From 16961ec36fc33581c305cbe5c9bcbe19ca1005b2 Mon Sep 17 00:00:00 2001 From: owner Date: Thu, 13 Aug 2026 17:58:03 +0300 Subject: [PATCH] feat: add durable task checkpoints and resume --- server/index.test.ts | 10 +++++++ server/index.ts | 53 +++++++++++++++++++++++++++++++++++- server/store.test.ts | 20 ++++++++++++++ server/store.ts | 64 +++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 145 insertions(+), 2 deletions(-) diff --git a/server/index.test.ts b/server/index.test.ts index bcc8022709..721be13c65 100644 --- a/server/index.test.ts +++ b/server/index.test.ts @@ -180,6 +180,16 @@ describe("harness HTTP API", () => { expect(missing.status).toBe(404); }); + it("lists checkpoints and rejects invalid resume requests safely", async () => { + const { body } = await api("GET", "/api/bots"); + const bot = body.bots[0]; + const list = await api("GET", `/api/bots/${bot.id}/checkpoints`); + expect(list.status).toBe(200); + expect(list.body.checkpoints).toEqual([]); + const missing = await api("POST", `/api/bots/${bot.id}/checkpoints/no-such-checkpoint/resume`); + expect(missing.status).toBe(404); + }); + it("saves config keys write-only and reports booleans", async () => { const before = await api("GET", "/api/config"); expect(before.body.box).toEqual({ configured: false }); diff --git a/server/index.ts b/server/index.ts index 46dd2a0fb4..fe15907a60 100644 --- a/server/index.ts +++ b/server/index.ts @@ -214,6 +214,16 @@ bus.subscribe((event: RuntimeEvent) => { break; case "turn.completed": { if (bot) { + const checkpoint = store.runningCheckpoint(bot.id); + if (checkpoint) { + const settled = store.updateCheckpoint(bot.id, checkpoint.id, { + status: event.ok ? "completed" : "interrupted", + ...(event.ok ? {} : { reason: event.stopReason || "turn stopped" }), + activeLeafId: store.activeLeaf(bot.threadId), + lastMessageId: store.messagesFor(bot.threadId).at(-1)?.id, + }); + if (settled) broadcast({ kind: "checkpoint", botId: bot.id, checkpoint: settled }); + } // the last live frame becomes a settled inline screen message — // the screenshot-in-chat moment const frame = stopScreenPoller(bot.id); @@ -312,7 +322,7 @@ function readCuaConnection(): { command: string; args: string[]; env: Record { if (m && method === "POST") { const bot = store.bot(m[1]); if (!bot) return json(res, 404, { error: "no such bot" }); + const checkpoint = store.runningCheckpoint(bot.id); + if (checkpoint) { + const stopped = store.updateCheckpoint(bot.id, checkpoint.id, { status: "interrupted", reason: "user interrupted" }); + if (stopped) broadcast({ kind: "checkpoint", botId: bot.id, checkpoint: stopped }); + } const instance = registry.get(bot.modelSelection.instanceId); await instance?.adapter.interruptTurn(bot.threadId); return json(res, 200, { ok: true }); } + m = path.match(/^\/api\/bots\/([\w-]+)\/checkpoints$/); + if (m && method === "GET") { + const bot = store.bot(m[1]); + if (!bot) return json(res, 404, { error: "no such bot" }); + return json(res, 200, { checkpoints: store.checkpoints(bot.id) }); + } + m = path.match(/^\/api\/bots\/([\w-]+)\/checkpoints\/([\w-]+)\/resume$/); + if (m && method === "POST") { + const bot = store.bot(m[1]); + if (!bot) return json(res, 404, { error: "no such bot" }); + if (bot.busy) return json(res, 409, { error: "the bot is already working" }); + const checkpoint = store.checkpoint(bot.id, m[2]); + if (!checkpoint) return json(res, 404, { error: "no such checkpoint" }); + if (checkpoint.status === "completed") return json(res, 409, { error: "completed checkpoints cannot be resumed" }); + if (checkpoint.modelSelection.instanceId !== bot.modelSelection.instanceId || checkpoint.modelSelection.model !== bot.modelSelection.model) { + return json(res, 409, { error: "switch back to the checkpoint's model before resuming" }); + } + if (!registry.get(bot.modelSelection.instanceId)) return json(res, 409, { error: "checkpoint provider is unavailable" }); + if (checkpoint.activeLeafId && !store.setActiveLeaf(bot.threadId, checkpoint.activeLeafId)) { + return json(res, 409, { error: "checkpoint conversation branch is no longer available" }); + } + const body = await readBody(req); + const instruction = typeof body.instruction === "string" && body.instruction.trim() + ? body.instruction.trim() + : "Continue the interrupted task from this checkpoint. Review the conversation and complete the next safe step."; + await startTurn(bot.id, instruction, { checkpointId: checkpoint.id }); + return json(res, 202, { ok: true, checkpoint: store.checkpoint(bot.id, checkpoint.id) }); + } + // identity handshake for the packaged app's port fallback: the forked // child proves it is OURS by echoing its pid (a stray dev server has // the same API shape but a different pid) diff --git a/server/store.test.ts b/server/store.test.ts index e2de098aac..20893b53d8 100644 --- a/server/store.test.ts +++ b/server/store.test.ts @@ -83,6 +83,26 @@ describe("Store", () => { expect(reloaded.bot(bot.id)?.resumeCursors).toEqual({ claude: "sess-abc", codex: "thread-xyz" }); }); + it("persists a checkpoint pointer and marks an in-flight task interrupted after restart", () => { + const store = new Store(selection); + const bot = store.createBot(); + const message = store.appendMessage(bot.threadId, { role: "user", kind: "text", text: "do the task" }); + const checkpoint = store.createCheckpoint(bot.id)!; + expect(checkpoint).toMatchObject({ status: "running", activeLeafId: message.id, modelSelection: selection() }); + + const reloaded = new Store(selection); + const recovered = reloaded.checkpoint(bot.id, checkpoint.id)!; + expect(recovered).toMatchObject({ status: "interrupted", reason: "harness restarted", activeLeafId: message.id }); + }); + + it("updates a checkpoint without mutating its original model snapshot", () => { + const store = new Store(selection); + const bot = store.createBot(); + const checkpoint = store.createCheckpoint(bot.id)!; + store.updateCheckpoint(bot.id, checkpoint.id, { status: "failed", reason: "provider failed" }); + expect(store.checkpoint(bot.id, checkpoint.id)).toMatchObject({ status: "failed", reason: "provider failed", modelSelection: selection() }); + }); + it("seedIfEmpty creates exactly one starter bot, once", () => { const store = new Store(selection); store.seedIfEmpty(); diff --git a/server/store.ts b/server/store.ts index 014bbc2edb..5e666732de 100644 --- a/server/store.ts +++ b/server/store.ts @@ -93,6 +93,10 @@ export interface BotRecord { modelSelection: ModelSelection; /** provider-native continuation per instance (e.g. claude session id) */ resumeCursors: Record; + /** Durable task snapshots. Transcript branches and provider cursors remain + * the source of truth; a checkpoint is a safe, user-addressable pointer to + * that state plus its lifecycle. */ + checkpoints?: TaskCheckpoint[]; /** which computer the bot acts on: its cloud box, this Mac (local CUA), * or none. Unset = auto (box when it exists, else local when available). */ computer?: "cloud" | "local" | "off"; @@ -106,6 +110,17 @@ export interface BotRecord { createdAt: number; } +export interface TaskCheckpoint { + id: string; + createdAt: number; + updatedAt: number; + status: "running" | "interrupted" | "completed" | "failed"; + activeLeafId: string | null; + modelSelection: ModelSelection; + lastMessageId?: string; + reason?: string; +} + const BOTS_FILE = join(DATA_DIR, "bots.json"); const GROUPS_FILE = join(DATA_DIR, "groups.json"); const messagesFile = (threadId: string) => join(DATA_DIR, `messages-${threadId}.json`); @@ -176,8 +191,20 @@ export class Store { this.groups = []; } // busy never survives a restart — no turn does either - for (const b of this.bots) b.busy = false; + let changed = false; + for (const b of this.bots) { + b.busy = false; + for (const checkpoint of b.checkpoints ?? []) { + if (checkpoint.status === "running") { + checkpoint.status = "interrupted"; + checkpoint.reason = "harness restarted"; + checkpoint.updatedAt = Date.now(); + changed = true; + } + } + } for (const g of this.groups) g.busyBotId = null; + if (changed) this.saveBots(); } private saveBots() { @@ -382,6 +409,7 @@ export class Store { unread: false, modelSelection: this.defaultSelection(), resumeCursors: {}, + checkpoints: [], createdAt: Date.now(), }; this.bots.unshift(bot); @@ -422,6 +450,40 @@ export class Store { this.saveBots(); } + checkpoints(botId: string) { + return [...(this.bot(botId)?.checkpoints ?? [])].sort((a, b) => b.updatedAt - a.updatedAt); + } + + checkpoint(botId: string, checkpointId: string) { + return this.bot(botId)?.checkpoints?.find((checkpoint) => checkpoint.id === checkpointId) ?? null; + } + + runningCheckpoint(botId: string) { + return this.bot(botId)?.checkpoints?.find((checkpoint) => checkpoint.status === "running") ?? null; + } + + createCheckpoint(botId: string): TaskCheckpoint | null { + const bot = this.bot(botId); + if (!bot) return null; + const now = Date.now(); + const checkpoint: TaskCheckpoint = { + id: newId(), createdAt: now, updatedAt: now, status: "running", + activeLeafId: this.activeLeaf(bot.threadId), modelSelection: { ...bot.modelSelection }, + lastMessageId: this.messagesFor(bot.threadId).at(-1)?.id, + }; + bot.checkpoints = [checkpoint, ...(bot.checkpoints ?? [])].slice(0, 20); + this.saveBots(); + return checkpoint; + } + + updateCheckpoint(botId: string, checkpointId: string, patch: Partial>) { + const checkpoint = this.checkpoint(botId, checkpointId); + if (!checkpoint) return null; + Object.assign(checkpoint, patch, { updatedAt: Date.now() }); + this.saveBots(); + return checkpoint; + } + /** First-run seed: one bot so the app never opens empty — it gets a * random friendly name like every other bot. */ seedIfEmpty() {