diff --git a/doc/sandbox-work-folders.md b/doc/sandbox-work-folders.md index dab84c38f1..412093f6b0 100644 --- a/doc/sandbox-work-folders.md +++ b/doc/sandbox-work-folders.md @@ -228,7 +228,9 @@ Native continuation validates the full control-plane journal with the same 2 MiB without invalidating its identity. Oversized, malformed, foreign-session, and unverifiable state still fail closed and remain recoverable in quarantine. The cached-file routes initialize storage once per router after authorization; -every request still checks current owner access. +every request still checks current owner access. If a run ends while its save is +starting or in progress, the inspector reports the interruption and keeps the +previous successful save time visible, including across sandbox replacement. Passing acceptance does not authorize a merge or mainline release. Both require the user's explicit sign-off. diff --git a/server/src/__tests__/work-folder-route-storage.test.ts b/server/src/__tests__/work-folder-route-storage.test.ts index 4e7ae84e77..bac60eb6cc 100644 --- a/server/src/__tests__/work-folder-route-storage.test.ts +++ b/server/src/__tests__/work-folder-route-storage.test.ts @@ -40,3 +40,26 @@ it("initializes storage only after access succeeds and reuses it while checking expect(mocks.access).toHaveBeenCalledTimes(4); expect(mocks.list).toHaveBeenCalledTimes(2); }); + +it.each(["same-sandbox", "replacement-sandbox"])("keeps the last successful save visible after an interrupted run on %s", async (sandboxKey) => { + const oldSave = { + folderRun: { runId: "saved-run", manifest: { agentId: "agent", sandboxKey: "same-sandbox" }, + state: "saved", lastSavedAt: new Date("2026-09-08T12:00:00Z"), error: null, refreshRequested: false }, + status: "succeeded", + }; + const interrupted = { + folderRun: { runId: "interrupted-run", manifest: { agentId: "agent", sandboxKey }, + state: "starting", lastSavedAt: null, error: null, refreshRequested: false }, + status: "failed", + }; + const query = { from: () => query, innerJoin: () => query, where: () => query, + orderBy: () => query, limit: async () => [interrupted, oldSave] }; + const app = express(); + app.use("/api", workFolderRoutes({ select: () => query } as unknown as Db)); + const response = await request(app).get("/api/companies/11111111-1111-4111-8111-111111111111/work-folders/task/22222222-2222-4222-8222-222222222222/sync").expect(200); + expect(response.body).toEqual([ + expect.objectContaining({ runId: "interrupted-run", state: "failed", active: false, + error: "Run ended before its final file save completed.", lastSavedAt: null }), + expect.objectContaining({ runId: "saved-run", state: "saved", lastSavedAt: "2026-09-08T12:00:00.000Z" }), + ]); +}); diff --git a/server/src/routes/work-folders.ts b/server/src/routes/work-folders.ts index 6c336cb969..57b68c3f9e 100644 --- a/server/src/routes/work-folders.ts +++ b/server/src/routes/work-folders.ts @@ -86,19 +86,31 @@ export function workFolderRoutes(db: Db, provider?: StorageProvider) { .where(and(eq(workFolderRuns.companyId, owner.companyId), sql`${workFolderRuns.manifest}->'folders'->>${owner.scope} = ${folder.id}`)) .orderBy(desc(workFolderRuns.updatedAt)).limit(100); + const isActive = (status: string) => status === "running" || status === "queued"; + const latestCompletedSave = rows.filter(({ folderRun, status }) => + !isActive(status) && folderRun.state === "saved" && folderRun.lastSavedAt !== null) + .sort((a, b) => b.folderRun.lastSavedAt!.getTime() - a.folderRun.lastSavedAt!.getTime())[0]; + const projectStatus = ({ folderRun: row, status }: typeof rows[number]) => { + const active = isActive(status); + const interrupted = !active && (row.state === "starting" || row.state === "saving"); + return { runId: row.runId, agentId: row.manifest.agentId, state: interrupted ? "failed" : row.state, + lastSavedAt: row.lastSavedAt, error: interrupted ? row.error ?? "Run ended before its final file save completed." : row.error, + refreshRequested: row.refreshRequested, active }; + }; const leases = new Set(); - let includedCompletedSave = false; - res.json(rows.flatMap(({ folderRun: row, status }) => { + const statuses = rows.flatMap((entry) => { + const row = entry.folderRun; if (leases.has(row.manifest.sandboxKey)) return []; leases.add(row.manifest.sandboxKey); - const active = status === "running" || status === "queued"; - if (!active && row.state !== "failed") { - if (includedCompletedSave) return []; - includedCompletedSave = true; - } - return [{ runId: row.runId, agentId: row.manifest.agentId, state: row.state, lastSavedAt: row.lastSavedAt, error: row.error, - refreshRequested: row.refreshRequested, active }]; - })); + if (!isActive(entry.status) && row.state === "saved" && row.runId !== latestCompletedSave?.folderRun.runId) return []; + return [projectStatus(entry)]; + }); + // A failed replacement or later run must not erase the last successful + // checkpoint, including when both runs share the same physical sandbox. + if (latestCompletedSave && !statuses.some((status) => status.runId === latestCompletedSave.folderRun.runId)) { + statuses.push(projectStatus(latestCompletedSave)); + } + res.json(statuses); }); router.post(`${base}/refresh`, async (req, res) => { const owner = ownerSchema.parse(req.params);