Skip to content

Commit 54e3ed8

Browse files
simple-agent-manager[bot]raphaeltmclaude
authored
feat: auto-delete stopped workspaces after configurable TTL (#916)
* task: move stopped-workspace-auto-delete to active Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: auto-delete stopped workspaces after configurable TTL Extend NodeLifecycle DO to schedule workspace deletion 5 minutes after a workspace stops. This prevents disk exhaustion on nodes caused by accumulated stopped workspaces (Docker volumes with git repos, node_modules). - Add DEFAULT_WORKSPACE_STOPPED_TTL_MS (300000ms) to shared constants - Add scheduleWorkspaceDeletion/cancelWorkspaceDeletion to NodeLifecycle DO - Alarm handler processes expired deletions (VM agent DELETE + D1 update) - recalculateAlarm picks earliest of warm timeout and pending deletions - Schedule deletion from: stop route, cleanupTaskRun, cleanupOnFailure - Cancel deletion from: restart route (before TTL expires) - Cron safety-net in node-cleanup.ts for missed DO alarms - Configurable via WORKSPACE_STOPPED_TTL_MS env var Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(test): update existing tests for recalculateAlarm and new cleanup counter - warm-node-pooling integration test: setAlarm → recalculateAlarm, deleteAlarm → recalculateAlarm(null) - node-cleanup unit test: add stoppedWorkspacesDeleted to expected result, mock node-agent and project-data Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * task: archive stopped-workspace-auto-delete task Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address cloudflare-specialist review findings - [HIGH] Use deleteWorkspaceOnNode shared helper with proper JWT auth instead of raw IP fetch with X-User-ID header - [HIGH] Hoist getStoredState() before the deletion loop to avoid N redundant DO storage reads - [MEDIUM] Add status='stopped' guard to cron sweep D1 update (TOCTOU) - [MEDIUM] Use recalculateAlarm in destroying branch so pending workspace deletions are not delayed by retry window Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: wire scheduleWorkspaceDeletion into idle cleanup path The processExpiredCleanups idle timeout path stops workspaces but wasn't scheduling automatic deletion. Workspaces stopped via idle timeout now get deletion scheduled via the NodeLifecycle DO, matching the lifecycle route and task-runner paths. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * ci: trigger fresh CI run with updated PR body --------- Co-authored-by: Raphaël Titsworth-Morin <raphael@raphaeltm.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f970b9d commit 54e3ed8

15 files changed

Lines changed: 603 additions & 27 deletions

File tree

apps/api/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ BASE_DOMAIN=workspaces.example.com
229229

230230
# Workspace idle timeout (ProjectData DO)
231231
# WORKSPACE_IDLE_TIMEOUT_MS=7200000 # 2 hours — global default idle timeout before workspace is stopped (overridable per-project)
232+
# WORKSPACE_STOPPED_TTL_MS=300000 # 5 minutes — auto-delete stopped workspaces after this TTL
232233

233234
# Task agent configuration
234235
# DEFAULT_TASK_AGENT_TYPE=opencode # Agent used for autonomous task execution

apps/api/src/durable-objects/node-lifecycle.ts

Lines changed: 206 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,25 +14,34 @@
1414
* alarm() → on `warm`: sets `destroying`, updates D1, cron handles teardown
1515
* → on `active`: no-op (was claimed between schedule and fire)
1616
*
17+
* Workspace auto-deletion:
18+
* scheduleWorkspaceDeletion(workspaceId, userId) → stores pending deletion, recalculates alarm
19+
* cancelWorkspaceDeletion(workspaceId) → removes pending deletion, recalculates alarm
20+
* alarm() → also processes expired workspace deletions (calls VM agent, updates D1)
21+
*
1722
* Actual infrastructure destruction (Hetzner API, DNS) is handled by the
1823
* cron sweep, NOT by this DO — because user credentials are encrypted in D1
1924
* and must be decrypted with CREDENTIAL_ENCRYPTION_KEY (or ENCRYPTION_KEY
2025
* fallback) in the worker context via getCredentialEncryptionKey(env).
2126
*
2227
* See: specs/021-task-chat-architecture/tasks.md (Phase 5)
2328
*/
24-
import type { NodeLifecycleState,NodeLifecycleStatus } from '@simple-agent-manager/shared';
29+
import type { NodeLifecycleState, NodeLifecycleStatus } from '@simple-agent-manager/shared';
2530
import {
2631
DEFAULT_NODE_LIFECYCLE_ALARM_RETRY_MS,
2732
DEFAULT_NODE_WARM_TIMEOUT_MS,
33+
DEFAULT_WORKSPACE_STOPPED_TTL_MS,
2834
} from '@simple-agent-manager/shared';
2935
import { DurableObject } from 'cloudflare:workers';
3036

37+
import type { Env } from '../env';
3138
import { log } from '../lib/logger';
39+
import { deleteWorkspaceOnNode } from '../services/node-agent';
3240

3341
type NodeLifecycleEnv = {
3442
DATABASE: D1Database;
3543
NODE_WARM_TIMEOUT_MS?: string;
44+
WORKSPACE_STOPPED_TTL_MS?: string;
3645
};
3746

3847
interface StoredState {
@@ -45,6 +54,12 @@ interface StoredState {
4554
warmTimeoutOverrideMs?: number | null;
4655
}
4756

57+
interface PendingWorkspaceDeletion {
58+
workspaceId: string;
59+
userId: string;
60+
deleteAt: number;
61+
}
62+
4863
export class NodeLifecycle extends DurableObject<NodeLifecycleEnv> {
4964
/**
5065
* Mark a node as idle (warm). Called after the last workspace on the node
@@ -73,8 +88,8 @@ export class NodeLifecycle extends DurableObject<NodeLifecycleEnv> {
7388
};
7489
await this.ctx.storage.put('state', newState);
7590

76-
// Schedule (or reschedule) alarm
77-
await this.ctx.storage.setAlarm(now + warmTimeout);
91+
// Recalculate alarm considering both warm timeout and pending workspace deletions
92+
await this.recalculateAlarm(now + warmTimeout);
7893

7994
// Update D1 warm_since column
8095
await this.updateD1WarmSince(nodeId, new Date(now).toISOString());
@@ -84,7 +99,7 @@ export class NodeLifecycle extends DurableObject<NodeLifecycleEnv> {
8499

85100
/**
86101
* Mark a node as active. Called when a workspace starts on the node.
87-
* Cancels any pending warm timeout alarm.
102+
* Cancels any pending warm timeout alarm but preserves workspace deletion alarms.
88103
*/
89104
async markActive(): Promise<NodeLifecycleState> {
90105
const state = await this.getStoredState();
@@ -97,8 +112,8 @@ export class NodeLifecycle extends DurableObject<NodeLifecycleEnv> {
97112
state.warmSince = null;
98113
await this.ctx.storage.put('state', state);
99114

100-
// Cancel any pending alarm
101-
await this.ctx.storage.deleteAlarm();
115+
// Recalculate alarm — pending workspace deletions still need to fire
116+
await this.recalculateAlarm(null);
102117

103118
// Clear D1 warm_since
104119
await this.updateD1WarmSince(state.nodeId, null);
@@ -128,8 +143,8 @@ export class NodeLifecycle extends DurableObject<NodeLifecycleEnv> {
128143
state.warmSince = null;
129144
await this.ctx.storage.put('state', state);
130145

131-
// Cancel alarm
132-
await this.ctx.storage.deleteAlarm();
146+
// Recalculate alarm — pending workspace deletions still need to fire
147+
await this.recalculateAlarm(null);
133148

134149
// Clear D1 warm_since
135150
await this.updateD1WarmSince(state.nodeId, null);
@@ -148,19 +163,65 @@ export class NodeLifecycle extends DurableObject<NodeLifecycleEnv> {
148163
return this.toPublicState(state);
149164
}
150165

166+
// =========================================================================
167+
// Workspace auto-deletion scheduling
168+
// =========================================================================
169+
170+
/**
171+
* Schedule a stopped workspace for automatic deletion after the configured TTL.
172+
* Called when a workspace transitions to 'stopped' status.
173+
*/
174+
async scheduleWorkspaceDeletion(workspaceId: string, userId: string): Promise<void> {
175+
const ttl = this.getWorkspaceStoppedTtlMs();
176+
const deleteAt = Date.now() + ttl;
177+
178+
const entry: PendingWorkspaceDeletion = { workspaceId, userId, deleteAt };
179+
await this.ctx.storage.put(`ws-delete:${workspaceId}`, entry);
180+
181+
log.info('node_lifecycle.workspace_deletion_scheduled', {
182+
workspaceId,
183+
userId,
184+
deleteAt: new Date(deleteAt).toISOString(),
185+
ttlMs: ttl,
186+
});
187+
188+
await this.recalculateAlarm(await this.getWarmAlarmTime());
189+
}
190+
151191
/**
152-
* Alarm handler. Fires when the warm timeout expires.
192+
* Cancel a pending workspace deletion. Called when a workspace is restarted
193+
* before the TTL expires.
194+
*/
195+
async cancelWorkspaceDeletion(workspaceId: string): Promise<void> {
196+
await this.ctx.storage.delete(`ws-delete:${workspaceId}`);
197+
198+
log.info('node_lifecycle.workspace_deletion_cancelled', { workspaceId });
199+
200+
await this.recalculateAlarm(await this.getWarmAlarmTime());
201+
}
202+
203+
// =========================================================================
204+
// Alarm handler
205+
// =========================================================================
206+
207+
/**
208+
* Alarm handler. Fires when either:
209+
* 1. The warm timeout expires (node should be destroyed)
210+
* 2. A workspace deletion is due
153211
*
154-
* If the node is still warm, transitions to `destroying` and marks D1
155-
* for the cron sweep to handle actual infrastructure teardown.
156-
* If the node was claimed between schedule and fire, this is a no-op.
212+
* Processes expired workspace deletions first, then handles warm timeout.
157213
*/
158214
async alarm(): Promise<void> {
215+
// Process any expired workspace deletions
216+
await this.processExpiredDeletions();
217+
159218
const state = await this.getStoredState();
160219
if (!state) return;
161220

162221
// No-op if node was claimed (active) or already destroying
163222
if (state.status === 'active') {
223+
// Still recalculate alarm for any remaining pending workspace deletions
224+
await this.recalculateAlarm(null);
164225
return;
165226
}
166227

@@ -171,7 +232,18 @@ export class NodeLifecycle extends DurableObject<NodeLifecycleEnv> {
171232
return;
172233
}
173234

174-
// status === 'warm' → transition to destroying
235+
// status === 'warm' → check if warm timeout has actually expired
236+
if (state.warmSince) {
237+
const warmTimeout = state.warmTimeoutOverrideMs ?? this.getWarmTimeoutMs();
238+
const warmExpiry = state.warmSince + warmTimeout;
239+
if (Date.now() < warmExpiry) {
240+
// Warm timeout hasn't expired yet — alarm fired for workspace deletion only
241+
await this.recalculateAlarm(warmExpiry);
242+
return;
243+
}
244+
}
245+
246+
// Warm timeout expired → transition to destroying
175247
state.status = 'destroying';
176248
await this.ctx.storage.put('state', state);
177249

@@ -193,8 +265,8 @@ export class NodeLifecycle extends DurableObject<NodeLifecycleEnv> {
193265
nodeId: state.nodeId,
194266
error: err instanceof Error ? err.message : String(err),
195267
});
196-
// Schedule retry
197-
await this.ctx.storage.setAlarm(Date.now() + DEFAULT_NODE_LIFECYCLE_ALARM_RETRY_MS);
268+
// Schedule retry (use recalculateAlarm to not delay pending workspace deletions)
269+
await this.recalculateAlarm(Date.now() + DEFAULT_NODE_LIFECYCLE_ALARM_RETRY_MS);
198270
}
199271
}
200272

@@ -215,6 +287,15 @@ export class NodeLifecycle extends DurableObject<NodeLifecycleEnv> {
215287
return DEFAULT_NODE_WARM_TIMEOUT_MS;
216288
}
217289

290+
private getWorkspaceStoppedTtlMs(): number {
291+
const envValue = this.env.WORKSPACE_STOPPED_TTL_MS;
292+
if (envValue) {
293+
const parsed = parseInt(envValue, 10);
294+
if (Number.isFinite(parsed) && parsed > 0) return parsed;
295+
}
296+
return DEFAULT_WORKSPACE_STOPPED_TTL_MS;
297+
}
298+
218299
private toPublicState(state: StoredState): NodeLifecycleState {
219300
return {
220301
nodeId: state.nodeId,
@@ -238,4 +319,114 @@ export class NodeLifecycle extends DurableObject<NodeLifecycleEnv> {
238319
});
239320
}
240321
}
322+
323+
/**
324+
* Get all pending workspace deletions from DO storage.
325+
*/
326+
private async getPendingDeletions(): Promise<Map<string, PendingWorkspaceDeletion>> {
327+
return await this.ctx.storage.list<PendingWorkspaceDeletion>({ prefix: 'ws-delete:' });
328+
}
329+
330+
/**
331+
* Process all workspace deletions whose deleteAt time has passed.
332+
*/
333+
private async processExpiredDeletions(): Promise<void> {
334+
const pending = await this.getPendingDeletions();
335+
const now = Date.now();
336+
337+
// Load state once to get nodeId — avoids N storage reads in the loop
338+
const state = await this.getStoredState();
339+
if (!state) return;
340+
341+
for (const [key, entry] of pending) {
342+
if (entry.deleteAt > now) continue;
343+
344+
try {
345+
await this.deleteWorkspace(state.nodeId, entry.workspaceId, entry.userId);
346+
await this.ctx.storage.delete(key);
347+
348+
log.info('node_lifecycle.workspace_auto_deleted', {
349+
workspaceId: entry.workspaceId,
350+
userId: entry.userId,
351+
});
352+
} catch (err) {
353+
log.error('node_lifecycle.workspace_deletion_failed', {
354+
workspaceId: entry.workspaceId,
355+
userId: entry.userId,
356+
error: err instanceof Error ? err.message : String(err),
357+
});
358+
// Leave the entry for retry on next alarm. Push deleteAt forward slightly
359+
// to avoid tight retry loops.
360+
entry.deleteAt = now + DEFAULT_NODE_LIFECYCLE_ALARM_RETRY_MS;
361+
await this.ctx.storage.put(key, entry);
362+
}
363+
}
364+
}
365+
366+
/**
367+
* Delete a workspace: call VM agent to remove Docker container + volume,
368+
* then update D1 status to 'deleted'.
369+
*/
370+
private async deleteWorkspace(nodeId: string, workspaceId: string, userId: string): Promise<void> {
371+
// Call VM agent DELETE endpoint via shared helper (handles JWT auth, proper URL routing)
372+
try {
373+
await deleteWorkspaceOnNode(nodeId, workspaceId, this.env as unknown as Env, userId);
374+
} catch (err) {
375+
// If the node is unreachable (already destroyed), log but don't fail
376+
// The D1 status update below still marks the workspace as deleted
377+
log.warn('node_lifecycle.workspace_delete_vm_agent_failed', {
378+
workspaceId,
379+
nodeId,
380+
error: err instanceof Error ? err.message : String(err),
381+
});
382+
}
383+
384+
// Update D1 workspace status to 'deleted'
385+
const now = new Date().toISOString();
386+
await this.env.DATABASE.prepare(
387+
`UPDATE workspaces SET status = 'deleted', updated_at = ? WHERE id = ? AND status = 'stopped'`
388+
).bind(now, workspaceId).run();
389+
390+
// Clean up any agent_sessions referencing this workspace (best-effort)
391+
try {
392+
await this.env.DATABASE.prepare(
393+
`UPDATE agent_sessions SET status = 'completed', updated_at = ? WHERE workspace_id = ? AND status NOT IN ('completed', 'failed')`
394+
).bind(now, workspaceId).run();
395+
} catch {
396+
// best-effort
397+
}
398+
}
399+
400+
/**
401+
* Get the warm alarm time if the node is in warm state.
402+
*/
403+
private async getWarmAlarmTime(): Promise<number | null> {
404+
const state = await this.getStoredState();
405+
if (!state || state.status !== 'warm' || !state.warmSince) return null;
406+
const warmTimeout = state.warmTimeoutOverrideMs ?? this.getWarmTimeoutMs();
407+
return state.warmSince + warmTimeout;
408+
}
409+
410+
/**
411+
* Recalculate and set the alarm to the earliest time needed:
412+
* either the warm timeout expiry or the earliest pending workspace deletion.
413+
*
414+
* @param warmAlarmTime - The warm timeout expiry time, or null if not applicable
415+
*/
416+
private async recalculateAlarm(warmAlarmTime: number | null): Promise<void> {
417+
let earliest = warmAlarmTime;
418+
419+
const pending = await this.getPendingDeletions();
420+
for (const [, entry] of pending) {
421+
if (earliest === null || entry.deleteAt < earliest) {
422+
earliest = entry.deleteAt;
423+
}
424+
}
425+
426+
if (earliest !== null) {
427+
await this.ctx.storage.setAlarm(earliest);
428+
} else {
429+
await this.ctx.storage.deleteAlarm();
430+
}
431+
}
241432
}

apps/api/src/durable-objects/project-data/index.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,24 @@ export class ProjectData extends DurableObject<Env> {
350350
(type, payload, sid) => this.broadcastEvent(type, payload, sid), () => this.scheduleSummarySync());
351351
await idleCleanup.processExpiredCleanups(this.sql, this.env,
352352
(taskId) => idleCleanup.completeTaskInD1(this.env.DATABASE, taskId),
353-
(workspaceId) => idleCleanup.stopWorkspaceInD1(this.env.DATABASE, workspaceId),
353+
async (workspaceId) => {
354+
await idleCleanup.stopWorkspaceInD1(this.env.DATABASE, workspaceId);
355+
// Schedule automatic deletion after TTL (best-effort)
356+
try {
357+
const workerEnv = this.env as unknown as import('../../env').Env;
358+
const wsRow = await workerEnv.DATABASE.prepare(
359+
'SELECT node_id, user_id FROM workspaces WHERE id = ?'
360+
).bind(workspaceId).first<{ node_id: string | null; user_id: string }>();
361+
if (wsRow?.node_id) {
362+
const doId = workerEnv.NODE_LIFECYCLE.idFromName(wsRow.node_id);
363+
const stub = workerEnv.NODE_LIFECYCLE.get(doId);
364+
await (stub as unknown as import('../node-lifecycle').NodeLifecycle)
365+
.scheduleWorkspaceDeletion(workspaceId, wsRow.user_id);
366+
}
367+
} catch {
368+
// Best-effort — cron safety-net will catch it
369+
}
370+
},
354371
(type, payload, sid) => this.broadcastEvent(type, payload, sid), () => this.scheduleSummarySync());
355372

356373
// Mailbox delivery sweep: expire stale messages and re-queue unacked ones

apps/api/src/durable-objects/task-runner/state-machine.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,20 @@ export async function cleanupOnFailure(
350350
error: err instanceof Error ? err.message : String(err),
351351
});
352352
}
353+
354+
// Schedule automatic deletion after TTL (best-effort)
355+
try {
356+
const doId = rc.env.NODE_LIFECYCLE.idFromName(state.stepResults.nodeId);
357+
const stub = rc.env.NODE_LIFECYCLE.get(doId);
358+
await (stub as unknown as import('../node-lifecycle').NodeLifecycle)
359+
.scheduleWorkspaceDeletion(state.stepResults.workspaceId, state.userId);
360+
} catch (err) {
361+
log.warn('task_runner_do.cleanup.schedule_deletion_failed', {
362+
taskId: state.taskId,
363+
workspaceId: state.stepResults.workspaceId,
364+
error: err instanceof Error ? err.message : String(err),
365+
});
366+
}
353367
}
354368

355369
// Clean up auto-provisioned node. If a workspace exists, cleanupTaskRun

apps/api/src/env.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,8 @@ export interface Env {
139139
ORPHANED_WORKSPACE_GRACE_PERIOD_MS?: string;
140140
// Workspace idle timeout (global default, overridable per-project)
141141
WORKSPACE_IDLE_TIMEOUT_MS?: string;
142+
// Auto-delete stopped workspaces after this TTL (default: 300000 = 5 minutes)
143+
WORKSPACE_STOPPED_TTL_MS?: string;
142144
// Task agent configuration
143145
DEFAULT_TASK_AGENT_TYPE?: string;
144146
// Built-in profile model overrides (defaults: claude-sonnet-4-5-20250929, claude-opus-4-6)

0 commit comments

Comments
 (0)