From 6d6f81cfe862794bbe06e9e08436e52e07d1be1d Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Fri, 4 Sep 2026 11:29:08 -0600 Subject: [PATCH] fix(server): show turns the provider starts on its own --- .../Layers/ProviderRuntimeIngestion.test.ts | 86 +++++++++++++++++++ .../Layers/ProviderRuntimeIngestion.ts | 39 ++++++++- 2 files changed, 123 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 0f82edcf9..454e8cc14 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -1162,6 +1162,92 @@ describe("ProviderRuntimeIngestion", () => { expect(thread.session?.activeTurnRequestId).toBe(requestId); }); + it("admits a provider-initiated turn and its output on an idle incarnation-tracked session", async () => { + // Claude continues on its own when a background task finishes: the + // adapter opens a synthetic turn with no admission request id. The strict + // gate must still let that turn and its output through when the session is + // idle, or the continuation runs invisibly. + const harness = await createHarness(); + const threadId = asThreadId("thread-1"); + const instanceId = ProviderInstanceId.make("codex"); + const incarnationId = RuntimeSessionId.make("session-provider-initiated"); + const turnId = asTurnId("turn-provider-initiated"); + const seededAt = "2026-01-01T00:00:00.000Z"; + await harness.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-provider-initiated-seed"), + threadId, + session: { + threadId, + status: "ready", + providerName: ProviderDriverKind.make("codex"), + providerInstanceId: instanceId, + runtimeMode: "approval-required", + sessionIncarnationId: incarnationId, + activeTurnId: null, + lastError: null, + updatedAt: seededAt, + }, + createdAt: seededAt, + }); + const eventBase = { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: instanceId, + sessionIncarnationId: incarnationId, + threadId, + turnId, + createdAt: "2026-01-01T00:00:01.000Z", + } as const; + + harness.emit({ + ...eventBase, + type: "turn.started", + eventId: asEventId("evt-provider-initiated-start"), + payload: {}, + }); + let thread = await waitForThread( + harness.readModel, + (entry) => entry.session?.status === "running" && entry.session.activeTurnId === turnId, + ); + expect(thread.session?.activeTurnRequestId).toBeUndefined(); + + // Output from some other turn is still stale and stays out. + harness.emit({ + ...eventBase, + turnId: asTurnId("turn-provider-initiated-other"), + type: "item.completed", + eventId: asEventId("evt-provider-initiated-stale-item"), + itemId: asItemId("item-provider-initiated-stale"), + payload: { itemType: "assistant_message", detail: "stale output" }, + }); + harness.emit({ + ...eventBase, + type: "item.completed", + eventId: asEventId("evt-provider-initiated-item"), + itemId: asItemId("item-provider-initiated"), + payload: { itemType: "assistant_message", detail: "Background continuation output" }, + }); + thread = await waitForThread(harness.readModel, (entry) => + entry.messages.some( + (message) => + message.role === "assistant" && message.text === "Background continuation output", + ), + ); + expect(thread.messages.some((message) => message.text === "stale output")).toBe(false); + + harness.emit({ + ...eventBase, + type: "turn.completed", + eventId: asEventId("evt-provider-initiated-complete"), + payload: { state: "completed" }, + }); + thread = await waitForThread( + harness.readModel, + (entry) => entry.session?.status === "ready" && entry.session.activeTurnId === null, + ); + expect(thread.session?.sessionIncarnationId).toBe(incarnationId); + }); + it("filters stale session A lifecycle and content before accepting session B", async () => { const harness = await createHarness(); const threadId = asThreadId("thread-1"); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 0aab5d7ca..61683577f 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -2349,7 +2349,36 @@ const make = Effect.gen(function* () { return; } let admissionAcceptedByCas = false; - if (event.type === "turn.started" && strictSessionIncarnation !== undefined) { + // A turn the provider opened on its own (Claude continuing after a + // background task finishes) carries no admission request id. It is + // admitted when the incarnation-tracked session is idle: nothing pending + // and no active turn. Its output then passes for as long as it is the + // active turn and the session still has no admitted request id. Any + // event that does carry a request id keeps needing the exact match, so + // stale admitted turns stay fenced. + const gateEventTurnId = toTurnId(event.turnId); + const providerInitiatedTurnStart = + event.type === "turn.started" && + strictSessionIncarnation !== undefined && + event.admissionRequestId === undefined && + event.sessionIncarnationId === strictSessionIncarnation && + gateEventTurnId !== undefined && + thread.session?.pendingTurnRequestId === undefined && + thread.session?.activeTurnId === null && + (thread.session.status === "ready" || thread.session.status === "running"); + const providerInitiatedTurnOutput = + strictSessionIncarnation !== undefined && + event.admissionRequestId === undefined && + thread.session?.activeTurnRequestId === undefined && + thread.session?.activeTurnId != null && + gateEventTurnId !== undefined && + sameId(thread.session.activeTurnId, gateEventTurnId); + + if ( + event.type === "turn.started" && + strictSessionIncarnation !== undefined && + !providerInitiatedTurnStart + ) { const eventTurnId = toTurnId(event.turnId); if ( thread.session?.status !== "starting" || @@ -2404,6 +2433,8 @@ const make = Effect.gen(function* () { if ( !admissionAcceptedByCas && isTurnScoped && + !providerInitiatedTurnStart && + !providerInitiatedTurnOutput && (activeRequestId === undefined || event.admissionRequestId !== activeRequestId) ) { return; @@ -2512,7 +2543,11 @@ const make = Effect.gen(function* () { return true; case "turn.started": if (activeTurnId === null) { - return turnStartedMatchesPendingAdmission || turnStartedIsLegacyUncorrelated; + return ( + turnStartedMatchesPendingAdmission || + turnStartedIsLegacyUncorrelated || + providerInitiatedTurnStart + ); } if (!conflictsWithActiveTurn) return true; return conflictingTurnStartIsPendingTurnStart;