From 495be4153ec9f9402140224e4ca2f9289a9d974a Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Sun, 26 Jul 2026 17:43:39 -0700 Subject: [PATCH] fix(server): clear pending requests whose provider session is gone A pending approval or user-input request can only be answered through the live provider session that holds its callback, so once that session is gone the request is permanently unanswerable. The reactor already refused to send those responses, but it recorded the refusal with a bespoke detail -- "No active provider session is bound to this thread." -- that none of the pending-request accounting recognizes. Every consumer decides whether an open request is still answerable by scanning activities and clearing on a *.respond.failed whose detail carries a known terminal marker: derivePendingApprovals / derivePendingUserInputs in web and mobile, the decider's settle guard, and ProjectionPipeline's pending counts. Matching none of them, the request stayed open forever: the prompt kept rendering as answerable, every submit appended another identical failure row, and the thread could no longer be settled or snoozed. Emit the canonical stale-request detail for this case instead, prefixed with the cause. One failed response now clears the prompt everywhere and explains why, rather than accumulating dead retries. This is the app-restart case specifically, where the session vanishes with no chance to emit user-input.resolved, so nothing else can clear the prompt. --- .../Layers/ProviderCommandReactor.test.ts | 138 ++++++++++++++++++ .../Layers/ProviderCommandReactor.ts | 17 ++- apps/web/src/session-logic.test.ts | 45 ++++++ 3 files changed, 198 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index c49646b7a4b..fe79696bcce 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -2174,6 +2174,144 @@ describe("ProviderCommandReactor", () => { expect(resolvedActivity).toBeUndefined(); }); + effectIt.effect( + "marks user-input responses stale when no provider session is bound to the thread", + () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => createHarness()); + const now = "2026-01-01T00:00:00.000Z"; + + yield* harness.engine.dispatch({ + type: "thread.activity.append", + commandId: CommandId.make("cmd-user-input-requested-no-session"), + threadId: ThreadId.make("thread-1"), + activity: { + id: EventId.make("activity-user-input-requested-no-session"), + tone: "info", + kind: "user-input.requested", + summary: "User input requested", + payload: { + requestId: "user-input-request-1", + questions: [ + { + id: "fix_scope", + header: "Fix scope", + question: "How should I handle this?", + options: [{ label: "Delete now", description: "Remove the garbage" }], + }, + ], + }, + turnId: null, + createdAt: now, + }, + createdAt: now, + }); + + yield* harness.engine.dispatch({ + type: "thread.user-input.respond", + commandId: CommandId.make("cmd-user-input-respond-no-session"), + threadId: ThreadId.make("thread-1"), + requestId: asApprovalRequestId("user-input-request-1"), + answers: { + fix_scope: "Delete now", + }, + createdAt: now, + }); + + yield* Effect.promise(() => + waitFor(async () => { + const readModel = await harness.readModel(); + const thread = readModel.threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + return ( + thread?.activities.some( + (activity) => activity.kind === "provider.user-input.respond.failed", + ) === true + ); + }), + ); + + expect(harness.respondToUserInput).not.toHaveBeenCalled(); + + const readModel = yield* Effect.promise(() => harness.readModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + const failureActivity = thread?.activities.find( + (activity) => activity.kind === "provider.user-input.respond.failed", + ); + const detail = (failureActivity?.payload as Record | undefined)?.detail; + expect(failureActivity?.payload).toMatchObject({ requestId: "user-input-request-1" }); + expect(detail).toContain("No active provider session is bound to this thread."); + // The stale marker is what every pending-request accounting keys off, so a + // dead session clears the prompt instead of leaving it answerable forever. + // Clearing itself is covered by session-logic / threadActivity tests. + expect(detail).toContain("Stale pending user-input request: user-input-request-1"); + }), + ); + + effectIt.effect( + "marks approval responses stale when no provider session is bound to the thread", + () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => createHarness()); + const now = "2026-01-01T00:00:00.000Z"; + + yield* harness.engine.dispatch({ + type: "thread.activity.append", + commandId: CommandId.make("cmd-approval-requested-no-session"), + threadId: ThreadId.make("thread-1"), + activity: { + id: EventId.make("activity-approval-requested-no-session"), + tone: "approval", + kind: "approval.requested", + summary: "Command approval requested", + payload: { + requestId: "approval-request-1", + requestKind: "command", + }, + turnId: null, + createdAt: now, + }, + createdAt: now, + }); + + yield* harness.engine.dispatch({ + type: "thread.approval.respond", + commandId: CommandId.make("cmd-approval-respond-no-session"), + threadId: ThreadId.make("thread-1"), + requestId: asApprovalRequestId("approval-request-1"), + decision: "acceptForSession", + createdAt: now, + }); + + yield* Effect.promise(() => + waitFor(async () => { + const readModel = await harness.readModel(); + const thread = readModel.threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + return ( + thread?.activities.some( + (activity) => activity.kind === "provider.approval.respond.failed", + ) === true + ); + }), + ); + + expect(harness.respondToRequest).not.toHaveBeenCalled(); + + const readModel = yield* Effect.promise(() => harness.readModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + const failureActivity = thread?.activities.find( + (activity) => activity.kind === "provider.approval.respond.failed", + ); + const detail = (failureActivity?.payload as Record | undefined)?.detail; + expect(failureActivity?.payload).toMatchObject({ requestId: "approval-request-1" }); + expect(detail).toContain("No active provider session is bound to this thread."); + expect(detail).toContain("Stale pending approval request: approval-request-1"); + }), + ); + it("reacts to thread.session.stop by stopping provider session and clearing thread session state", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 3044cc6029d..4490f77729f 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -166,6 +166,19 @@ function stalePendingRequestDetail( return `Stale pending ${requestKind} request: ${requestId}. Provider callback state does not survive app restarts or recovered sessions. Restart the turn to continue.`; } +// A response can only reach the provider through the live session holding the +// pending callback, so once that session is gone the request is permanently +// unanswerable — exactly the stale-request case. Reuse the stale detail so the +// pending accounting that keys off it (web/mobile derivePending*, the decider's +// settle guard, ProjectionPipeline) clears the request instead of leaving an +// answerable card that appends an identical failure on every submit. +function noActiveSessionRequestDetail( + requestKind: "approval" | "user-input", + requestId: string, +): string { + return `No active provider session is bound to this thread. ${stalePendingRequestDetail(requestKind, requestId)}`; +} + function buildGeneratedWorktreeBranchName(raw: string): string { const normalized = raw .trim() @@ -929,7 +942,7 @@ const make = Effect.gen(function* () { threadId: event.payload.threadId, kind: "provider.approval.respond.failed", summary: "Provider approval response failed", - detail: "No active provider session is bound to this thread.", + detail: noActiveSessionRequestDetail("approval", event.payload.requestId), turnId: null, createdAt: event.payload.createdAt, requestId: event.payload.requestId, @@ -973,7 +986,7 @@ const make = Effect.gen(function* () { threadId: event.payload.threadId, kind: "provider.user-input.respond.failed", summary: "Provider user input response failed", - detail: "No active provider session is bound to this thread.", + detail: noActiveSessionRequestDetail("user-input", event.payload.requestId), turnId: null, createdAt: event.payload.createdAt, requestId: event.payload.requestId, diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 0f12e672f66..707f53ea7a0 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -307,6 +307,51 @@ describe("derivePendingUserInputs", () => { expect(derivePendingUserInputs(activities)).toEqual([]); }); + + it("clears pending user-input prompts once no provider session is bound to the thread", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "user-input-open-no-session", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "user-input.requested", + summary: "User input requested", + tone: "info", + payload: { + requestId: "req-user-input-no-session-1", + questions: [ + { + id: "fix_scope", + header: "Fix scope", + question: "How should I handle this?", + options: [ + { + label: "Delete now", + description: "Remove the garbage", + }, + ], + multiSelect: false, + }, + ], + }, + }), + makeActivity({ + id: "user-input-failed-no-session", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "provider.user-input.respond.failed", + summary: "Provider user input response failed", + tone: "error", + payload: { + requestId: "req-user-input-no-session-1", + detail: + "No active provider session is bound to this thread. Stale pending user-input request: req-user-input-no-session-1. Provider callback state does not survive app restarts or recovered sessions. Restart the turn to continue.", + }, + }), + ]; + + // The prompt's callback died with the session, so leaving the card + // answerable only lets every submit append another identical failure. + expect(derivePendingUserInputs(activities)).toEqual([]); + }); }); describe("deriveActivePlanState", () => {