Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions server/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
66 changes: 65 additions & 1 deletion server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,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 });
}
store.patchBot(bot.id, { busy: false, unread: true });
broadcast({ kind: "bot", bot: store.bot(bot.id) });
if (screenPollers.has(bot.id)) {
Expand Down Expand Up @@ -358,7 +368,7 @@ function readCuaConnection(): { command: string; args: string[]; env: Record<str
async function startTurn(
botId: string,
text: string,
opts?: { commsDepth?: number; userMessage?: Message },
opts?: { commsDepth?: number; userMessage?: Message; checkpointId?: string },
) {
const bot = store.bot(botId);
if (!bot) throw Object.assign(new Error("no such bot"), { status: 404 });
Expand All @@ -379,6 +389,16 @@ async function startTurn(
userMessage = store.appendMessage(bot.threadId, { role: "user", kind: "text", text });
broadcast({ kind: "message", threadId: bot.threadId, message: userMessage });
}
const checkpoint = opts?.checkpointId
? store.updateCheckpoint(bot.id, opts.checkpointId, {
status: "running",
reason: undefined,
activeLeafId: store.activeLeaf(bot.threadId),
lastMessageId: userMessage.id,
})
: store.createCheckpoint(bot.id);
if (!checkpoint) throw Object.assign(new Error("checkpoint unavailable"), { status: 409 });
broadcast({ kind: "checkpoint", botId: bot.id, checkpoint });

// transcript for API-backed drivers: settled text turns on the ACTIVE
// branch only — abandoned forks never reach the model
Expand Down Expand Up @@ -513,6 +533,8 @@ async function startTurn(
if (rewound) store.patchBot(bot.id, { rewound: false, resumeCursors: {} });
if (previewBoxId) startScreenPoller(bot.id, previewBoxId);
} catch (e) {
const failed = store.updateCheckpoint(bot.id, checkpoint.id, { status: "failed", reason: "turn dispatch failed" });
if (failed) broadcast({ kind: "checkpoint", botId: bot.id, checkpoint: failed });
const message = e instanceof Error ? e.message : String(e);
const failure = store.appendMessage(bot.threadId, {
role: "bot",
Expand Down Expand Up @@ -1076,11 +1098,53 @@ const server = createServer(async (req, res) => {
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",
activeLeafId: store.activeLeaf(bot.threadId),
lastMessageId: store.messagesFor(bot.threadId).at(-1)?.id,
});
if (stopped) broadcast({ kind: "checkpoint", botId: bot.id, checkpoint: stopped });
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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" });
}
// The provider session contains the branch active before restore.
// Force the next turn to replay only the checkpoint's visible path.
store.patchBot(bot.id, { rewound: true });
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 });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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)
Expand Down
20 changes: 20 additions & 0 deletions server/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
64 changes: 63 additions & 1 deletion server/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ export interface BotRecord {
modelSelection: ModelSelection;
/** provider-native continuation per instance (e.g. claude session id) */
resumeCursors: Record<string, unknown>;
/** 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";
Expand All @@ -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`);
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -398,6 +425,7 @@ export class Store {
unread: false,
modelSelection: this.defaultSelection(),
resumeCursors: {},
checkpoints: [],
createdAt: Date.now(),
};
this.bots.unshift(bot);
Expand Down Expand Up @@ -438,6 +466,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<Pick<TaskCheckpoint, "status" | "reason" | "activeLeafId" | "lastMessageId">>) {
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() {
Expand Down