Publish native resume identity only after durable file save

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-07 16:17:53 -05:00
parent ea785c47aa
commit 5f58c6b4f2
4 changed files with 49 additions and 32 deletions

View File

@ -111,9 +111,17 @@ describe("shared sandbox work-folder lifecycle", () => {
await run.flush();
const [before] = await db.select().from(workFolderRuns).where(eq(workFolderRuns.runId, run.manifest.runId));
await fs.writeFile(path.join(run.home, "task/activity-proof.txt"), "saved after logging recovers");
const beforeCompletion = vi.fn(async () => {
const [state] = await db.select().from(workFolderRuns).where(eq(workFolderRuns.runId, run.manifest.runId));
expect(state?.state).toBe("saved");
expect(state?.manifest.finalCheckpointAt).toBeUndefined();
const folder = await workFolderService(db, storage).ensure({ companyId, scope: "task", ownerId: taskId });
expect((await workFolderService(db, storage).list(folder)).files.some((file) => file.path === "activity-proof.txt")).toBe(true);
});
const activity = vi.spyOn(activityLog, "logActivity").mockRejectedValue(new Error("activity unavailable"));
try {
await expect(run.stop()).rejects.toThrow("activity unavailable");
await expect(run.stop(beforeCompletion)).rejects.toThrow("activity unavailable");
expect(beforeCompletion).not.toHaveBeenCalled();
const [state] = await db.select().from(workFolderRuns).where(eq(workFolderRuns.runId, run.manifest.runId));
expect(state?.state).toBe("failed");
expect(state?.lastSavedAt).toEqual(before?.lastSavedAt);
@ -121,9 +129,11 @@ describe("shared sandbox work-folder lifecycle", () => {
expect((await workFolderService(db, storage).list(folder)).files.some((file) => file.path === "activity-proof.txt")).toBe(false);
expect(await fs.readFile(path.join(run.home, "task/activity-proof.txt"), "utf8")).toBe("saved after logging recovers");
} finally { activity.mockRestore(); }
await run.stop(); active.splice(active.indexOf(run), 1);
await run.stop(beforeCompletion); active.splice(active.indexOf(run), 1);
expect(beforeCompletion).toHaveBeenCalledTimes(1);
const [saved] = await db.select().from(workFolderRuns).where(eq(workFolderRuns.runId, run.manifest.runId));
expect(saved?.state).toBe("saved");
expect(saved?.manifest.finalCheckpointAt).toBeTruthy();
expect(saved!.lastSavedAt!.getTime()).toBeGreaterThan(before!.lastSavedAt!.getTime());
const folder = await workFolderService(db, storage).ensure({ companyId, scope: "task", ownerId: taskId });
expect((await workFolderService(db, storage).list(folder)).files.some((file) => file.path === "activity-proof.txt")).toBe(true);

View File

@ -18040,6 +18040,7 @@ export function heartbeatService(
let sandboxWorkFolders: Awaited<ReturnType<typeof prepareSandboxWorkFolders>> | null = null;
let workFolderSaveFailed = false;
let nativeTaskSessionPersisted = false;
let beforeWorkFolderCompletion: (() => Promise<void>) | undefined;
let workFolderLeaseId: string | null = null;
let nativeSessionResumeScheduled = false;
let nativeWorkspaceFinalizeScheduled = false;
@ -21612,35 +21613,38 @@ export function heartbeatService(
// finalize row.
if (sandboxWorkFolders) {
if (adapterResult.nativeFinalization && taskKey) {
// Publish the resumable identity before the final file-save
// barrier lets native reconciliation expose terminal status.
// A user can start the next turn as soon as completion appears.
const sessionState = resolveNextSessionState({
adapterType: agent.adapterType,
codec: sessionCodec,
adapterResult,
outcome: adapterResult.nativeFinalization.terminal.runTerminalState === "succeeded"
? "succeeded" : "failed",
previousParams: previousSessionParams,
previousDisplayId: runtimeForAdapter.sessionDisplayId,
previousLegacySessionId: runtimeForAdapter.sessionId,
});
await upsertTaskSession({
companyId: agent.companyId,
agentId: agent.id,
adapterType: agent.adapterType,
taskKey,
sessionParamsJson: attachPaperclipSessionMetadataToSessionParams(
sessionState.params, configuredModel, sessionConfigMetadata,
),
sessionDisplayId: sessionState.displayId,
lastRunId: run.id,
lastError: adapterResult.errorMessage ?? null,
});
nativeTaskSessionPersisted = true;
const nativeTerminal = adapterResult.nativeFinalization.terminal.runTerminalState;
beforeWorkFolderCompletion = async () => {
if (nativeTaskSessionPersisted) return;
// The coordinator invokes this after saving data and before
// publishing the barrier that allows terminal reconciliation.
const sessionState = resolveNextSessionState({
adapterType: agent.adapterType,
codec: sessionCodec,
adapterResult,
outcome: nativeTerminal === "succeeded"
? "succeeded" : "failed",
previousParams: previousSessionParams,
previousDisplayId: runtimeForAdapter.sessionDisplayId,
previousLegacySessionId: runtimeForAdapter.sessionId,
});
await upsertTaskSession({
companyId: agent.companyId,
agentId: agent.id,
adapterType: agent.adapterType,
taskKey,
sessionParamsJson: attachPaperclipSessionMetadataToSessionParams(
sessionState.params, configuredModel, sessionConfigMetadata,
),
sessionDisplayId: sessionState.displayId,
lastRunId: run.id,
lastError: adapterResult.errorMessage ?? null,
});
nativeTaskSessionPersisted = true;
};
}
workFolderSaveFailed = true;
await sandboxWorkFolders.stop();
await sandboxWorkFolders.stop(beforeWorkFolderCompletion);
sandboxWorkFolders = null;
workFolderSaveFailed = false;
}
@ -22854,7 +22858,7 @@ export function heartbeatService(
}
} finally {
if (sandboxWorkFolders) {
try { await sandboxWorkFolders.stop(); workFolderSaveFailed = false; }
try { await sandboxWorkFolders.stop(beforeWorkFolderCompletion); workFolderSaveFailed = false; }
catch (error) { workFolderSaveFailed = true; logger.error({ err: error, runId: run.id }, "Work folder save failed; retaining sandbox for recovery"); }
}
let latestRun = await getRun(run.id).catch(() => null);

View File

@ -321,7 +321,7 @@ export async function prepareSandboxWorkFolders(input: {
return { manifest, home, identityChanged, primaryRepo: bindings.find(({ binding }) => manifest.repositories.some((repo) => repo.bindingId === binding.id && repo.primary))?.root ?? bindings[0]?.root ?? paths.task!,
env: { HOME: home, AGENT_HOME: paths.agent!, PAPERCLIP_PRIMARY_REPO: bindings.find(({ binding }) => manifest.repositories.some((repo) => repo.bindingId === binding.id && repo.primary))?.root ?? bindings[0]?.root ?? paths.task!, PAPERCLIP_TASK_DIR: paths.task!, PAPERCLIP_AGENT_DIR: paths.agent!,
PAPERCLIP_USER_DIR: paths.user!, PAPERCLIP_PROJECT_DIR: paths.project!, PAPERCLIP_REPOS_DIR: paths.repos! },
flush: checkpointer.flush, stop: async () => {
flush: checkpointer.flush, stop: async (beforeCompletion?: () => Promise<void>) => {
await checkpointer.stop();
const [run] = await db.select({ refreshRequested: workFolderRuns.refreshRequested }).from(workFolderRuns)
.where(eq(workFolderRuns.runId, input.runId));
@ -333,6 +333,9 @@ export async function prepareSandboxWorkFolders(input: {
await db.update(workFolderRuns).set({ refreshRequested: false, baselines, updatedAt: new Date() })
.where(eq(workFolderRuns.runId, input.runId));
}
// Native resume identity must be published after the data is durable,
// but before completion can release a new turn onto this sandbox.
await beforeCompletion?.();
manifest.finalCheckpointAt = new Date().toISOString();
await saveState("saved");
} };

View File

@ -41,7 +41,7 @@ export function repoAcceptancePrompt(nonce: string, warm: boolean): string {
return [
"Execute this exact acceptance shell script from your initial working directory in one tool call. Use real filesystem tools; do not simulate its result.",
warm ? "This must reuse the same warm sandbox. Do not repair, recreate, or reset missing state." : "This creates disposable local commits and staged, unstaged, and untracked test files. Do not push.",
'If any assertion fails, stop and PATCH the task with status "blocked" and unblockDescriptor {"owner":"board","action":"Investigate the failed acceptance assertion"}, including the actual error in your comment. Otherwise mark the Paperclip task done after the script succeeds.',
'If any assertion fails, stop and PATCH the task with status "blocked" and unblockDescriptor {"owner":{"agentId":"<your PAPERCLIP_AGENT_ID>"},"action":"Investigate the failed acceptance assertion"}, replacing the placeholder with your agent ID and including the actual error in your comment. Otherwise mark the Paperclip task done after the script succeeds.',
"```sh", repoAcceptanceScript(nonce, warm), "```",
].join("\n");
}