History

Handle stale pending requests and reused Claude text block indices

- Map unknown pending approval/user-input errors to explicit stale-request details - Clear stale pending approvals and user-input prompts in session derivation logic - Treat reused Claude text block indexes as new assistant messages to avoid item ID reuse - Persist latest runtime metadata before provider stop-all and expand regression coverage

Julius Marminge committed 7 months ago

Commit 754efdb1 parent ef843d48 files · +473 −36Browse files

Unified · Split

8 files
  1. apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +112 −0
  2. apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +21 −2
  3. apps/server/src/provider/Layers/ClaudeAdapter.test.ts +166 −0
  4. apps/server/src/provider/Layers/ClaudeAdapter.ts +18 −23
  5. apps/server/src/provider/Layers/ProviderService.test.ts +32 −7
  6. apps/server/src/provider/Layers/ProviderService.ts +27 −3
  7. apps/web/src/session-logic.test.ts +72 −0
  8. apps/web/src/session-logic.ts +25 −1
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+112−0
@@ -1135,6 +1135,7 @@ describe("ProviderCommandReactor", () => { · 1134 unmodified lines
11351135 expect(failureActivity).toBeDefined();
11361136 expect(failureActivity?.payload).toMatchObject({
11371137 requestId: "approval-request-1",
1138+ detail: expect.stringContaining("Stale pending approval request: approval-request-1"),
11381139 });
11391140
11401141 const resolvedActivity = thread?.activities.find(
@@ -1147,6 +1148,117 @@ describe("ProviderCommandReactor", () => { · 6 unmodified lines
11471148 expect(resolvedActivity).toBeUndefined();
11481149 });
11491150
1151+ it("surfaces stale provider user-input failures without faking user-input resolution", async () => {
1152+ const harness = await createHarness();
1153+ const now = new Date().toISOString();
1154+ harness.respondToUserInput.mockImplementation(() =>
1155+ Effect.fail(
1156+ new ProviderAdapterRequestError({
1157+ provider: "claudeAgent",
1158+ method: "item/tool/respondToUserInput",
1159+ detail: "Unknown pending user-input request: user-input-request-1",
1160+ }),
1161+ ),
1162+ );
1163+
1164+ await Effect.runPromise(
1165+ harness.engine.dispatch({
1166+ type: "thread.session.set",
1167+ commandId: CommandId.makeUnsafe("cmd-session-set-for-user-input-error"),
1168+ threadId: ThreadId.makeUnsafe("thread-1"),
1169+ session: {
1170+ threadId: ThreadId.makeUnsafe("thread-1"),
1171+ status: "running",
1172+ providerName: "claudeAgent",
1173+ runtimeMode: "approval-required",
1174+ activeTurnId: null,
1175+ lastError: null,
1176+ updatedAt: now,
1177+ },
1178+ createdAt: now,
1179+ }),
1180+ );
1181+
1182+ await Effect.runPromise(
1183+ harness.engine.dispatch({
1184+ type: "thread.activity.append",
1185+ commandId: CommandId.makeUnsafe("cmd-user-input-requested"),
1186+ threadId: ThreadId.makeUnsafe("thread-1"),
1187+ activity: {
1188+ id: EventId.makeUnsafe("activity-user-input-requested"),
1189+ tone: "info",
1190+ kind: "user-input.requested",
1191+ summary: "User input requested",
1192+ payload: {
1193+ requestId: "user-input-request-1",
1194+ questions: [
1195+ {
1196+ id: "sandbox_mode",
1197+ header: "Sandbox",
1198+ question: "Which mode should be used?",
1199+ options: [
1200+ {
1201+ label: "workspace-write",
1202+ description: "Allow workspace writes only",
1203+ },
1204+ ],
1205+ },
1206+ ],
1207+ },
1208+ turnId: null,
1209+ createdAt: now,
1210+ },
1211+ createdAt: now,
1212+ }),
1213+ );
1214+
1215+ await Effect.runPromise(
1216+ harness.engine.dispatch({
1217+ type: "thread.user-input.respond",
1218+ commandId: CommandId.makeUnsafe("cmd-user-input-respond-stale"),
1219+ threadId: ThreadId.makeUnsafe("thread-1"),
1220+ requestId: asApprovalRequestId("user-input-request-1"),
1221+ answers: {
1222+ sandbox_mode: "workspace-write",
1223+ },
1224+ createdAt: now,
1225+ }),
1226+ );
1227+
1228+ await waitFor(async () => {
1229+ const readModel = await Effect.runPromise(harness.engine.getReadModel());
1230+ const thread = readModel.threads.find(
1231+ (entry) => entry.id === ThreadId.makeUnsafe("thread-1"),
1232+ );
1233+ if (!thread) return false;
1234+ return thread.activities.some(
1235+ (activity) => activity.kind === "provider.user-input.respond.failed",
1236+ );
1237+ });
1238+
1239+ const readModel = await Effect.runPromise(harness.engine.getReadModel());
1240+ const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
1241+ expect(thread).toBeDefined();
1242+
1243+ const failureActivity = thread?.activities.find(
1244+ (activity) => activity.kind === "provider.user-input.respond.failed",
1245+ );
1246+ expect(failureActivity).toBeDefined();
1247+ expect(failureActivity?.payload).toMatchObject({
1248+ requestId: "user-input-request-1",
1249+ detail: expect.stringContaining("Stale pending user-input request: user-input-request-1"),
1250+ });
1251+
1252+ const resolvedActivity = thread?.activities.find(
1253+ (activity) =>
1254+ activity.kind === "user-input.resolved" &&
1255+ typeof activity.payload === "object" &&
1256+ activity.payload !== null &&
1257+ (activity.payload as Record<string, unknown>).requestId === "user-input-request-1",
1258+ );
1259+ expect(resolvedActivity).toBeUndefined();
1260+ });
1261+
11501262 it("reacts to thread.session.stop by stopping provider session and clearing thread session state", async () => {
11511263 const harness = await createHarness();
11521264 const now = new Date().toISOString();
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+21−2
@@ -97,6 +97,21 @@ function isUnknownPendingApprovalRequestError(cause: Cause.Cause<ProviderService · 96 unmodified lines
9797 );
9898 }
9999
100+function isUnknownPendingUserInputRequestError(cause: Cause.Cause<ProviderServiceError>): boolean {
101+ const error = Cause.squash(cause);
102+ if (Schema.is(ProviderAdapterRequestError)(error)) {
103+ return error.detail.toLowerCase().includes("unknown pending user-input request");
104+ }
105+ return Cause.pretty(cause).toLowerCase().includes("unknown pending user-input request");
106+}
107+
108+function stalePendingRequestDetail(
109+ requestKind: "approval" | "user-input",
110+ requestId: string,
111+): string {
112+ return `Stale pending ${requestKind} request: ${requestId}. Provider callback state does not survive app restarts or recovered sessions. Restart the turn to continue.`;
113+}
114+
100115 function isTemporaryWorktreeBranch(branch: string): boolean {
101116 return TEMP_WORKTREE_BRANCH_PATTERN.test(branch.trim().toLowerCase());
102117 }
@@ -589,7 +604,9 @@ const make = Effect.gen(function* () { · 486 unmodified lines
589604 threadId: event.payload.threadId,
590605 kind: "provider.approval.respond.failed",
591606 summary: "Provider approval response failed",
592− detail: Cause.pretty(cause),
607+ detail: isUnknownPendingApprovalRequestError(cause)
608+ ? stalePendingRequestDetail("approval", event.payload.requestId)
609+ : Cause.pretty(cause),
593610 turnId: null,
594611 createdAt: event.payload.createdAt,
595612 requestId: event.payload.requestId,
@@ -633,7 +650,9 @@ const make = Effect.gen(function* () { · 37 unmodified lines
633650 threadId: event.payload.threadId,
634651 kind: "provider.user-input.respond.failed",
635652 summary: "Provider user input response failed",
636− detail: Cause.pretty(cause),
653+ detail: isUnknownPendingUserInputRequestError(cause)
654+ ? stalePendingRequestDetail("user-input", event.payload.requestId)
655+ : Cause.pretty(cause),
637656 turnId: null,
638657 createdAt: event.payload.createdAt,
639658 requestId: event.payload.requestId,
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+166−0
@@ -1089,6 +1089,172 @@ describe("ClaudeAdapterLive", () => { · 1088 unmodified lines
10891089 },
10901090 );
10911091
1092+ it.effect("creates a fresh assistant message when Claude reuses a text block index", () => {
1093+ const harness = makeHarness();
1094+ return Effect.gen(function* () {
1095+ const adapter = yield* ClaudeAdapter;
1096+
1097+ const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 9).pipe(
1098+ Stream.runCollect,
1099+ Effect.forkChild,
1100+ );
1101+
1102+ const session = yield* adapter.startSession({
1103+ threadId: THREAD_ID,
1104+ provider: "claudeAgent",
1105+ runtimeMode: "full-access",
1106+ });
1107+
1108+ yield* adapter.sendTurn({
1109+ threadId: session.threadId,
1110+ input: "hello",
1111+ attachments: [],
1112+ });
1113+
1114+ harness.query.emit({
1115+ type: "stream_event",
1116+ session_id: "sdk-session-reused-text-index",
1117+ uuid: "stream-reused-start-1",
1118+ parent_tool_use_id: null,
1119+ event: {
1120+ type: "content_block_start",
1121+ index: 0,
1122+ content_block: {
1123+ type: "text",
1124+ text: "",
1125+ },
1126+ },
1127+ } as unknown as SDKMessage);
1128+
1129+ harness.query.emit({
1130+ type: "stream_event",
1131+ session_id: "sdk-session-reused-text-index",
1132+ uuid: "stream-reused-delta-1",
1133+ parent_tool_use_id: null,
1134+ event: {
1135+ type: "content_block_delta",
1136+ index: 0,
1137+ delta: {
1138+ type: "text_delta",
1139+ text: "First",
1140+ },
1141+ },
1142+ } as unknown as SDKMessage);
1143+
1144+ harness.query.emit({
1145+ type: "stream_event",
1146+ session_id: "sdk-session-reused-text-index",
1147+ uuid: "stream-reused-stop-1",
1148+ parent_tool_use_id: null,
1149+ event: {
1150+ type: "content_block_stop",
1151+ index: 0,
1152+ },
1153+ } as unknown as SDKMessage);
1154+
1155+ harness.query.emit({
1156+ type: "stream_event",
1157+ session_id: "sdk-session-reused-text-index",
1158+ uuid: "stream-reused-start-2",
1159+ parent_tool_use_id: null,
1160+ event: {
1161+ type: "content_block_start",
1162+ index: 0,
1163+ content_block: {
1164+ type: "text",
1165+ text: "",
1166+ },
1167+ },
1168+ } as unknown as SDKMessage);
1169+
1170+ harness.query.emit({
1171+ type: "stream_event",
1172+ session_id: "sdk-session-reused-text-index",
1173+ uuid: "stream-reused-delta-2",
1174+ parent_tool_use_id: null,
1175+ event: {
1176+ type: "content_block_delta",
1177+ index: 0,
1178+ delta: {
1179+ type: "text_delta",
1180+ text: "Second",
1181+ },
1182+ },
1183+ } as unknown as SDKMessage);
1184+
1185+ harness.query.emit({
1186+ type: "stream_event",
1187+ session_id: "sdk-session-reused-text-index",
1188+ uuid: "stream-reused-stop-2",
1189+ parent_tool_use_id: null,
1190+ event: {
1191+ type: "content_block_stop",
1192+ index: 0,
1193+ },
1194+ } as unknown as SDKMessage);
1195+
1196+ harness.query.emit({
1197+ type: "result",
1198+ subtype: "success",
1199+ is_error: false,
1200+ errors: [],
1201+ session_id: "sdk-session-reused-text-index",
1202+ uuid: "result-reused-text-index",
1203+ } as unknown as SDKMessage);
1204+
1205+ const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
1206+ assert.deepEqual(
1207+ runtimeEvents.map((event) => event.type),
1208+ [
1209+ "session.started",
1210+ "session.configured",
1211+ "session.state.changed",
1212+ "turn.started",
1213+ "thread.started",
1214+ "content.delta",
1215+ "item.completed",
1216+ "content.delta",
1217+ "item.completed",
1218+ ],
1219+ );
1220+
1221+ const assistantDeltas = runtimeEvents.filter(
1222+ (event) => event.type === "content.delta" && event.payload.streamKind === "assistant_text",
1223+ );
1224+ assert.equal(assistantDeltas.length, 2);
1225+ if (assistantDeltas.length !== 2) {
1226+ return;
1227+ }
1228+ const [firstAssistantDelta, secondAssistantDelta] = assistantDeltas;
1229+ assert.equal(firstAssistantDelta?.type, "content.delta");
1230+ assert.equal(secondAssistantDelta?.type, "content.delta");
1231+ if (
1232+ firstAssistantDelta?.type !== "content.delta" ||
1233+ secondAssistantDelta?.type !== "content.delta"
1234+ ) {
1235+ return;
1236+ }
1237+ assert.equal(firstAssistantDelta.payload.delta, "First");
1238+ assert.equal(secondAssistantDelta.payload.delta, "Second");
1239+ assert.notEqual(firstAssistantDelta.itemId, secondAssistantDelta.itemId);
1240+
1241+ const assistantCompletions = runtimeEvents.filter(
1242+ (event) =>
1243+ event.type === "item.completed" && event.payload.itemType === "assistant_message",
1244+ );
1245+ assert.equal(assistantCompletions.length, 2);
1246+ assert.equal(String(assistantCompletions[0]?.itemId), String(firstAssistantDelta.itemId));
1247+ assert.equal(String(assistantCompletions[1]?.itemId), String(secondAssistantDelta.itemId));
1248+ assert.notEqual(
1249+ String(assistantCompletions[0]?.itemId),
1250+ String(assistantCompletions[1]?.itemId),
1251+ );
1252+ }).pipe(
1253+ Effect.provideService(Random.Random, makeDeterministicRandomService()),
1254+ Effect.provide(harness.layer),
1255+ );
1256+ });
1257+
10921258 it.effect("falls back to assistant payload text when stream deltas are absent", () => {
10931259 const harness = makeHarness();
10941260 return Effect.gen(function* () {
apps/server/src/provider/Layers/ClaudeAdapter.ts+18−23
@@ -87,13 +87,14 @@ interface ClaudeTurnState { · 86 unmodified lines
8787 readonly startedAt: string;
8888 readonly items: Array<unknown>;
8989 readonly assistantTextBlocks: Map<number, AssistantTextBlockState>;
90− readonly assistantTextBlockOrder: Array<number>;
90+ readonly assistantTextBlockOrder: Array<AssistantTextBlockState>;
9191 readonly capturedProposedPlanKeys: Set<string>;
9292 nextSyntheticAssistantBlockIndex: number;
9393 }
9494
9595 interface AssistantTextBlockState {
9696 readonly itemId: string;
97+ readonly blockIndex: number;
9798 emittedTextDelta: boolean;
9899 fallbackText: string;
99100 streamClosed: boolean;
@@ -829,7 +830,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { · 729 unmodified lines
829830 }
830831
831832 const existing = turnState.assistantTextBlocks.get(blockIndex);
832− if (existing) {
833+ if (existing && !existing.completionEmitted) {
833834 if (existing.fallbackText.length === 0 && options?.fallbackText) {
834835 existing.fallbackText = options.fallbackText;
835836 }
@@ -841,13 +842,14 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { · 5 unmodified lines
841842
842843 const block: AssistantTextBlockState = {
843844 itemId: yield* Random.nextUUIDv4,
845+ blockIndex,
844846 emittedTextDelta: false,
845847 fallbackText: options?.fallbackText ?? "",
846848 streamClosed: options?.streamClosed ?? false,
847849 completionEmitted: false,
848850 };
849851 turnState.assistantTextBlocks.set(blockIndex, block);
850− turnState.assistantTextBlockOrder.push(blockIndex);
852+ turnState.assistantTextBlockOrder.push(block);
851853 return { blockIndex, block };
852854 });
853855
@@ -877,7 +879,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { · 23 unmodified lines
877879
878880 const completeAssistantTextBlock = (
879881 context: ClaudeSessionContext,
880− blockIndex: number,
882+ block: AssistantTextBlockState,
881883 options?: {
882884 readonly force?: boolean;
883885 readonly rawMethod?: string;
@@ -886,8 +888,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { · 2 unmodified lines
886888 ): Effect.Effect<void> =>
887889 Effect.gen(function* () {
888890 const turnState = context.turnState;
889− const block = turnState?.assistantTextBlocks.get(blockIndex);
890− if (!turnState || !block || block.completionEmitted) {
891+ if (!turnState || block.completionEmitted) {
891892 return;
892893 }
893894
@@ -923,6 +924,9 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { · 29 unmodified lines
923924 }
924925
925926 block.completionEmitted = true;
927+ if (turnState.assistantTextBlocks.get(block.blockIndex) === block) {
928+ turnState.assistantTextBlocks.delete(block.blockIndex);
929+ }
926930
927931 const stamp = yield* makeEventStamp();
928932 yield* offerRuntimeEvent({
@@ -967,19 +971,10 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { · 38 unmodified lines
967971 return;
968972 }
969973
970− const orderedBlocks = turnState.assistantTextBlockOrder
971− .map((blockIndex) => ({
972− blockIndex,
973− block: turnState.assistantTextBlocks.get(blockIndex),
974− }))
975− .filter(
976− (
977− entry,
978− ): entry is {
979− readonly blockIndex: number;
980− readonly block: AssistantTextBlockState;
981− } => entry.block !== undefined,
982− );
974+ const orderedBlocks = turnState.assistantTextBlockOrder.map((block) => ({
975+ blockIndex: block.blockIndex,
976+ block,
977+ }));
983978
984979 for (const [position, text] of snapshotTextBlocks.entries()) {
985980 const existingEntry = orderedBlocks[position];
@@ -1003,7 +998,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { · 17 unmodified lines
1003998 }
1004999
10051000 if (entry.block.streamClosed && !entry.block.completionEmitted) {
1006− yield* completeAssistantTextBlock(context, entry.blockIndex, {
1001+ yield* completeAssistantTextBlock(context, entry.block, {
10071002 rawMethod: "claude/assistant",
10081003 rawPayload: message,
10091004 });
@@ -1208,8 +1203,8 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { · 198 unmodified lines
12081203 // Clear any remaining stale entries (e.g. from interrupted content blocks)
12091204 context.inFlightTools.clear();
12101205
1211− for (const blockIndex of turnState.assistantTextBlockOrder) {
1212− yield* completeAssistantTextBlock(context, blockIndex, {
1206+ for (const block of turnState.assistantTextBlockOrder) {
1207+ yield* completeAssistantTextBlock(context, block, {
12131208 force: true,
12141209 rawMethod: "claude/result",
12151210 rawPayload: result ?? { status },
@@ -1460,7 +1455,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { · 244 unmodified lines
14601455 const assistantBlock = context.turnState?.assistantTextBlocks.get(index);
14611456 if (assistantBlock) {
14621457 assistantBlock.streamClosed = true;
1463− yield* completeAssistantTextBlock(context, index, {
1458+ yield* completeAssistantTextBlock(context, assistantBlock, {
14641459 rawMethod: "claude/stream_event/content_block_stop",
14651460 rawPayload: message,
14661461 });
apps/server/src/provider/Layers/ProviderService.test.ts+32−7
@@ -195,9 +195,21 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") { · 194 unmodified lines
195195 Effect.runSync(PubSub.publish(runtimeEventPubSub, event as unknown as ProviderRuntimeEvent));
196196 };
197197
198+ const updateSession = (
199+ threadId: ThreadId,
200+ update: (session: ProviderSession) => ProviderSession,
201+ ): void => {
202+ const existing = sessions.get(threadId);
203+ if (!existing) {
204+ return;
205+ }
206+ sessions.set(threadId, update(existing));
207+ };
208+
198209 return {
199210 adapter,
200211 emit,
212+ updateSession,
201213 startSession,
202214 sendTurn,
203215 interruptTurn,
@@ -348,16 +360,29 @@ it.effect( · 144 unmodified lines
348360 Layer.provide(firstDirectoryLayer),
349361 Layer.provide(AnalyticsService.layerTest),
350362 );
363+ const updatedResumeCursor = {
364+ threadId: asThreadId("thread-1"),
365+ resume: "resume-session-1",
366+ resumeSessionAt: "assistant-message-1",
367+ turnCount: 1,
368+ };
351369
352370 const startedSession = yield* Effect.gen(function* () {
353371 const provider = yield* ProviderService;
354372 const threadId = asThreadId("thread-1");
355− return yield* provider.startSession(threadId, {
373+ const session = yield* provider.startSession(threadId, {
356374 provider: "codex",
357375 cwd: "/tmp/project",
358376 runtimeMode: "full-access",
359377 threadId,
360378 });
379+ firstCodex.updateSession(threadId, (existing) => ({
380+ ...existing,
381+ status: "ready",
382+ resumeCursor: updatedResumeCursor,
383+ updatedAt: new Date(Date.now() + 1_000).toISOString(),
384+ }));
385+ return session;
361386 }).pipe(Effect.provide(firstProviderLayer));
362387
363388 const persistedAfterStopAll = yield* Effect.gen(function* () {
@@ -367,7 +392,7 @@ it.effect( · 3 unmodified lines
367392 assert.equal(Option.isSome(persistedAfterStopAll), true);
368393 if (Option.isSome(persistedAfterStopAll)) {
369394 assert.equal(persistedAfterStopAll.value.status, "stopped");
370− assert.deepEqual(persistedAfterStopAll.value.resumeCursor, startedSession.resumeCursor);
395+ assert.deepEqual(persistedAfterStopAll.value.resumeCursor, updatedResumeCursor);
371396 }
372397
373398 const secondCodex = makeFakeCodexAdapter();
@@ -410,7 +435,7 @@ it.effect( · 36 unmodified lines
410435 };
411436 assert.equal(startPayload.provider, "codex");
412437 assert.equal(startPayload.cwd, "/tmp/project");
413− assert.deepEqual(startPayload.resumeCursor, startedSession.resumeCursor);
438+ assert.deepEqual(startPayload.resumeCursor, updatedResumeCursor);
414439 assert.equal(startPayload.threadId, startedSession.threadId);
415440 }
416441 assert.equal(secondCodex.rollbackThread.mock.calls.length, 1);
@@ -733,7 +758,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { · 316 unmodified lines
733758 const consumer = yield* Stream.runForEach(provider.streamEvents, (event) =>
734759 Ref.update(eventsRef, (current) => [...current, event]),
735760 ).pipe(Effect.forkChild);
736− yield* sleep(20);
761+ yield* sleep(50);
737762
738763 const completedEvent: LegacyProviderRuntimeEvent = {
739764 type: "turn.completed",
@@ -746,7 +771,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { · 6 unmodified lines
746771 };
747772
748773 fanout.codex.emit(completedEvent);
749− yield* sleep(20);
774+ yield* sleep(50);
750775
751776 const events = yield* Ref.get(eventsRef);
752777 yield* Fiber.interrupt(consumer);
@@ -772,7 +797,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { · 19 unmodified lines
772797 Stream.runForEach((event) => Ref.update(receivedRef, (current) => [...current, event])),
773798 Effect.forkChild,
774799 );
775− yield* sleep(20);
800+ yield* sleep(50);
776801
777802 fanout.codex.emit({
778803 type: "tool.started",
@@ -836,7 +861,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { · 57 unmodified lines
836861 Stream.runForEach(() => Effect.fail("listener crash")),
837862 Effect.forkChild,
838863 );
839− yield* sleep(20);
864+ yield* sleep(50);
840865
841866 const events: ReadonlyArray<LegacyProviderRuntimeEvent> = [
842867 {
apps/server/src/provider/Layers/ProviderService.ts+27−3
@@ -88,7 +88,12 @@ function toRuntimeStatus(session: ProviderSession): "starting" | "running" | "st · 87 unmodified lines
8888
8989 function toRuntimePayloadFromSession(
9090 session: ProviderSession,
91− extra?: { readonly modelOptions?: unknown; readonly providerOptions?: unknown },
91+ extra?: {
92+ readonly modelOptions?: unknown;
93+ readonly providerOptions?: unknown;
94+ readonly lastRuntimeEvent?: string;
95+ readonly lastRuntimeEventAt?: string;
96+ },
9297 ): Record<string, unknown> {
9398 return {
9499 cwd: session.cwd ?? null,
@@ -97,6 +102,10 @@ function toRuntimePayloadFromSession( · 2 unmodified lines
97102 lastError: session.lastError ?? null,
98103 ...(extra?.modelOptions !== undefined ? { modelOptions: extra.modelOptions } : {}),
99104 ...(extra?.providerOptions !== undefined ? { providerOptions: extra.providerOptions } : {}),
105+ ...(extra?.lastRuntimeEvent !== undefined ? { lastRuntimeEvent: extra.lastRuntimeEvent } : {}),
106+ ...(extra?.lastRuntimeEventAt !== undefined
107+ ? { lastRuntimeEventAt: extra.lastRuntimeEventAt }
108+ : {}),
100109 };
101110 }
102111
@@ -162,7 +171,12 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => · 59 unmodified lines
162171 const upsertSessionBinding = (
163172 session: ProviderSession,
164173 threadId: ThreadId,
165− extra?: { readonly modelOptions?: unknown; readonly providerOptions?: unknown },
174+ extra?: {
175+ readonly modelOptions?: unknown;
176+ readonly providerOptions?: unknown;
177+ readonly lastRuntimeEvent?: string;
178+ readonly lastRuntimeEventAt?: string;
179+ },
166180 ) =>
167181 directory.upsert({
168182 threadId,
@@ -177,7 +191,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => · 8 unmodified lines
177191 const adapters = yield* Effect.forEach(providers, (provider) =>
178192 registry.getByProvider(provider),
179193 );
180−
181194 const processRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> =>
182195 publishRuntimeEvent(event);
183196
@@ -513,6 +526,17 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => · 329 unmodified lines
513526 const runStopAll = () =>
514527 Effect.gen(function* () {
515528 const threadIds = yield* directory.listThreadIds();
529+ const activeSessions = yield* Effect.forEach(adapters, (adapter) =>
530+ adapter.listSessions(),
531+ ).pipe(
532+ Effect.map((sessionsByAdapter) => sessionsByAdapter.flatMap((sessions) => sessions)),
533+ );
534+ yield* Effect.forEach(activeSessions, (session) =>
535+ upsertSessionBinding(session, session.threadId, {
536+ lastRuntimeEvent: "provider.stopAll",
537+ lastRuntimeEventAt: new Date().toISOString(),
538+ }),
539+ ).pipe(Effect.asVoid);
516540 yield* Effect.forEach(adapters, (adapter) => adapter.stopAll()).pipe(Effect.asVoid);
517541 yield* Effect.forEach(threadIds, (threadId) =>
518542 directory.getProvider(threadId).pipe(
apps/web/src/session-logic.test.ts+72−0
@@ -142,6 +142,36 @@ describe("derivePendingApprovals", () => { · 141 unmodified lines
142142
143143 expect(derivePendingApprovals(activities)).toEqual([]);
144144 });
145+
146+ it("clears stale pending approvals when the backend marks them stale after restart", () => {
147+ const activities: OrchestrationThreadActivity[] = [
148+ makeActivity({
149+ id: "approval-open-stale-restart",
150+ createdAt: "2026-02-23T00:00:01.000Z",
151+ kind: "approval.requested",
152+ summary: "Command approval requested",
153+ tone: "approval",
154+ payload: {
155+ requestId: "req-stale-restart-1",
156+ requestKind: "command",
157+ },
158+ }),
159+ makeActivity({
160+ id: "approval-failed-stale-restart",
161+ createdAt: "2026-02-23T00:00:02.000Z",
162+ kind: "provider.approval.respond.failed",
163+ summary: "Provider approval response failed",
164+ tone: "error",
165+ payload: {
166+ requestId: "req-stale-restart-1",
167+ detail:
168+ "Stale pending approval request: req-stale-restart-1. Provider callback state does not survive app restarts or recovered sessions. Restart the turn to continue.",
169+ },
170+ }),
171+ ];
172+
173+ expect(derivePendingApprovals(activities)).toEqual([]);
174+ });
145175 });
146176
147177 describe("derivePendingUserInputs", () => {
@@ -228,6 +258,48 @@ describe("derivePendingUserInputs", () => { · 80 unmodified lines
228258 },
229259 ]);
230260 });
261+
262+ it("clears stale pending user-input prompts when the provider reports an orphaned request", () => {
263+ const activities: OrchestrationThreadActivity[] = [
264+ makeActivity({
265+ id: "user-input-open-stale",
266+ createdAt: "2026-02-23T00:00:01.000Z",
267+ kind: "user-input.requested",
268+ summary: "User input requested",
269+ tone: "info",
270+ payload: {
271+ requestId: "req-user-input-stale-1",
272+ questions: [
273+ {
274+ id: "sandbox_mode",
275+ header: "Sandbox",
276+ question: "Which mode should be used?",
277+ options: [
278+ {
279+ label: "workspace-write",
280+ description: "Allow workspace writes only",
281+ },
282+ ],
283+ },
284+ ],
285+ },
286+ }),
287+ makeActivity({
288+ id: "user-input-failed-stale",
289+ createdAt: "2026-02-23T00:00:02.000Z",
290+ kind: "provider.user-input.respond.failed",
291+ summary: "Provider user input response failed",
292+ tone: "error",
293+ payload: {
294+ requestId: "req-user-input-stale-1",
295+ detail:
296+ "Stale pending user-input request: req-user-input-stale-1. Provider callback state does not survive app restarts or recovered sessions. Restart the turn to continue.",
297+ },
298+ }),
299+ ];
300+
301+ expect(derivePendingUserInputs(activities)).toEqual([]);
302+ });
231303 });
232304
233305 describe("deriveActivePlanState", () => {
apps/web/src/session-logic.ts+25−1
@@ -160,6 +160,20 @@ function requestKindFromRequestType(requestType: unknown): PendingApproval["requ · 159 unmodified lines
160160 }
161161 }
162162
163+function isStalePendingRequestFailureDetail(detail: string | undefined): boolean {
164+ const normalized = detail?.toLowerCase();
165+ if (!normalized) {
166+ return false;
167+ }
168+ return (
169+ normalized.includes("stale pending approval request") ||
170+ normalized.includes("stale pending user-input request") ||
171+ normalized.includes("unknown pending approval request") ||
172+ normalized.includes("unknown pending permission request") ||
173+ normalized.includes("unknown pending user-input request")
174+ );
175+}
176+
163177 export function derivePendingApprovals(
164178 activities: ReadonlyArray<OrchestrationThreadActivity>,
165179 ): PendingApproval[] {
@@ -204,7 +218,7 @@ export function derivePendingApprovals( · 38 unmodified lines
204218 if (
205219 activity.kind === "provider.approval.respond.failed" &&
206220 requestId &&
207− detail?.includes("Unknown pending permission request")
221+ isStalePendingRequestFailureDetail(detail)
208222 ) {
209223 openByRequestId.delete(requestId);
210224 continue;
@@ -280,6 +294,7 @@ export function derivePendingUserInputs( · 69 unmodified lines
280294 payload && typeof payload.requestId === "string"
281295 ? ApprovalRequestId.makeUnsafe(payload.requestId)
282296 : null;
297+ const detail = payload && typeof payload.detail === "string" ? payload.detail : undefined;
283298
284299 if (activity.kind === "user-input.requested" && requestId) {
285300 const questions = parseUserInputQuestions(payload);
@@ -296,6 +311,15 @@ export function derivePendingUserInputs( · 10 unmodified lines
296311
297312 if (activity.kind === "user-input.resolved" && requestId) {
298313 openByRequestId.delete(requestId);
314+ continue;
315+ }
316+
317+ if (
318+ activity.kind === "provider.user-input.respond.failed" &&
319+ requestId &&
320+ isStalePendingRequestFailureDetail(detail)
321+ ) {
322+ openByRequestId.delete(requestId);
299323 }
300324 }
301325