diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index c69c44ab1b..dcb938097a 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -991,3 +991,5 @@ pending approvals, task completion, reassignment, and later executions still gat admission. Native runners keep their supported reattachment protocol. Historical released reusable leases without termination receipts are not reclaimed by an old run. A concurrent resume can own the provider resource before its new lease row exists. Those runs remain gated until termination is confirmed. New cleanup attempts retain their durable pending-cleanup ownership. + +For historical task-bound runs with no adapter evidence, termination authorizes a fresh task conversation with the currently assigned conversational agent. It does not establish which adapter ran previously. Recovery records that identity as unknown and forces a fresh session while supplying the task history and latest request. This is an intentional exception to historical adapter classification, not permission to replay old process, webhook, or tool commands. Known non-conversational dispatch evidence remains ineligible for this automatic path. diff --git a/server/src/services/automatic-sandbox-continuation.test.ts b/server/src/services/automatic-sandbox-continuation.test.ts index a27bde9183..91987820cc 100644 --- a/server/src/services/automatic-sandbox-continuation.test.ts +++ b/server/src/services/automatic-sandbox-continuation.test.ts @@ -137,6 +137,20 @@ const support = await getEmbeddedPostgresTestSupport(); expect(old).toMatchObject({ status: "failed", errorCode: "process_lost" }); expect(old.resultJson?.automaticSandboxRecovery).toMatchObject({ actionOutcomes: "unknown" }); }); + it("starts fresh after termination when historical adapter identity is unavailable", async () => { + const f = await seed(); + // Old installs did not record the adapter before provisioning. Changing + // current settings cannot reveal it; the recovery audit must keep it unknown. + await db.update(agents).set({ adapterType: "process" }).where(eq(agents.id, f.agentId)); + await db.update(agents).set({ adapterType: "claude_local" }).where(eq(agents.id, f.agentId)); + await heartbeatService(db).resumeInterruptedSandboxRuns(); + const [next] = await successors(f.run.id); + expect(next.contextSnapshot?.forceFreshSession).toBe(true); + const [old] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.run.id)); + expect(old.resultJson?.automaticSandboxRecovery).toMatchObject({ + priorAdapter: "unknown", actionOutcomes: "unknown", continuation: "fresh_task_conversation", + }); + }); it("queues missing termination proof for cleanup, then resumes after a restart", async () => { const f = await seed(false); expect(await prepareAutomaticSandboxContinuation(db, f.run)).toBeNull(); diff --git a/server/src/services/automatic-sandbox-continuation.ts b/server/src/services/automatic-sandbox-continuation.ts index d1c0e3eb99..4b05f8b62b 100644 --- a/server/src/services/automatic-sandbox-continuation.ts +++ b/server/src/services/automatic-sandbox-continuation.ts @@ -49,6 +49,9 @@ export async function prepareAutomaticSandboxContinuation(db: Db, source: typeof if (!agent || !isConversationAdapter(agent.adapterType)) return null; const recorded = await recordedRunAdapter(tx as unknown as Db, run); if (recorded && !isConversationAdapter(recorded)) return null; + // Missing historical evidence does not establish the old adapter type. + // After exact termination, it permits only a fresh task conversation with + // the currently assigned conversational agent, never old session replay. // A later execution owns current task work. Never revive an older request. if (await hasLaterSandboxExecution(tx as unknown as Db, run, issue.id)) return null; const leases = await tx.select().from(environmentLeases).where(and( @@ -80,9 +83,11 @@ export async function prepareAutomaticSandboxContinuation(db: Db, source: typeof // Remote process numbers belong to the remote namespace, not this host. const [ready] = await tx.update(heartbeatRuns).set({ processPid: null, processGroupId: null, processStartedAt: null, + ...(!recorded ? { contextSnapshot: { ...run.contextSnapshot, forceFreshSession: true } } : {}), resultJson: sql`coalesce(${heartbeatRuns.resultJson}, '{}'::jsonb) || ${JSON.stringify({ conversationContinuation: CONVERSATION_CONTINUATION_POLICY, - automaticSandboxRecovery: { state: "provider_terminated", actionOutcomes: "unknown" }, + automaticSandboxRecovery: { state: "provider_terminated", actionOutcomes: "unknown", + priorAdapter: recorded ?? "unknown", continuation: recorded ? "conversation_turn" : "fresh_task_conversation" }, })}::jsonb`, }).where(and(eq(heartbeatRuns.id, run.id), eq(heartbeatRuns.companyId, run.companyId))).returning(); const retired = await tx.update(issueRecoveryActions).set({