From 60beb77303e14a2376b0a1c2cc2ed7233b448d3e Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 11 Nov 2026 11:56:53 -0600 Subject: [PATCH] fix(recovery): accept a person's own answer at dispatch, not only at admission Admission accepted a person's answer to a held task's pending decision, but buildExecutionContinuation only accepted interaction receipts that came through a saved interrupt queue, so the successor failed with continuation_user_authorization_missing before the agent saw the answer. Dispatch now accepts the same receipt when the interaction was resolved by that person (not an agent), its status matches the wake, and the authorization names this successor. Co-Authored-By: Claude Opus 5.5 --- server/src/services/execution-continuation.ts | 12 +++++++++--- .../src/services/governed-wait-runner-loss.test.ts | 7 +++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/server/src/services/execution-continuation.ts b/server/src/services/execution-continuation.ts index b5882d0f19f..c39a2df26ce 100644 --- a/server/src/services/execution-continuation.ts +++ b/server/src/services/execution-continuation.ts @@ -501,9 +501,15 @@ export async function buildExecutionContinuation(input: { : value.interactionId ? interactions.some(interaction => interaction.id === value.interactionId && interaction.id === triggerInteraction?.id && ["accepted", "answered", "rejected"].includes(interaction.status) && - interruptQueues.some(queue => queue.id === value.queuedCommentInterruptId && queue.runId === value.runId && - object(queue.payload?.queuedCommentInterrupt).actorId === value.actorId && - queuedInteractionId(queue.payload) === interaction.id && queue.payload?.interactionStatus === interaction.status)) + (value.queuedCommentInterruptId + ? interruptQueues.some(queue => queue.id === value.queuedCommentInterruptId && queue.runId === value.runId && + object(queue.payload?.queuedCommentInterrupt).actorId === value.actorId && + queuedInteractionId(queue.payload) === interaction.id && queue.payload?.interactionStatus === interaction.status) + // A person's own answer to the held task's pending decision: + // bound to that person, this successor and the answered status. + : interaction.resolvedByUserId === value.actorId && !interaction.resolvedByAgentId && + string(input.context.interactionStatus) === interaction.status && + priorRuns.some(run => run.id === value.runId))) : rows.some(comment => comment.id === value.commentId && comment.authorType === "user" && (!("commentUpdatedAt" in value || "commentBodyHash" in value || value.automaticRetry) || ( diff --git a/server/src/services/governed-wait-runner-loss.test.ts b/server/src/services/governed-wait-runner-loss.test.ts index 4c4a4d8f992..a32b42be18f 100644 --- a/server/src/services/governed-wait-runner-loss.test.ts +++ b/server/src/services/governed-wait-runner-loss.test.ts @@ -13,6 +13,7 @@ import { taskInterruptionPresentation } from "./task-interruption-presentation.j import { readSystemRecovery } from "./system-recovery-state.js"; import { reconcileRetainedWorkspaces } from "./workspace-recovery.js"; import { requestTaskRetry } from "./task-retry.js"; +import { buildExecutionContinuation } from "./execution-continuation.js"; import { useWorkspaceRecoveryFixture, action, db, run, seed } from "./__tests__/workspace-recovery-fixture.js"; const targets = vi.hoisted(() => new Map()); @@ -130,6 +131,12 @@ describe("a held task with a pending decision after its remote runner was lost", evidence: { explicitUserContinuation: { actorId: "local-board", interactionId: f.decision.id, previousRunId: f.runId } } }); expect(await db.select().from(activityLog).where(and(eq(activityLog.companyId, f.companyId), eq(activityLog.action, "issue.execution_recovery_settled"), eq(activityLog.actorId, "local-board")))).toHaveLength(1); + // Dispatch rebuilds the continuation under the successor's identity and + // rechecks the user authorization; the agent then receives the answer. + const envelope = await buildExecutionContinuation({ db, companyId: f.companyId, issueId: f.issueId, agentId: f.agentId, + runId: successor.id, context: successor.contextSnapshot ?? {}, summary: null, exposeLowTrustRaw: false }); + expect(envelope).toMatchObject({ trigger: { interactionId: f.decision.id }, interruptedRunId: f.runId }); + expect(envelope.interactionOutcomes).toEqual(expect.arrayContaining([expect.objectContaining({ id: f.decision.id })])); }, 60_000); it("keeps the hold when the wake's user did not give the answer", async () => {