diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index 65ebcda93af..e12aa309131 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -83,7 +83,10 @@ the task. Its later task mutations are rejected, including under the update transaction's task and run locks. Read and comment access follows normal policy. A native run's task tools stop acting for the task once it is handed off, and the run's result is kept as history: its finalizer preserves the person's task status -and adds no continuation, blocker, or recovery to it. +and adds no continuation, blocker, or recovery to it. If a restart interrupts the +outgoing turn, the run settles and its unsaved files are retained. Once automatic +recovery saves them, the workspace hold releases for the person who received the +task through that run's own recorded hand-off. Board-initiated reassignments, agent API keys, and handoffs to another agent still require the outgoing execution to stop before ownership changes. diff --git a/server/src/services/execution-blocker.ts b/server/src/services/execution-blocker.ts index 35229f1f2a3..379a76d7a82 100644 --- a/server/src/services/execution-blocker.ts +++ b/server/src/services/execution-blocker.ts @@ -19,6 +19,7 @@ import { nativeManualAdmissionHoldsExecution } from "./native-runtime/native-run import { PROCESS_IDENTITY_RECORDED, PROCESS_START_REQUESTED } from "./native-local-process-stop.js"; import { NATIVE_CLEANUP_SETTLING_MESSAGE, NATIVE_SESSION_CLEANUP_QUARANTINED } from "./native-runtime/completed-turn-cleanup.js"; import { EXECUTION_FINALIZATION_DEADLINE_EXCEEDED, EXECUTION_FINALIZATION_DEADLINE_MESSAGE } from "./execution-control-deadline.js"; +import { NATIVE_HUMAN_SELF_HANDOFF_KEY } from "./native-runtime/native-human-self-handoff.js"; const blockerContextKeys = ["issueId", "taskId", "source", "connectionIntentResolution", "interactionKind", "interactionStatus", "mutation", "interactionId"] as const; @@ -254,7 +255,8 @@ export function interruptedPreparationBeforeProviderWorkCondition() { * unknown outcome, so its hold releases for the same agent too. The proof is * the durable completed-recovery receipt bound to this action, so later reuse * of the workspace by the current owner (new epoch, writer or snapshot) cannot - * re-arm the hold. + * re-arm the hold. A person who received the task through the stopped run's + * own recorded hand-off is released the same way. */ export function restoreHoldReleasedForCurrentOwnerPredicate() { const sourceRunId = sql`coalesce(${issueRecoveryActions.evidence}->>'runId', ${issueRecoveryActions.evidence}->>'sourceRunId')`; @@ -271,13 +273,20 @@ export function restoreHoldReleasedForCurrentOwnerPredicate() { and stopped.id::text = ${sourceRunId} and stopped.status in ('failed', 'timed_out', 'interrupted', 'cancelled') and stopped.finished_at is not null - and task.assignee_agent_id is not null - and ((task.assignee_agent_id <> stopped.agent_id - and not coalesce(task.status = 'in_review' - and task.execution_state->>'status' = 'pending' - and task.execution_state->'currentParticipant'->>'type' = 'agent' - and task.execution_state->'currentParticipant'->>'agentId' = stopped.agent_id::text, false)) - or ${stoppedBeforeProviderTurnCondition()} or ${interruptedPreparationBeforeProviderWorkCondition()}))`, + and ((task.assignee_agent_id is not null + and ((task.assignee_agent_id <> stopped.agent_id + and not coalesce(task.status = 'in_review' + and task.execution_state->>'status' = 'pending' + and task.execution_state->'currentParticipant'->>'type' = 'agent' + and task.execution_state->'currentParticipant'->>'agentId' = stopped.agent_id::text, false)) + or ${stoppedBeforeProviderTurnCondition()} or ${interruptedPreparationBeforeProviderWorkCondition()})) + -- The stopped run handed this task to the person who owns it now + -- (its own recorded hand-off). Its remaining outcomes were never + -- that person's work; once its files are saved, the hold releases. + or (task.assignee_agent_id is null and task.assignee_user_id is not null + and jsonb_typeof(stopped.result_json->'${sql.raw(NATIVE_HUMAN_SELF_HANDOFF_KEY)}') = 'object' + and stopped.result_json->'${sql.raw(NATIVE_HUMAN_SELF_HANDOFF_KEY)}'->>'issueId' = task.id::text + and stopped.result_json->'${sql.raw(NATIVE_HUMAN_SELF_HANDOFF_KEY)}'->>'assigneeUserId' = task.assignee_user_id)))`, completedWorkspaceRecoveryCondition(sourceRunId), // Copyback put the files back into the task workspace that the new owner // reuses. A snapshot-only save needs an explicit fresh-workspace request diff --git a/server/src/services/workspace-recovery-admission.test.ts b/server/src/services/workspace-recovery-admission.test.ts index 9b8ea3fd142..0017992e1b6 100644 --- a/server/src/services/workspace-recovery-admission.test.ts +++ b/server/src/services/workspace-recovery-admission.test.ts @@ -434,6 +434,36 @@ describe("automatic retained workspace recovery", () => { expect(wake.payload?.taskRetry).toMatchObject({ strategy: "continue" }); }, 30_000); + it("releases a handed-off run's recovered files hold for the person it handed the task to", async () => { + // Shape of a restart that lands after a native run handed its own task to a + // person (staging class H): the run recorded the hand-off, the person owns + // the task in review, and restart recovery settled the run as failed with + // its unsaved files retained. + const f = await seed(); + const person = "handoff-reviewer"; + await db.update(heartbeatRuns).set({ errorCode: "native_restart_recovery_blocked", + resultJson: { ...(await run(f.runId)).resultJson, humanSelfHandoff: { issueId: f.issueId, assigneeUserId: person, + recordedAt: new Date().toISOString() } } }).where(eq(heartbeatRuns.id, f.runId)); + await db.update(issues).set({ assigneeAgentId: null, assigneeUserId: person, status: "in_review" }).where(eq(issues.id, f.issueId)); + + // Until its files are saved, the outgoing run's hold stays: nothing may reuse the workspace. + expect(await getExecutionBlocker(db, f.companyId, f.issueId)).toMatchObject({ runId: f.runId, workspaceRepairRequired: true }); + + await recoverRetainedWorkspace(f.input); + expect(await hasVerifiedWorkspaceRepair(db, await run(f.runId))).toBe(true); + // Once they are saved, the person's task carries no workspace-repair hold. + expect(await getExecutionBlocker(db, f.companyId, f.issueId)).toBeNull(); + }); + + it("keeps the recovered files hold when the person did not receive the task from that run's own hand-off", async () => { + const f = await seed(); + await db.update(heartbeatRuns).set({ resultJson: { ...(await run(f.runId)).resultJson, humanSelfHandoff: { + issueId: f.issueId, assigneeUserId: "someone-else", recordedAt: new Date().toISOString() } } }).where(eq(heartbeatRuns.id, f.runId)); + await db.update(issues).set({ assigneeAgentId: null, assigneeUserId: "handoff-reviewer", status: "in_review" }).where(eq(issues.id, f.issueId)); + await recoverRetainedWorkspace(f.input); + expect(await getExecutionBlocker(db, f.companyId, f.issueId)).toMatchObject({ runId: f.runId }); + }); + it("hands a reassigned task to its new assignee once the previous owner's files are recovered", async () => { // Shape of a mid-run reassignment: the old owner's run was cancelled for // the reassignment, its copyback was retained, and its no-replay hold was