Skip to content

Commit ad7044f

Browse files
Phase 3a: runtime-neutral cf-container session hibernate/wake/restore (#1562)
Phase 3a: runtime-neutral session hibernate/wake
2 parents aaa9537 + b16496a commit ad7044f

50 files changed

Lines changed: 3144 additions & 127 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/skills/env-reference/SKILL.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,13 @@ See `apps/api/.env.example` for the full list. Key variables:
6666
- `CF_CONTAINER_SLEEP_AFTER` — Container idle sleep duration for instant-session runtime (default: `10m`)
6767
- `CF_CONTAINER_VM_AGENT_PORT` — vm-agent standalone HTTP port inside the raw container (default: `8080`)
6868
- `CF_CONTAINER_PORT_READY_TIMEOUT_MS` — Max wait for vm-agent port readiness (default: `30000`)
69-
- `CF_CONTAINER_WORKSPACE_BASE_DIR` — Base checkout directory inside raw containers (default: `/workspaces`)
69+
- `$1
70+
- `SESSION_SNAPSHOT_TTL_DAYS` — Retention for hibernated session snapshots; deployment also provisions matching R2 prefix expiration (default: `7`)
71+
- `SESSION_SNAPSHOT_TOTAL_BUDGET_BYTES` — Maximum bytes accepted for one snapshot artifact (default: `104857600`)
72+
- `SESSION_SNAPSHOT_ENTRY_THRESHOLD_BYTES` — Per-file threshold before snapshot content is visibly skipped (default: `52428800`)
73+
- `SESSION_SNAPSHOT_TRANSFER_IDLE_TIMEOUT_MS` — Progress-idle timeout for snapshot upload/download (default: `30000`)
74+
- `SESSION_SNAPSHOT_JSON_BODY_MAX_BYTES` — Maximum snapshot coordination JSON body (default: `262144`)
75+
- `SESSION_SNAPSHOT_R2_PREFIX` — Private R2 object prefix for session snapshots (default: `session-snapshots`)
7076

7177
### Devcontainer Cache
7278

.github/workflows/deploy-reusable.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,9 @@ jobs:
358358
HETZNER_BASE_IMAGE: ${{ vars.HETZNER_BASE_IMAGE }}
359359
ARTIFACTS_BINDING_ENABLED: ${{ vars.ARTIFACTS_BINDING_ENABLED }}
360360
CF_CONTAINER_ENABLED: ${{ vars.CF_CONTAINER_ENABLED }}
361+
CF_CONTAINER_SLEEP_AFTER: ${{ vars.CF_CONTAINER_SLEEP_AFTER }}
361362
CF_CONTAINER_PORT_READY_TIMEOUT_MS: ${{ vars.CF_CONTAINER_PORT_READY_TIMEOUT_MS }}
363+
CF_CONTAINER_WAKE_TIMEOUT_MS: ${{ vars.CF_CONTAINER_WAKE_TIMEOUT_MS }}
362364
CF_CONTAINER_VM_AGENT_PORT: ${{ vars.CF_CONTAINER_VM_AGENT_PORT }}
363365
SANDBOX_ENABLED: ${{ vars.SANDBOX_ENABLED }}
364366
SANDBOX_EXEC_TIMEOUT_MS: ${{ vars.SANDBOX_EXEC_TIMEOUT_MS }}

apps/api/.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ BASE_DOMAIN=workspaces.example.com
5252
# CF_CONTAINER_VM_AGENT_PORT=8080
5353
# CF_CONTAINER_PORT_READY_TIMEOUT_MS=30000
5454
# CF_CONTAINER_WORKSPACE_BASE_DIR=/workspaces
55+
# SESSION_SNAPSHOT_TTL_DAYS=7
56+
# SESSION_SNAPSHOT_TOTAL_BUDGET_BYTES=104857600
57+
# SESSION_SNAPSHOT_ENTRY_THRESHOLD_BYTES=52428800
58+
# SESSION_SNAPSHOT_TRANSFER_IDLE_TIMEOUT_MS=30000
59+
# SESSION_SNAPSHOT_JSON_BODY_MAX_BYTES=262144
60+
# SESSION_SNAPSHOT_R2_PREFIX=session-snapshots
5561

5662
# User approval / invite-only mode
5763
# When "true", new users require admin approval before accessing the platform.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
-- Runtime-neutral hibernate/restore snapshots for agent sessions.
2+
CREATE TABLE session_snapshots (
3+
id TEXT PRIMARY KEY,
4+
project_id TEXT REFERENCES projects(id) ON DELETE SET NULL,
5+
workspace_id TEXT REFERENCES workspaces(id) ON DELETE SET NULL,
6+
node_id TEXT REFERENCES nodes(id) ON DELETE SET NULL,
7+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
8+
chat_session_id TEXT NOT NULL,
9+
agent_session_id TEXT,
10+
runtime TEXT NOT NULL,
11+
status TEXT NOT NULL DEFAULT 'pending',
12+
degradation TEXT NOT NULL DEFAULT 'none',
13+
home_r2_key TEXT,
14+
wip_r2_key TEXT,
15+
manifest_r2_key TEXT NOT NULL,
16+
base_commit TEXT,
17+
expires_at TEXT NOT NULL,
18+
manifest_json TEXT,
19+
restore_status TEXT,
20+
restore_message TEXT,
21+
restored_at TEXT,
22+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
23+
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
24+
);
25+
26+
CREATE UNIQUE INDEX idx_session_snapshots_chat_session_id
27+
ON session_snapshots(chat_session_id);
28+
29+
CREATE INDEX idx_session_snapshots_workspace_id
30+
ON session_snapshots(workspace_id);
31+
32+
CREATE INDEX idx_session_snapshots_expires_at
33+
ON session_snapshots(expires_at);

apps/api/src/db/schema.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1044,6 +1044,47 @@ export const agentSessions = sqliteTable(
10441044
})
10451045
);
10461046

1047+
// =============================================================================
1048+
// Runtime-neutral Session Snapshots
1049+
// =============================================================================
1050+
export const sessionSnapshots = sqliteTable(
1051+
'session_snapshots',
1052+
{
1053+
id: text('id').primaryKey(),
1054+
projectId: text('project_id').references(() => projects.id, { onDelete: 'set null' }),
1055+
workspaceId: text('workspace_id').references(() => workspaces.id, { onDelete: 'set null' }),
1056+
nodeId: text('node_id').references(() => nodes.id, { onDelete: 'set null' }),
1057+
userId: text('user_id')
1058+
.notNull()
1059+
.references(() => users.id, { onDelete: 'cascade' }),
1060+
chatSessionId: text('chat_session_id').notNull(),
1061+
agentSessionId: text('agent_session_id'),
1062+
runtime: text('runtime').notNull(),
1063+
status: text('status').notNull().default('pending'),
1064+
degradation: text('degradation').notNull().default('none'),
1065+
homeR2Key: text('home_r2_key'),
1066+
wipR2Key: text('wip_r2_key'),
1067+
manifestR2Key: text('manifest_r2_key').notNull(),
1068+
baseCommit: text('base_commit'),
1069+
expiresAt: text('expires_at').notNull(),
1070+
manifestJson: text('manifest_json'),
1071+
restoreStatus: text('restore_status'),
1072+
restoreMessage: text('restore_message'),
1073+
restoredAt: text('restored_at'),
1074+
createdAt: text('created_at')
1075+
.notNull()
1076+
.default(sql`CURRENT_TIMESTAMP`),
1077+
updatedAt: text('updated_at')
1078+
.notNull()
1079+
.default(sql`CURRENT_TIMESTAMP`),
1080+
},
1081+
(table) => ({
1082+
chatSessionIdUnique: uniqueIndex('idx_session_snapshots_chat_session_id').on(table.chatSessionId),
1083+
workspaceIdIdx: index('idx_session_snapshots_workspace_id').on(table.workspaceId),
1084+
expiresAtIdx: index('idx_session_snapshots_expires_at').on(table.expiresAt),
1085+
})
1086+
);
1087+
10471088
// =============================================================================
10481089
// Agent Settings (per-user, per-agent configuration)
10491090
// =============================================================================
@@ -1545,6 +1586,8 @@ export type Workspace = typeof workspaces.$inferSelect;
15451586
export type NewWorkspace = typeof workspaces.$inferInsert;
15461587
export type AgentSession = typeof agentSessions.$inferSelect;
15471588
export type NewAgentSession = typeof agentSessions.$inferInsert;
1589+
export type SessionSnapshot = typeof sessionSnapshots.$inferSelect;
1590+
export type NewSessionSnapshot = typeof sessionSnapshots.$inferInsert;
15481591
export type UIStandard = typeof uiStandards.$inferSelect;
15491592
export type NewUIStandard = typeof uiStandards.$inferInsert;
15501593
export type ThemeToken = typeof themeTokens.$inferSelect;

apps/api/src/durable-objects/vm-agent-container.ts

Lines changed: 186 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { Container, switchPort } from '@cloudflare/containers';
2-
import { eq } from 'drizzle-orm';
2+
import { desc, eq } from 'drizzle-orm';
33
import { drizzle } from 'drizzle-orm/d1';
44

55
import * as schema from '../db/schema';
66
import type { Env } from '../env';
77
import { log } from '../lib/logger';
8+
import { signCallbackToken, signNodeCallbackToken, signNodeManagementToken } from '../services/jwt';
89

910
export const DEFAULT_CF_CONTAINER_SLEEP_AFTER = '1h';
1011
export const DEFAULT_CF_CONTAINER_ACTIVE_WORK_MAX_MS = 2 * 60 * 60 * 1000;
@@ -45,14 +46,20 @@ interface ActiveWorkState {
4546

4647
const ACTIVE_WORK_KEY = 'activeWork';
4748
const KEEPALIVE_CALLBACK = 'renewActiveWorkKeepalive';
48-
const SLEEPING_RESPONSE = 'Container is asleep; wake/rehydrate is not implemented yet.';
49+
const WAKE_DEGRADED_RESPONSE = 'Workspace woke with degraded snapshot restore; retry the prompt or fork from transcript history.';
4950

5051
export class VmAgentContainer extends Container<Env> {
5152
defaultPort = 8080;
5253
requiredPorts = [8080];
5354
sleepAfter = DEFAULT_CF_CONTAINER_SLEEP_AFTER;
5455
enableInternet = true;
5556

57+
// Serializes wake-from-snapshot so two concurrent requests to a sleeping
58+
// container do not both launch a fresh container + restore. DO request
59+
// handlers interleave across `await`, so the sleeping-check + wake must run
60+
// as one critical section (see .claude/rules/45).
61+
private wakeChain: Promise<unknown> = Promise.resolve();
62+
5663
constructor(ctx: DurableObjectState<Record<string, never>>, env: Env) {
5764
super(ctx, env);
5865
const configuredPort = Number.parseInt(env.CF_CONTAINER_VM_AGENT_PORT || env.SANDBOX_VM_AGENT_PORT || '', 10);
@@ -108,12 +115,18 @@ export class VmAgentContainer extends Container<Env> {
108115
}
109116

110117
async proxyHttp(request: Request, port?: number): Promise<Response> {
111-
const state = await this.getState();
118+
let state = await this.getState();
112119
const lifecycleStatus = await this.ctx.storage.get<LifecycleStatus>('lifecycleStatus');
113120
if (lifecycleStatus === 'sleeping') {
114-
// Phase 3 of idea 01KX4KSXEXQMP41KS34TW9EN01 will replace this temporary
115-
// response with wake/rehydrate before proxying the request.
116-
return new Response(SLEEPING_RESPONSE, { status: 503 });
121+
const wake = await this.ensureAwake();
122+
if (!wake.ok) {
123+
return new Response(wake.message || WAKE_DEGRADED_RESPONSE, { status: 503 });
124+
}
125+
// wakeFromSnapshot launched a fresh container and restored the session.
126+
// Re-read the container state so the stopped-check below reflects the
127+
// now-running container instead of the pre-wake stopped snapshot,
128+
// otherwise a successfully-woken session is wrongly rejected with 410.
129+
state = await this.getState();
117130
}
118131
if (state.status === 'stopped' || state.status === 'stopped_with_code') {
119132
return new Response('Container is stopped; create a new instant session.', { status: 410 });
@@ -232,12 +245,16 @@ export class VmAgentContainer extends Container<Env> {
232245
}
233246

234247
override async fetch(request: Request): Promise<Response> {
235-
const state = await this.getState();
248+
let state = await this.getState();
236249
const lifecycleStatus = await this.ctx.storage.get<LifecycleStatus>('lifecycleStatus');
237250
if (lifecycleStatus === 'sleeping') {
238-
// Phase 3 of idea 01KX4KSXEXQMP41KS34TW9EN01 will replace this temporary
239-
// response with wake/rehydrate before proxying the request.
240-
return new Response(SLEEPING_RESPONSE, { status: 503 });
251+
const wake = await this.ensureAwake();
252+
if (!wake.ok) {
253+
return new Response(wake.message || WAKE_DEGRADED_RESPONSE, { status: 503 });
254+
}
255+
// Re-read the container state after wake so the stopped-check reflects
256+
// the freshly-launched container, not the pre-wake stopped snapshot.
257+
state = await this.getState();
241258
}
242259
if (state.status === 'stopped' || state.status === 'stopped_with_code') {
243260
return new Response('Container is stopped; create a new instant session.', { status: 410 });
@@ -299,6 +316,165 @@ export class VmAgentContainer extends Container<Env> {
299316
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_CF_CONTAINER_KEEPALIVE_RENEW_INTERVAL_MS;
300317
}
301318

319+
/**
320+
* Wake a sleeping container exactly once under concurrency. Serializes on
321+
* `wakeChain` and re-reads `lifecycleStatus` inside the critical section, so a
322+
* second request that arrives while the first is waking sees `running` and
323+
* skips a duplicate launch/restore instead of racing it (see rule 45).
324+
*/
325+
private async ensureAwake(): Promise<{ ok: boolean; message?: string }> {
326+
const run = this.wakeChain.then(async () => {
327+
const status = await this.ctx.storage.get<LifecycleStatus>('lifecycleStatus');
328+
if (status !== 'sleeping') {
329+
return { ok: true };
330+
}
331+
return this.wakeFromSnapshot();
332+
});
333+
// Keep the chain alive even if this wake rejects, so a failure does not
334+
// permanently wedge all future wakes.
335+
this.wakeChain = run.then(
336+
() => undefined,
337+
() => undefined
338+
);
339+
return run;
340+
}
341+
342+
private async wakeFromSnapshot(): Promise<{ ok: boolean; message?: string }> {
343+
const config = await this.ctx.storage.get<VmAgentContainerLaunchConfig>('launchConfig');
344+
if (!config) {
345+
return { ok: false, message: 'Container launch configuration is unavailable.' };
346+
}
347+
const db = drizzle(this.env.DATABASE, { schema });
348+
const workspace = await db
349+
.select({
350+
userId: schema.workspaces.userId,
351+
chatSessionId: schema.workspaces.chatSessionId,
352+
})
353+
.from(schema.workspaces)
354+
.where(eq(schema.workspaces.id, config.workspaceId))
355+
.get();
356+
if (!workspace?.chatSessionId) {
357+
return { ok: false, message: 'Workspace session metadata is unavailable.' };
358+
}
359+
const agentSession = await db
360+
.select({ id: schema.agentSessions.id, agentType: schema.agentSessions.agentType })
361+
.from(schema.agentSessions)
362+
.where(eq(schema.agentSessions.workspaceId, config.workspaceId))
363+
.orderBy(desc(schema.agentSessions.updatedAt))
364+
.get();
365+
if (!agentSession) {
366+
return { ok: false, message: 'Agent session metadata is unavailable.' };
367+
}
368+
369+
log.info('vm_agent_container_wake_started', {
370+
nodeId: config.nodeId,
371+
workspaceId: config.workspaceId,
372+
chatSessionId: workspace.chatSessionId,
373+
agentSessionId: agentSession.id,
374+
});
375+
376+
await this.ctx.storage.put('lifecycleStatus', 'launching' satisfies LifecycleStatus);
377+
// The container's CALLBACK_TOKEN must be node-scoped to match the initial
378+
// launch (see launchInstantSession): the vm-agent uses it for node callbacks
379+
// (error/activity/message reporting) which reject workspace-scoped tokens.
380+
// Using a workspace-scoped token here caused restored sessions to accept a
381+
// prompt (200) but silently fail to report the agent's reply back (403
382+
// "Insufficient token scope"), so no answer appeared after wake.
383+
const callbackToken = await signNodeCallbackToken(config.nodeId, this.env);
384+
await this.launch(config, { nodeCallbackToken: callbackToken });
385+
386+
// The fresh container never ran create-workspace, so its workspace-scoped
387+
// runtime.CallbackToken is unset. The message reporter and snapshot
388+
// callbacks require it (they do NOT fall back to the node-scoped token), so
389+
// pass it on the restore request; without it, restored sessions accept a
390+
// prompt but silently discard the agent's reply ("no auth token").
391+
const workspaceCallbackToken = await signCallbackToken(config.workspaceId, this.env);
392+
const { token } = await signNodeManagementToken(workspace.userId, config.nodeId, config.workspaceId, this.env);
393+
const restoreUrl = new URL(`http://localhost:${config.vmAgentPort}/workspaces/${config.workspaceId}/agent-sessions/${agentSession.id}/restore`);
394+
const restoreResponse = await this.containerFetch(
395+
new Request(restoreUrl.toString(), {
396+
method: 'POST',
397+
headers: {
398+
Authorization: `Bearer ${token}`,
399+
'Content-Type': 'application/json',
400+
'X-SAM-Node-Id': config.nodeId,
401+
'X-SAM-Workspace-Id': config.workspaceId,
402+
},
403+
body: JSON.stringify({
404+
chatSessionId: workspace.chatSessionId,
405+
runtime: 'cf-container',
406+
agentType: agentSession.agentType,
407+
workspaceCallbackToken,
408+
}),
409+
}),
410+
config.vmAgentPort
411+
);
412+
const restoreBody = await restoreResponse.text().catch(() => '');
413+
if (!restoreResponse.ok) {
414+
await this.markWakeDegraded(config, restoreBody || `restore failed with HTTP ${restoreResponse.status}`);
415+
return { ok: false, message: restoreBody || 'Session restore failed.' };
416+
}
417+
let restoreStatus = '';
418+
try {
419+
const parsed = JSON.parse(restoreBody) as { status?: unknown };
420+
restoreStatus = typeof parsed.status === 'string' ? parsed.status : '';
421+
} catch {
422+
// A successful restore must provide an explicit machine-readable status.
423+
}
424+
if (restoreStatus !== 'restored') {
425+
const message = restoreBody || 'Session restore did not report restored status.';
426+
await this.markWakeDegraded(config, message);
427+
return { ok: false, message };
428+
}
429+
430+
const now = new Date().toISOString();
431+
await db.update(schema.nodes).set({
432+
status: 'running',
433+
healthStatus: 'healthy',
434+
errorMessage: null,
435+
updatedAt: now,
436+
}).where(eq(schema.nodes.id, config.nodeId));
437+
await db.update(schema.workspaces).set({
438+
status: 'running',
439+
errorMessage: null,
440+
updatedAt: now,
441+
}).where(eq(schema.workspaces.id, config.workspaceId));
442+
await db.update(schema.agentSessions).set({
443+
status: 'running',
444+
errorMessage: null,
445+
updatedAt: now,
446+
}).where(eq(schema.agentSessions.id, agentSession.id));
447+
await this.ctx.storage.put('lifecycleStatus', 'running' satisfies LifecycleStatus);
448+
449+
log.info('vm_agent_container_wake_completed', {
450+
nodeId: config.nodeId,
451+
workspaceId: config.workspaceId,
452+
chatSessionId: workspace.chatSessionId,
453+
agentSessionId: agentSession.id,
454+
});
455+
return { ok: true };
456+
}
457+
458+
private async markWakeDegraded(config: VmAgentContainerLaunchConfig, message: string): Promise<void> {
459+
const now = new Date().toISOString();
460+
const db = drizzle(this.env.DATABASE, { schema });
461+
await db.update(schema.workspaces).set({
462+
status: 'recovery',
463+
errorMessage: message,
464+
updatedAt: now,
465+
}).where(eq(schema.workspaces.id, config.workspaceId));
466+
await db.update(schema.agentSessions).set({
467+
status: 'error',
468+
errorMessage: message,
469+
updatedAt: now,
470+
}).where(eq(schema.agentSessions.workspaceId, config.workspaceId));
471+
log.warn('vm_agent_container_wake_degraded', {
472+
nodeId: config.nodeId,
473+
workspaceId: config.workspaceId,
474+
message,
475+
});
476+
}
477+
302478
private async replaceKeepaliveSchedule(delayMs: number): Promise<void> {
303479
await this.clearKeepaliveSchedule();
304480
await this.schedule(Math.max(1, Math.ceil(delayMs / 1000)), KEEPALIVE_CALLBACK);

apps/api/src/env.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,12 @@ export interface Env {
140140
COMPOSE_IMAGE_ARTIFACT_CLEANUP_BATCH_SIZE?: string; // Max abandoned compose artifacts to delete per cleanup run (default: 50)
141141
COMPOSE_IMAGE_ARTIFACT_CLEANUP_INTERVAL_HOURS?: string; // Minimum hours between R2 cleanup scans from cron (default: 24)
142142
COMPOSE_IMAGE_ARTIFACT_CLEANUP_LAST_RUN_KV_KEY?: string; // KV key for cleanup interval gating
143+
SESSION_SNAPSHOT_TTL_DAYS?: string; // Runtime hibernate snapshot retention (default: 7)
144+
SESSION_SNAPSHOT_R2_PREFIX?: string; // R2 key prefix for runtime hibernate snapshots
145+
SESSION_SNAPSHOT_TOTAL_BUDGET_BYTES?: string; // Max combined home/WIP snapshot size (default: 104857600)
146+
SESSION_SNAPSHOT_ENTRY_THRESHOLD_BYTES?: string; // Max individual file/dir included by vm-agent scanner (default: 52428800)
147+
SESSION_SNAPSHOT_TRANSFER_IDLE_TIMEOUT_MS?: string; // No-progress upload/download watchdog window (default: 30000)
148+
SESSION_SNAPSHOT_JSON_BODY_MAX_BYTES?: string; // Max snapshot control-plane JSON request size (default: 262144)
143149
DEPLOY_ACME_EMAIL?: string; // Contact email for deployment-node ACME certificates
144150
DEPLOY_ACME_CA?: string; // ACME CA directory override for deployment nodes
145151
DEPLOY_COMPOSE_CMD?: string; // Docker Compose command override on deployment nodes
@@ -772,6 +778,7 @@ export interface Env {
772778
CF_CONTAINER_KEEPALIVE_RENEW_INTERVAL_MS?: string; // Active-work renewActivityTimeout interval (default: 300000)
773779
CF_CONTAINER_VM_AGENT_PORT?: string; // vm-agent standalone HTTP port inside the raw container (default: 8080)
774780
CF_CONTAINER_PORT_READY_TIMEOUT_MS?: string; // Max time to wait for vm-agent port readiness (default: 30000)
781+
CF_CONTAINER_WAKE_TIMEOUT_MS?: string; // Max time for launch + restore before forwarding a wake request (default: 120000)
775782
CF_CONTAINER_WORKSPACE_BASE_DIR?: string; // Base checkout dir inside raw container (default: /workspaces)
776783
// Legacy Sandbox SDK prototype (admin-only)
777784
SANDBOX_ENABLED?: string; // Legacy/fallback kill switch for sandbox routes and older staging config (default: false)

0 commit comments

Comments
 (0)