Publish warm session before native sandbox completion

Wait for the final work-folder checkpoint before background reconciliation can finalize a sandbox run. Persist its resumable task identity before exposing completion, and avoid late cleanup overwriting a newer turn.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-07 16:07:46 -05:00
parent 1f06bfff29
commit 5a2ed91963
4 changed files with 68 additions and 2 deletions

View File

@ -12,6 +12,7 @@ import {
nativeRunResults,
statusDecisions,
workAssessments,
workFolderRuns,
workspaceOperations,
} from "@paperclipai/db";
import {
@ -208,6 +209,29 @@ describe("P6-16/P6-25/P6-28 native finalization recovery", () => {
await temporary.cleanup();
});
it("does not publish native completion before the sandbox's final durable save", async () => {
const manifest = { version: 1 as const, companyId, runId, agentId, taskId: issueId,
responsibleUserId: null, projectId: null, leaseId: runId, sandboxKey: runId,
home: "/home/sandbox", folders: {}, repositories: [] };
await db.insert(workFolderRuns).values({ runId, companyId, manifest });
try {
for (const state of ["starting", "saving", "saved", "failed"] as const) {
await db.update(workFolderRuns).set({ state, lastSavedAt: new Date() })
.where(eq(workFolderRuns.runId, runId));
await expect(reconcileNativeFinalizations(db, [runId])).resolves.toEqual([]);
}
// A failed refresh/save cannot reuse an older final-save marker.
await db.update(workFolderRuns).set({ manifest: { ...manifest, finalCheckpointAt: new Date().toISOString() } })
.where(eq(workFolderRuns.runId, runId));
await expect(reconcileNativeFinalizations(db, [runId])).resolves.toEqual([]);
const run = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
expect(run[0]?.status).toBe("running");
await expect(db.select().from(statusDecisions).where(eq(statusDecisions.issueId, issueId))).resolves.toHaveLength(0);
} finally {
await db.delete(workFolderRuns).where(eq(workFolderRuns.runId, runId));
}
});
it("fails closed into bounded named recovery without consulting the live flag or falling back", async () => {
await expect(reconcileNativeFinalizations(db, [runId])).resolves.toEqual([
expect.objectContaining({ phase: "retryable_failure", failureCode: "native_finalization_invalid" }),

View File

@ -18039,6 +18039,7 @@ export function heartbeatService(
let runScratch: HeartbeatRunScratch | null = null;
let sandboxWorkFolders: Awaited<ReturnType<typeof prepareSandboxWorkFolders>> | null = null;
let workFolderSaveFailed = false;
let nativeTaskSessionPersisted = false;
let workFolderLeaseId: string | null = null;
let nativeSessionResumeScheduled = false;
let nativeWorkspaceFinalizeScheduled = false;
@ -21610,6 +21611,34 @@ export function heartbeatService(
// rather than silently leaving dependents stranded behind a missing
// 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;
}
workFolderSaveFailed = true;
await sandboxWorkFolders.stop();
sandboxWorkFolders = null;
@ -22318,7 +22347,7 @@ export function heartbeatService(
},
normalizedUsage,
);
if (taskKey) {
if (taskKey && !nativeTaskSessionPersisted) {
if (
adapterResult.clearSession ||
(!nextSessionState.params && !nextSessionState.displayId)
@ -22617,6 +22646,7 @@ export function heartbeatService(
if (
taskKey &&
!nativeTaskSessionPersisted &&
(previousSessionParams || previousSessionDisplayId || taskSession)
) {
await upsertTaskSession({

View File

@ -12,6 +12,7 @@ import {
statusDecisionEffects,
statusDecisions,
workAssessments,
workFolderRuns,
workspaceOperations,
} from "@paperclipai/db";
import { finalizeNativeRun, recordNativeFinalizationFailure } from "./native-run-finalizer.js";
@ -404,6 +405,8 @@ export async function reconcileNativeFinalizations(
assessmentId: nativeRunFinalizations.assessmentId,
decisionId: nativeRunFinalizations.decisionId,
runnerProfileJson: heartbeatRuns.runnerProfileJson,
workFolderState: workFolderRuns.state,
workFolderManifest: workFolderRuns.manifest,
})
.from(heartbeatRuns)
.innerJoin(nativeRunFinalizations, eq(nativeRunFinalizations.runId, heartbeatRuns.id))
@ -411,6 +414,10 @@ export async function reconcileNativeFinalizations(
eq(issues.id, nativeRunFinalizations.issueId),
eq(issues.companyId, heartbeatRuns.companyId),
))
.leftJoin(workFolderRuns, and(
eq(workFolderRuns.runId, heartbeatRuns.id),
eq(workFolderRuns.companyId, heartbeatRuns.companyId),
))
.where(and(
eq(heartbeatRuns.runtimeMode, "native"),
isNotNull(nativeRunFinalizations.resultId),
@ -428,6 +435,11 @@ export async function reconcileNativeFinalizations(
));
const results = [];
for (const row of rows) {
// A scoped sandbox's final flush is its durability barrier. A periodic
// save or the old host workspace directory cannot substitute for it.
// The live executor also publishes its next-turn session before this
// barrier, so completion cannot race a warm restart with a fresh identity.
if (row.workFolderManifest && (row.workFolderState !== "saved" || !row.workFolderManifest.finalCheckpointAt)) continue;
const pendingEffects = row.decisionId
? await db.select({ id: statusDecisionEffects.id }).from(statusDecisionEffects).where(and(
eq(statusDecisionEffects.companyId, row.companyId),

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, mark the task blocked with the actual error. 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":"board","action":"Investigate the failed acceptance assertion"}, including the actual error in your comment. Otherwise mark the Paperclip task done after the script succeeds.',
"```sh", repoAcceptanceScript(nonce, warm), "```",
].join("\n");
}