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 () => {