From 133941c3cd35d8fc9698c3008c6607fe7a868a72 Mon Sep 17 00:00:00 2001 From: Lior Yasur Date: Wed, 16 Sep 2026 00:48:54 +0300 Subject: [PATCH 1/4] feat(server): add exact-session conditional dispatch --- .../OrchestrationEngineHarness.integration.ts | 1 + .../Layers/ProjectionPipeline.ts | 1 + .../Layers/ProjectionSnapshotQuery.test.ts | 2 + .../Layers/ProjectionSnapshotQuery.ts | 16 +- .../Layers/ProviderCommandReactor.test.ts | 228 +++++- .../Layers/ProviderCommandReactor.ts | 199 +++++- .../Layers/ProviderRuntimeIngestion.ts | 3 + .../decider.sessionFence.test.ts | 190 +++++ apps/server/src/orchestration/decider.ts | 45 +- .../Layers/ProjectionThreadSessions.ts | 4 + .../Services/ProjectionThreadSessions.ts | 1 + .../src/project/AgentSessionImporter.test.ts | 2 +- apps/server/src/provider/Errors.ts | 13 +- .../src/provider/Layers/ClaudeAdapter.test.ts | 24 + .../src/provider/Layers/ClaudeAdapter.ts | 10 +- .../provider/Layers/ProviderService.test.ts | 658 +++++++++++++++--- .../src/provider/Layers/ProviderService.ts | 407 ++++++++--- .../src/provider/Services/ProviderAdapter.ts | 6 +- packages/contracts/src/orchestration.test.ts | 31 + packages/contracts/src/orchestration.ts | 25 + packages/contracts/src/provider.ts | 5 + 21 files changed, 1643 insertions(+), 228 deletions(-) create mode 100644 apps/server/src/orchestration/decider.sessionFence.test.ts diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 4d8a384ee997..72c97ec6fde1 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -423,6 +423,7 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge(orchestrationReactorLayer), Layer.provideMerge(providerRegistryLayer), + Layer.provideMerge(OrchestrationCommandReceiptRepositoryLive), Layer.provide(persistenceLayer), Layer.provideMerge(RepositoryIdentityResolver.layer), Layer.provideMerge(ServerSettingsService.layerTest()), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index f6913426a0a3..7016d9476efa 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1350,6 +1350,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti yield* projectionThreadSessionRepository.upsert({ threadId: event.payload.threadId, status: event.payload.session.status, + providerSessionId: event.payload.session.providerSessionId ?? null, providerName: event.payload.session.providerName, providerInstanceId: event.payload.session.providerInstanceId ?? null, runtimeMode: event.payload.session.runtimeMode, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 6183dbc66168..3cdfed8c9d86 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -536,6 +536,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { threadId: ThreadId.make("thread-1"), status: "running", providerName: "codex", + providerSessionId: "provider-session-1", runtimeMode: "approval-required", activeTurnId: asTurnId("turn-1"), lastError: null, @@ -617,6 +618,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { threadId: ThreadId.make("thread-1"), status: "running", providerName: "codex", + providerSessionId: "provider-session-1", runtimeMode: "approval-required", activeTurnId: asTurnId("turn-1"), lastError: null, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index edfee6fbdd9e..121b516436fd 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -377,6 +377,7 @@ function mapSessionRow( return { threadId: row.threadId, status: row.status, + ...(row.providerSessionId != null ? { providerSessionId: row.providerSessionId } : {}), providerName: row.providerName, ...(row.providerInstanceId !== null ? { providerInstanceId: row.providerInstanceId } : {}), runtimeMode: row.runtimeMode, @@ -1253,6 +1254,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { threads.title, threads.title_state_json AS "titleState", sessions.thread_id AS "threadId", + sessions.provider_session_id AS "providerSessionId", sessions.status, sessions.provider_name AS "providerName", sessions.provider_instance_id AS "providerInstanceId", @@ -1571,6 +1573,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { sql` SELECT thread_id AS "threadId", + provider_session_id AS "providerSessionId", status, provider_name AS "providerName", provider_instance_id AS "providerInstanceId", @@ -2240,18 +2243,7 @@ pending_approval_requests AS ( for (const row of sessionRows) { updatedAt = maxIso(updatedAt, row.updatedAt); - sessionsByThread.set(row.threadId, { - threadId: row.threadId, - status: row.status, - providerName: row.providerName, - ...(row.providerInstanceId !== null - ? { providerInstanceId: row.providerInstanceId } - : {}), - runtimeMode: row.runtimeMode, - activeTurnId: row.activeTurnId, - lastError: row.lastError, - updatedAt: row.updatedAt, - }); + sessionsByThread.set(row.threadId, mapSessionRow(row)); } const repositoryIdentities = yield* resolveRepositoryIdentitiesForProjects( diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 9bc701af0837..f17b5ccee287 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -42,6 +42,7 @@ import { deriveServerPaths, ServerConfig } from "../../config.ts"; import { TextGenerationError } from "@t3tools/contracts"; import { ProviderAdapterRequestError, + ProviderSessionFenceError, ProviderWorkspaceMissingError, type ProviderServiceError, } from "../../provider/Errors.ts"; @@ -181,7 +182,11 @@ describe("ProviderCommandReactor", () => { readonly beforeTurnStartDispatch?: () => Effect.Effect; readonly afterTurnStartDispatch?: () => Effect.Effect; readonly compactThreadEffect?: () => Effect.Effect; - readonly interruptTurnEffect?: () => Effect.Effect; + readonly conditionalBeforeStart?: "intent" | "dispatching"; + readonly interruptTurnEffect?: () => Effect.Effect< + void, + ProviderAdapterRequestError | ProviderSessionFenceError + >; readonly stopSessionEffect?: () => Effect.Effect; readonly startSessionEffect?: ( session: ProviderSession, @@ -461,6 +466,9 @@ describe("ProviderCommandReactor", () => { }), ).pipe(Layer.provide(orchestrationLayer)); const layer = ProviderCommandReactorLive.pipe( + Layer.provide( + OrchestrationCommandReceiptRepositoryLive.pipe(Layer.provide(SqlitePersistenceMemory)), + ), Layer.provideMerge(reactorOrchestrationLayer), Layer.provideMerge(projectionSnapshotLayer), Layer.provideMerge(Layer.succeed(ProviderService, service)), @@ -581,6 +589,79 @@ describe("ProviderCommandReactor", () => { ); } + if (input?.conditionalBeforeStart) { + const threadId = ThreadId.make("thread-1"); + const commandId = CommandId.make("interrupted-conditional"); + const expectedSession = { + providerInstanceId: ProviderInstanceId.make("codex"), + providerSessionId: "prior-generation", + activeTurnId: null, + readiness: "ready" as const, + }; + await runEffect( + engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("prior-session"), + threadId, + session: { + threadId, + providerName: "codex", + providerInstanceId: expectedSession.providerInstanceId, + providerSessionId: expectedSession.providerSessionId, + status: "ready", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + await runEffect( + engine.dispatch({ + type: "thread.turn.start", + commandId, + threadId, + expectedSession, + message: { + messageId: asMessageId("prior-message"), + role: "user", + text: "Do not resend after restart", + attachments: [], + }, + runtimeMode: "approval-required", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + createdAt: now, + }), + ); + if (input.conditionalBeforeStart === "dispatching") { + await runEffect( + engine.dispatch({ + type: "thread.activity.append", + commandId: CommandId.make(`provider-execution:${commandId}:dispatching`), + threadId, + activity: { + id: EventId.make("prior-dispatching"), + tone: "info", + kind: "provider.command.execution", + summary: "Dispatch started", + payload: { + commandId, + operation: "send-turn", + expectedSession, + status: "dispatching", + turnId: null, + detail: "Dispatch started", + }, + turnId: null, + createdAt: now, + }, + createdAt: now, + }), + ); + } + } + scope = await Effect.runPromise(Scope.make("sequential")); const reactorScope = scope; const startReactor = () => @@ -848,6 +929,151 @@ describe("ProviderCommandReactor", () => { ); }), ); + it("records conditional delivery without starting a different session and replays without resending", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + const threadId = ThreadId.make("thread-1"); + const expectedSession = { + providerInstanceId: ProviderInstanceId.make("codex"), + providerSessionId: "selected-generation", + activeTurnId: null, + readiness: "ready" as const, + }; + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("set-selected-session"), + threadId, + session: { + threadId, + providerName: "codex", + providerInstanceId: expectedSession.providerInstanceId, + providerSessionId: expectedSession.providerSessionId, + status: "ready", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + const command = { + type: "thread.turn.start" as const, + threadId, + commandId: CommandId.make("conditional-send"), + expectedSession, + message: { + messageId: asMessageId("selected-message"), + role: "user" as const, + text: "Review this patch", + attachments: [], + }, + runtimeMode: "approval-required" as const, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + createdAt: now, + }; + await harness.runEffect(harness.engine.dispatch(command)); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + await harness.drain(); + const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); + expect( + thread?.activities.find( + (activity) => + activity.kind === "provider.command.execution" && + (activity.payload as { status?: string }).status === "dispatched", + )?.payload, + ).toMatchObject({ + commandId: command.commandId, + expectedSession, + status: "dispatched", + operation: "send-turn", + }); + expect(harness.startSession).not.toHaveBeenCalled(); + expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ expectedSession }); + await harness.runEffect(harness.engine.dispatch(command)); + await harness.drain(); + expect(harness.sendTurn).toHaveBeenCalledTimes(1); + }); + + for (const stage of ["intent", "dispatching"] as const) { + it(`closes a ${stage} left across startup without resending`, async () => { + const harness = await createHarness({ conditionalBeforeStart: stage }); + await harness.drain(); + const thread = (await harness.readModel()).threads.find((entry) => entry.id === "thread-1"); + const status = stage === "intent" ? "rejected" : "uncertain"; + expect( + thread?.activities.find( + (activity) => + activity.kind === "provider.command.execution" && + (activity.payload as { status?: string }).status === status, + )?.payload, + ).toMatchObject({ commandId: "interrupted-conditional", status }); + expect(harness.sendTurn).not.toHaveBeenCalled(); + expect(harness.startSession).not.toHaveBeenCalled(); + }); + } + + it("records an interrupt fence rejection without the ordinary fallback stop", async () => { + const harness = await createHarness({ + interruptTurnEffect: () => + Effect.fail( + new ProviderSessionFenceError({ + threadId: "thread-1", + detail: "Selected session was replaced", + }), + ), + }); + const now = "2026-01-01T00:00:00.000Z"; + const threadId = ThreadId.make("thread-1"); + const activeTurnId = asTurnId("selected-turn"); + const expectedSession = { + providerInstanceId: ProviderInstanceId.make("codex"), + providerSessionId: "selected-generation", + activeTurnId, + readiness: "running" as const, + }; + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("set-interrupted-session"), + threadId, + session: { + threadId, + providerName: "codex", + providerInstanceId: expectedSession.providerInstanceId, + providerSessionId: expectedSession.providerSessionId, + status: "running", + runtimeMode: "approval-required", + activeTurnId, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.interrupt", + commandId: CommandId.make("conditional-interrupt"), + threadId, + turnId: activeTurnId, + expectedSession, + createdAt: now, + }), + ); + await waitFor(() => harness.interruptTurn.mock.calls.length === 1); + await harness.drain(); + expect(harness.stopSession).not.toHaveBeenCalled(); + const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); + expect( + thread?.activities.find( + (activity) => + activity.kind === "provider.command.execution" && + (activity.payload as { status?: string }).status === "rejected", + )?.payload, + ).toMatchObject({ expectedSession, status: "rejected", operation: "interrupt-turn" }); + }); it("reacts to thread.turn.start by ensuring session and sending provider turn", async () => { const harness = await createHarness(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index b653047d8255..1b1fa94c64dd 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -11,6 +11,7 @@ import { type ProviderSession, type RuntimeMode, type TurnId, + type ProviderCommandExecutionResult, } from "@t3tools/contracts"; import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; import { projectComposerContextForProvider } from "@t3tools/shared/composerContextReferences"; @@ -35,9 +36,11 @@ import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts"; import { ProviderAdapterRequestError, + ProviderSessionFenceError, ProviderAdapterValidationError, ProviderWorkspaceMissingError, } from "../../provider/Errors.ts"; +import { OrchestrationCommandReceiptRepository } from "../../persistence/Services/OrchestrationCommandReceipts.ts"; import type { ProviderServiceError } from "../../provider/Errors.ts"; import { TextGeneration } from "../../textGeneration/TextGeneration.ts"; import { ProviderAuthService } from "../../provider/Services/ProviderAuthService.ts"; @@ -66,6 +69,7 @@ const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); const isProviderAdapterValidationError = Schema.is(ProviderAdapterValidationError); const isProviderWorkspaceMissingError = Schema.is(ProviderWorkspaceMissingError); const isProviderDriverKind = Schema.is(ProviderDriverKind); +const isSessionFenceError = Schema.is(ProviderSessionFenceError); type ProviderIntentEvent = Extract< OrchestrationEvent, @@ -207,6 +211,7 @@ function buildGeneratedWorktreeBranchName(raw: string): string { const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; + const executionReceipts = yield* OrchestrationCommandReceiptRepository; const orchestrationEngine = yield* OrchestrationEngineService; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; const providerAuthService = yield* ProviderAuthService; @@ -260,6 +265,78 @@ const make = Effect.gen(function* () { >(); const stoppingThreadIds = new Set(); + const executeConditionalCommand = Effect.fn("executeConditionalCommand")(function* ( + event: Extract< + ProviderIntentEvent, + { type: "thread.turn-start-requested" | "thread.turn-interrupt-requested" } + >, + execute: Effect.Effect, + ) { + const expectedSession = event.payload.expectedSession; + if (!expectedSession || !event.commandId) return; + const originalCommandId = event.commandId; + const markerId = CommandId.make(`provider-execution:${originalCommandId}:dispatching`); + const resultId = CommandId.make(`provider-execution:${originalCommandId}:result`); + const completed = yield* executionReceipts.getByCommandId({ commandId: resultId }); + if (Option.isSome(completed)) return; + const started = yield* executionReceipts.getByCommandId({ commandId: markerId }); + const operation = event.type === "thread.turn-start-requested" ? "send-turn" : "interrupt-turn"; + const append = (result: ProviderCommandExecutionResult) => + orchestrationEngine.dispatch({ + type: "thread.activity.append", + commandId: result.status === "dispatching" ? markerId : resultId, + threadId: event.payload.threadId, + activity: { + id: EventId.make(`${result.status === "dispatching" ? markerId : resultId}`), + tone: result.status === "rejected" || result.status === "uncertain" ? "error" : "info", + kind: "provider.command.execution", + summary: result.detail, + payload: result, + turnId: result.turnId, + createdAt: event.payload.createdAt, + }, + createdAt: event.payload.createdAt, + }); + const base = { + commandId: originalCommandId, + operation, + expectedSession, + turnId: null, + } as const; + if (Option.isSome(started)) { + yield* append({ + ...base, + status: "uncertain", + detail: "An earlier execution started without a final result. It will not be sent again.", + }); + return; + } + yield* append({ + ...base, + status: "dispatching", + detail: "Dispatching to the selected provider session.", + }); + yield* execute.pipe( + Effect.matchCauseEffect({ + onSuccess: (turnId) => + append({ + ...base, + turnId, + status: "dispatched", + detail: "Delivered to the selected provider session.", + }), + onFailure: (cause) => + append({ + ...base, + status: Cause.findErrorOption(cause).pipe(Option.exists(isSessionFenceError)) + ? "rejected" + : "uncertain", + detail: formatFailureDetail(cause), + }), + }), + ); + }); + const appendProviderFailureActivity = (input: { readonly threadId: ThreadId; readonly kind: @@ -731,6 +808,7 @@ const make = Effect.gen(function* () { : mapProviderSessionStatusToOrchestrationStatus(session.status), providerName: session.provider, providerInstanceId: session.providerInstanceId, + ...(session.providerSessionId ? { providerSessionId: session.providerSessionId } : {}), runtimeMode: desiredRuntimeMode, // Provider turn ids are not orchestration turn ids. activeTurnId: null, @@ -1198,6 +1276,30 @@ const make = Effect.gen(function* () { const resumed = receivedEvent.commandId !== null ? resumedTurnStarts.get(receivedEvent.commandId) : undefined; const event = resumed ? { ...receivedEvent, payload: resumed.event.payload } : receivedEvent; + if (event.payload.expectedSession) { + const thread = yield* resolveThreadDetail(event.payload.threadId); + const message = thread?.messages.find( + (entry) => entry.id === event.payload.messageId && entry.role === "user", + ); + const execute = message + ? providerService + .sendTurn({ + threadId: event.payload.threadId, + input: message.text, + ...(message.attachments ? { attachments: message.attachments } : {}), + expectedSession: event.payload.expectedSession, + interactionMode: event.payload.interactionMode, + }) + .pipe(Effect.map((result) => result.turnId)) + : Effect.fail( + new ProviderSessionFenceError({ + threadId: event.payload.threadId, + detail: "The selected user message is no longer available.", + }), + ); + yield* executeConditionalCommand(event, execute); + return; + } const key = turnStartKeyForEvent(event); if (yield* hasHandledTurnStartRecently(key)) { return; @@ -1502,6 +1604,21 @@ const make = Effect.gen(function* () { const processTurnInterruptRequested = Effect.fn("processTurnInterruptRequested")(function* ( event: Extract, ) { + if (event.payload.expectedSession) { + yield* executeConditionalCommand( + event, + providerService + .interruptTurn({ + threadId: event.payload.threadId, + expectedSession: event.payload.expectedSession, + ...(event.payload.expectedSession.activeTurnId + ? { turnId: event.payload.expectedSession.activeTurnId } + : {}), + }) + .pipe(Effect.as(null)), + ); + return; + } yield* cancelTurnsAfterCompaction( event.payload.threadId, "Context compaction was interrupted. Send this message again to continue.", @@ -1851,6 +1968,7 @@ const make = Effect.gen(function* () { const worker = yield* makeDrainableWorker(processDomainEventSafely); const start: ProviderCommandReactorShape["start"] = Effect.fn("start")(function* () { + const startupSequence = yield* orchestrationEngine.latestSequence; const pendingTitles = yield* findPendingThreadTitles().pipe( Effect.catchCause((cause) => { if (Cause.hasInterruptsOnly(cause)) { @@ -1884,29 +2002,70 @@ const make = Effect.gen(function* () { const domainEvents = yield* orchestrationEngine.subscribeDomainEvents; yield* forkParked(Stream.runForEach(domainEvents, processEvent)); + // ponytail: page the durable log on startup; add an indexed conditional-intent query if + // startup replay becomes material. Never resend an operation whose outcome is unknown. + const closeInterruptedConditionalCommands = Effect.gen(function* () { + let cursor = 0; + while (cursor < startupSequence) { + const events = Array.from( + yield* Stream.runCollect( + orchestrationEngine.readEvents(cursor, Math.min(1_000, startupSequence - cursor)), + ), + ); + if (events.length === 0) break; + for (const event of events) { + if (event.sequence > startupSequence) { + cursor = startupSequence; + break; + } + cursor = event.sequence; + if ( + (event.type === "thread.turn-start-requested" || + event.type === "thread.turn-interrupt-requested") && + event.payload.expectedSession + ) { + yield* executeConditionalCommand( + event, + Effect.fail( + new ProviderSessionFenceError({ + threadId: event.payload.threadId, + detail: + "The server restarted before this command had a confirmed execution result. Choose again against the current session.", + }), + ), + ); + } + } + } + }); + // Earlier events do not replay. Clear interrupted requests by their captured // IDs, then schedule persisted refinements after subscribing to their events. - const recoverTitles = clearInterruptedThreadTitleRegenerations( - pendingTitles.interruptedRegenerations, - ).pipe( - Effect.andThen( - Effect.forEach(pendingTitles.refinementThreadIds, maybeRefineThreadTitle, { - discard: true, + const recoverTitles = closeInterruptedConditionalCommands + .pipe( + Effect.andThen( + clearInterruptedThreadTitleRegenerations(pendingTitles.interruptedRegenerations), + ), + ) + .pipe( + Effect.andThen( + Effect.forEach(pendingTitles.refinementThreadIds, maybeRefineThreadTitle, { + discard: true, + }), + ), + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.interrupt; + } + return Effect.logWarning( + "provider command reactor failed to recover pending thread titles", + { + failureKind: Cause.hasDies(cause) ? "defect" : "failure", + reasonCount: cause.reasons.length, + }, + ); }), - ), - Effect.catchCause((cause) => { - if (Cause.hasInterruptsOnly(cause)) { - return Effect.interrupt; - } - return Effect.logWarning( - "provider command reactor failed to recover pending thread titles", - { - failureKind: Cause.hasDies(cause) ? "defect" : "failure", - reasonCount: cause.reasons.length, - }, - ); - }), - ); + ); const activation = yield* ServerActivation; if (activation === undefined) { yield* recoverTitles; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index ee4d08c36d5a..100a14b81a09 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1753,6 +1753,9 @@ const make = Effect.gen(function* () { threadId: thread.id, session: { threadId: thread.id, + ...(thread.session?.providerSessionId + ? { providerSessionId: thread.session.providerSessionId } + : {}), status, providerName: event.provider, ...(event.providerInstanceId !== undefined diff --git a/apps/server/src/orchestration/decider.sessionFence.test.ts b/apps/server/src/orchestration/decider.sessionFence.test.ts new file mode 100644 index 000000000000..a49a6e213bfe --- /dev/null +++ b/apps/server/src/orchestration/decider.sessionFence.test.ts @@ -0,0 +1,190 @@ +import { + CommandId, + EventId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, + TurnId, + type OrchestrationReadModel, + type OrchestrationSession, + type ProviderSessionFence, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { decideOrchestrationCommand } from "./decider.ts"; + +const now = "2026-09-08T00:00:00.000Z"; +const threadId = ThreadId.make("thread"); +const expectedSession: ProviderSessionFence = { + providerInstanceId: ProviderInstanceId.make("codex"), + providerSessionId: "generation-1", + activeTurnId: null, + readiness: "ready", +}; +const session: OrchestrationSession = { + threadId, + providerInstanceId: expectedSession.providerInstanceId, + providerSessionId: expectedSession.providerSessionId, + status: "ready", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, +}; +const model = (current: OrchestrationSession = session): OrchestrationReadModel => ({ + snapshotSequence: 0, + projects: [], + updatedAt: now, + threads: [ + { + id: threadId, + projectId: ProjectId.make("project"), + title: "Work", + modelSelection: { instanceId: expectedSession.providerInstanceId, model: "gpt" }, + runtimeMode: "full-access", + interactionMode: "default", + pullRequests: [], + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, + pinnedAt: null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: current, + }, + ], +}); +const start = { + type: "thread.turn.start" as const, + commandId: CommandId.make("conditional"), + threadId, + expectedSession, + message: { + messageId: MessageId.make("selected"), + role: "user" as const, + text: "Use the selected skill", + attachments: [], + }, + runtimeMode: "full-access" as const, + interactionMode: "default" as const, + createdAt: now, +}; + +it.layer(NodeServices.layer)("conditional command acceptance", (it) => { + for (const responseMode of [undefined, "message"] as const) { + it.effect(`handles pending questions with response mode ${responseMode ?? "callback"}`, () => + Effect.gen(function* () { + const current = model(); + const readModel = { + ...current, + threads: current.threads.map((thread) => ({ + ...thread, + activities: [ + { + id: EventId.make("pending-question"), + tone: "info" as const, + kind: "user-input.requested", + summary: "Question", + payload: { requestId: "question", ...(responseMode ? { responseMode } : {}) }, + turnId: null, + createdAt: now, + }, + ], + })), + }; + const result = yield* decideOrchestrationCommand({ command: start, readModel }).pipe( + Effect.result, + ); + expect(result._tag).toBe(responseMode === "message" ? "Success" : "Failure"); + }), + ); + } + it.effect("persists the exact expected session in the accepted intent", () => + Effect.gen(function* () { + const result = yield* decideOrchestrationCommand({ command: start, readModel: model() }); + const events = Array.isArray(result) ? result : [result]; + expect( + events.find((event) => event.type === "thread.turn-start-requested")?.payload, + ).toMatchObject({ expectedSession }); + }), + ); + it.effect( + "rejects replacement, busy, missing identity and different provider before emitting a message", + () => + Effect.gen(function* () { + for (const changed of [ + { ...session, providerSessionId: "generation-2" }, + { ...session, status: "running" as const, activeTurnId: TurnId.make("running") }, + { ...session, providerSessionId: undefined }, + { ...session, providerInstanceId: ProviderInstanceId.make("claude") }, + ]) { + const error = yield* decideOrchestrationCommand({ + command: start, + readModel: model(changed), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + } + }), + ); + it.effect("preserves ordinary busy-thread steering without a condition", () => + Effect.gen(function* () { + const { expectedSession: _condition, ...ordinary } = start; + const result = yield* decideOrchestrationCommand({ + command: ordinary, + readModel: model({ ...session, status: "running", activeTurnId: TurnId.make("busy") }), + }); + const events = Array.isArray(result) ? result : [result]; + expect(events.some((event) => event.type === "thread.turn-start-requested")).toBe(true); + }), + ); + it.effect("rejects an interrupt for a superseded turn and persists the matching condition", () => + Effect.gen(function* () { + const currentTurn = TurnId.make("active"); + const current = model({ ...session, status: "running", activeTurnId: currentTurn }); + const interrupt = { + type: "thread.turn.interrupt" as const, + commandId: CommandId.make("interrupt"), + threadId, + expectedSession: { + ...expectedSession, + readiness: "running" as const, + activeTurnId: currentTurn, + }, + turnId: currentTurn, + createdAt: now, + }; + const accepted = yield* decideOrchestrationCommand({ + command: interrupt, + readModel: current, + }); + const events = Array.isArray(accepted) ? accepted : [accepted]; + expect(events[0]?.payload).toMatchObject({ expectedSession: interrupt.expectedSession }); + const rejected = yield* decideOrchestrationCommand({ + command: { + ...interrupt, + expectedSession: { ...interrupt.expectedSession, activeTurnId: TurnId.make("old") }, + }, + readModel: current, + }).pipe(Effect.flip); + expect(rejected._tag).toBe("OrchestrationCommandInvariantError"); + const differentTarget = yield* decideOrchestrationCommand({ + command: { ...interrupt, turnId: TurnId.make("other") }, + readModel: current, + }).pipe(Effect.flip); + expect(differentTarget._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 86be0610f804..0a4471991b29 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -13,6 +13,7 @@ import { type ThreadPullRequestKey, type ThreadPullRequestLink, type OrchestrationThreadActivity, + type ProviderSessionFence, } from "@t3tools/contracts"; import { legacyLinkedPullRequestOf, @@ -53,6 +54,44 @@ const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const decodeUserInputRequestedPayload = Schema.decodeUnknownOption(UserInputRequestedPayload); const threadPullRequestLinksEqual = Schema.toEquivalence(Schema.NullOr(ThreadLinkedPullRequest)); +function requireSessionFence( + thread: OrchestrationThread, + command: OrchestrationCommand & { expectedSession?: ProviderSessionFence | undefined }, +) { + const expected = command.expectedSession; + if (!expected) return Effect.void; + const session = thread.session; + const matches = + session?.providerInstanceId === expected.providerInstanceId && + session.providerSessionId === expected.providerSessionId && + session.activeTurnId === expected.activeTurnId && + session.status === expected.readiness; + const idleSend = + command.type !== "thread.turn.start" || + (expected.readiness === "ready" && + expected.activeTurnId === null && + !Array.from(openRequests(thread).values()).some( + (activity) => + activity.kind !== "user-input.requested" || + !Predicate.isObject(activity.payload) || + activity.payload.responseMode !== "message", + ) && + !hasQueuedTurnStartForThread(thread, command.createdAt)); + const targetedInterrupt = + command.type !== "thread.turn.interrupt" || + (expected.readiness === "running" && + expected.activeTurnId !== null && + (command.turnId === undefined || command.turnId === expected.activeTurnId)); + return matches && idleSend && targetedInterrupt + ? Effect.void + : Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "The selected provider session or turn is no longer ready for this command.", + }), + ); +} + /** * Blocked-on-you work derived from the thread's retained activities: an * approval or user-input request with no later resolution for the same @@ -1367,6 +1406,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" threadId: command.threadId, }); const sourceProposedPlan = command.sourceProposedPlan; + yield* requireSessionFence(targetThread, command); const sourceThread = sourceProposedPlan ? yield* requireThread({ readModel, @@ -1434,6 +1474,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" type: "thread.turn-start-requested", payload: { threadId: command.threadId, + ...(command.expectedSession ? { expectedSession: command.expectedSession } : {}), messageId: command.message.messageId, ...(command.modelSelection !== undefined ? { modelSelection: command.modelSelection } @@ -1533,11 +1574,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.turn.interrupt": { - yield* requireThread({ + const targetThread = yield* requireThread({ readModel, command, threadId: command.threadId, }); + yield* requireSessionFence(targetThread, command); return { ...(yield* withEventBase({ aggregateKind: "thread", @@ -1548,6 +1590,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" type: "thread.turn-interrupt-requested", payload: { threadId: command.threadId, + ...(command.expectedSession ? { expectedSession: command.expectedSession } : {}), ...(command.turnId !== undefined ? { turnId: command.turnId } : {}), createdAt: command.createdAt, }, diff --git a/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts b/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts index dcb750983a00..62b453c2fa55 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts @@ -22,6 +22,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { sql` INSERT INTO projection_thread_sessions ( thread_id, + provider_session_id, status, provider_name, provider_instance_id, @@ -32,6 +33,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { ) VALUES ( ${row.threadId}, + ${row.providerSessionId ?? null}, ${row.status}, ${row.providerName}, ${row.providerInstanceId}, @@ -43,6 +45,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { ON CONFLICT (thread_id) DO UPDATE SET status = excluded.status, + provider_session_id = excluded.provider_session_id, provider_name = excluded.provider_name, provider_instance_id = excluded.provider_instance_id, runtime_mode = excluded.runtime_mode, @@ -59,6 +62,7 @@ const makeProjectionThreadSessionRepository = Effect.gen(function* () { sql` SELECT thread_id AS "threadId", + provider_session_id AS "providerSessionId", status, provider_name AS "providerName", provider_instance_id AS "providerInstanceId", diff --git a/apps/server/src/persistence/Services/ProjectionThreadSessions.ts b/apps/server/src/persistence/Services/ProjectionThreadSessions.ts index 7cecac33eb6a..d3a0446a259c 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadSessions.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadSessions.ts @@ -23,6 +23,7 @@ import type { ProjectionRepositoryError } from "../Errors.ts"; export const ProjectionThreadSession = Schema.Struct({ threadId: ThreadId, + providerSessionId: Schema.optional(Schema.NullOr(Schema.String)), status: OrchestrationSessionStatus, providerName: Schema.NullOr(Schema.String), providerInstanceId: Schema.NullOr(ProviderInstanceId), diff --git a/apps/server/src/project/AgentSessionImporter.test.ts b/apps/server/src/project/AgentSessionImporter.test.ts index 4eb03a5cc036..ede9cc0b427d 100644 --- a/apps/server/src/project/AgentSessionImporter.test.ts +++ b/apps/server/src/project/AgentSessionImporter.test.ts @@ -573,7 +573,7 @@ const integrationLayer = Layer.mergeAll( Layer.provide(ThreadBackgroundLiveness.layer), Layer.provide(ThreadPlanProgress.layer), Layer.provide(OrchestrationEventStoreLive), - Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provideMerge(OrchestrationCommandReceiptRepositoryLive), Layer.provide(RepositoryIdentityResolver.layer), Layer.provide(SqlitePersistenceMemory), Layer.provideMerge(integrationServerConfig), diff --git a/apps/server/src/provider/Errors.ts b/apps/server/src/provider/Errors.ts index cdeeb3b922d4..2aef09a0835a 100644 --- a/apps/server/src/provider/Errors.ts +++ b/apps/server/src/provider/Errors.ts @@ -102,8 +102,18 @@ export class ProviderWorkspaceMissingError extends Schema.TaggedError()( + "ProviderSessionFenceError", + { threadId: Schema.String, detail: Schema.String }, +) { + override get message(): string { + return this.detail; + } +} + +/** Invalid provider API input. */ export class ProviderValidationError extends Schema.TaggedError()( "ProviderValidationError", { @@ -211,6 +221,7 @@ export type ProviderAdapterError = | ProviderAdapterProcessError; export type ProviderServiceError = + | ProviderSessionFenceError | ProviderValidationError | ProviderUnsupportedError | ProviderWorkspaceMissingError diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 38bdb7f1f2e7..72b379ee0227 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -71,6 +71,7 @@ class FakeClaudeQuery implements AsyncIterable { public readonly setPermissionModeCalls: Array = []; public readonly setMaxThinkingTokensCalls: Array = []; public closeCalls = 0; + public interruptCalls = 0; public closeError: unknown | undefined; emit(message: SDKMessage): void { @@ -127,6 +128,10 @@ class FakeClaudeQuery implements AsyncIterable { this.finish(); }; + readonly interrupt = async (): Promise => { + this.interruptCalls += 1; + }; + [Symbol.asyncIterator](): AsyncIterator { return { next: () => { @@ -2231,6 +2236,25 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("conditionally interrupts without closing or declaring the session idle", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ threadId: session.threadId, input: "Work" }); + yield* adapter.interruptTurn(session.threadId, turn.turnId, { preserveSession: true }); + assert.equal(harness.query.interruptCalls, 1); + assert.equal(harness.query.closeCalls, 0); + const current = (yield* adapter.listSessions())[0]; + assert.equal(current?.status, "running"); + assert.equal(current?.activeTurnId, turn.turnId); + }).pipe(Effect.provide(harness.layer)); + }); + const AUTH_FAILURE_ASSISTANT = { type: "assistant", session_id: "sdk-session-auth", diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 3afde2b39a7a..bd71531923d8 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -363,6 +363,7 @@ interface ClaudeSessionContext { } interface ClaudeQueryRuntime extends AsyncIterable { + readonly interrupt: () => Promise; readonly setModel: (model?: string) => Promise; readonly setPermissionMode: (mode: PermissionMode) => Promise; readonly setMaxThinkingTokens: (maxThinkingTokens: number | null) => Promise; @@ -5084,8 +5085,15 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); const interruptTurn: ClaudeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( - function* (threadId, _turnId) { + function* (threadId, _turnId, options) { const context = yield* requireSession(threadId); + if (options?.preserveSession) { + yield* Effect.tryPromise({ + try: () => context.query.interrupt(), + catch: (cause) => toRequestError(threadId, "turn/interrupt", cause), + }); + return; + } // interrupt() can acknowledge while resumed background tasks keep the // CLI alive. Stop is a hard session boundary for Claude, so close the // query and let the SDK escalate to SIGKILL when graceful exit fails. diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 1d86bc981340..aa710f429af4 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -100,6 +100,7 @@ const asEventId = (value: string): EventId => EventId.make(value); const asThreadId = (value: string): ThreadId => ThreadId.make(value); const asTurnId = (value: string): TurnId => TurnId.make(value); const codexInstanceId = ProviderInstanceId.make("codex"); +const cursorInstanceId = ProviderInstanceId.make("cursor"); const claudeAgentInstanceId = ProviderInstanceId.make("claudeAgent"); const CODEX_DRIVER = ProviderDriverKind.make("codex"); const CLAUDE_AGENT_DRIVER = ProviderDriverKind.make("claudeAgent"); @@ -651,8 +652,8 @@ it.effect("ProviderServiceLive catches stopAll failures during shutdown", () => it.effect("ProviderServiceLive flushes deferred completions during shutdown", () => Effect.gen(function* () { const recordedAnalytics = makeRecordingAnalytics(); - const codex = makeFakeCodexAdapter(); - const registry = makeStaticInstanceRegistry([[codexInstanceId, codex.adapter]]); + const cursor = makeFakeCodexAdapter(CURSOR_DRIVER); + const registry = makeStaticInstanceRegistry([[cursorInstanceId, cursor.adapter]]); const providerAdapterLayer = Layer.succeed( ProviderAdapterRegistry.ProviderAdapterRegistry, registry, @@ -689,12 +690,12 @@ it.effect("ProviderServiceLive flushes deferred completions during shutdown", () const sendRelease = yield* Deferred.make(); const turnId = asTurnId("turn-analytics-stop-all-deferred"); yield* provider.startSession(threadId, { - provider: CODEX_DRIVER, - providerInstanceId: codexInstanceId, + provider: CURSOR_DRIVER, + providerInstanceId: cursorInstanceId, threadId, runtimeMode: "full-access", }); - codex.sendTurn + cursor.sendTurn .mockImplementationOnce(() => Effect.gen(function* () { yield* Deferred.succeed(firstStarted, undefined); @@ -724,19 +725,19 @@ it.effect("ProviderServiceLive flushes deferred completions during shutdown", () Effect.forkChild, ); yield* Effect.yieldNow; - codex.emit({ + cursor.emit({ type: "turn.started", eventId: asEventId("evt-turn-analytics-stop-all-deferred-start"), - provider: CODEX_DRIVER, + provider: CURSOR_DRIVER, createdAt: "2026-01-01T00:00:00.000Z", threadId, turnId, payload: { model: "native-stop-all" }, }); - codex.emit({ + cursor.emit({ type: "turn.completed", eventId: asEventId("evt-turn-analytics-stop-all-deferred-complete"), - provider: CODEX_DRIVER, + provider: CURSOR_DRIVER, createdAt: "2026-01-01T00:00:00.000Z", threadId, turnId, @@ -1220,6 +1221,427 @@ unsupportedRollback.layer("ProviderServiceLive unsupported rewind", (it) => { ); }); +const fenced = makeProviderServiceLayer(); +fenced.layer("ProviderService session fences", (it) => { + for (const operation of ["compaction", "feedback"] as const) { + it.effect(`${operation} recovery waits for an admitted conditional send`, () => + Effect.gen(function* () { + const service = yield* ProviderService.ProviderService; + const threadId = asThreadId(`fence-recovery-${operation}`); + const session = yield* service.startSession(threadId, { + threadId, + providerInstanceId: codexInstanceId, + runtimeMode: "full-access", + }); + const expectedSession = { + providerInstanceId: codexInstanceId, + providerSessionId: session.providerSessionId!, + activeTurnId: null, + readiness: "ready" as const, + }; + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + fenced.codex.sendTurn.mockImplementationOnce(() => + Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as({ threadId, turnId: asTurnId("admitted-turn") }), + ), + ); + const send = yield* service + .sendTurn({ threadId, input: "Selected session", expectedSession }) + .pipe(Effect.forkChild); + yield* Deferred.await(entered); + // Simulate a runtime disappearing while its acknowledgment is in flight. + yield* fenced.codex.stopSession(threadId); + const starts = fenced.codex.startSession.mock.calls.length; + const recover = yield* ( + operation === "compaction" + ? service.compactThread(threadId) + : service.uploadFeedback({ threadId }).pipe(Effect.asVoid) + ).pipe(Effect.forkChild); + yield* Effect.yieldNow; + assert.equal(fenced.codex.startSession.mock.calls.length, starts); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(send); + yield* Fiber.join(recover); + assert.equal(fenced.codex.startSession.mock.calls.length, starts + 1); + const stale = yield* service + .sendTurn({ threadId, input: "Old session", expectedSession }) + .pipe(Effect.flip); + assert.equal(stale._tag, "ProviderSessionFenceError"); + yield* service.stopSession({ threadId }); + }), + ); + } + + for (const [name, instanceId, fake] of [ + ["Codex", codexInstanceId, fenced.codex], + ["Claude", claudeAgentInstanceId, fenced.claude], + ] as const) { + it.effect(`${name} rejects fenced sends during compaction and allows Stop`, () => + Effect.gen(function* () { + const service = yield* ProviderService.ProviderService; + const threadId = asThreadId(`fence-compaction-${name}`); + const session = yield* service.startSession(threadId, { + threadId, + providerInstanceId: instanceId, + runtimeMode: "full-access", + }); + const entered = yield* Deferred.make(); + if (name === "Codex") { + fake.compactThread.mockImplementationOnce(() => + Deferred.succeed(entered, undefined).pipe(Effect.asVoid), + ); + } else { + fake.sendTurn.mockImplementationOnce(() => + Deferred.succeed(entered, undefined).pipe( + Effect.as({ threadId, turnId: asTurnId("compaction-turn") }), + ), + ); + } + const compaction = yield* service + .compactThread(threadId) + .pipe(Effect.result, Effect.forkChild); + yield* Deferred.await(entered); + const sends = fake.sendTurn.mock.calls.length; + const result = yield* service + .sendTurn({ + threadId, + input: "Only send to an idle session", + expectedSession: { + providerInstanceId: instanceId, + providerSessionId: session.providerSessionId!, + activeTurnId: null, + readiness: "ready", + }, + }) + .pipe(Effect.flip); + assert.equal(result._tag, "ProviderSessionFenceError"); + assert.equal(fake.sendTurn.mock.calls.length, sends); + yield* service.stopSession({ threadId }); + assert.equal((yield* Fiber.join(compaction))._tag, "Failure"); + }), + ); + } + + for (const [name, instanceId, fake] of [ + ["Codex", codexInstanceId, fenced.codex], + ["Claude", claudeAgentInstanceId, fenced.claude], + ] as const) { + it.effect(`${name} rejects a replaced session without recovery or a second send`, () => + Effect.gen(function* () { + const service = yield* ProviderService.ProviderService; + const threadId = asThreadId(`fence-${name}`); + const input = { + threadId, + providerInstanceId: instanceId, + runtimeMode: "full-access" as const, + }; + const session = yield* service.startSession(threadId, input); + assert.isDefined(session.providerSessionId); + const expectedSession = { + providerInstanceId: instanceId, + providerSessionId: session.providerSessionId!, + activeTurnId: null, + readiness: "ready" as const, + }; + const before = fake.sendTurn.mock.calls.length; + yield* service.sendTurn({ threadId, input: "Review the patch", expectedSession }); + assert.strictEqual(fake.sendTurn.mock.calls.length, before + 1); + const replacement = yield* service.startSession(threadId, input); + assert.notStrictEqual(replacement.providerSessionId, session.providerSessionId); + const failed = yield* service + .sendTurn({ threadId, input: "Stale review", expectedSession }) + .pipe(Effect.flip); + assert.strictEqual(failed._tag, "ProviderSessionFenceError"); + assert.strictEqual(fake.sendTurn.mock.calls.length, before + 1); + yield* service.stopSession({ threadId }); + const starts = fake.startSession.mock.calls.length; + yield* service + .sendTurn({ threadId, input: "Do not recover", expectedSession }) + .pipe(Effect.flip); + assert.strictEqual(fake.startSession.mock.calls.length, starts); + }), + ); + } + + it.effect( + "rejects conditional Cursor sends and interrupts without calling its blocking adapter", + () => + Effect.gen(function* () { + const service = yield* ProviderService.ProviderService; + const threadId = asThreadId("fence-cursor-unsupported"); + const providerInstanceId = ProviderInstanceId.make("cursor"); + const session = yield* service.startSession(threadId, { + threadId, + providerInstanceId, + runtimeMode: "full-access", + }); + const expectedSession = { + providerInstanceId, + providerSessionId: session.providerSessionId!, + activeTurnId: null, + readiness: "ready" as const, + }; + const sends = fenced.cursor.sendTurn.mock.calls.length; + const interrupts = fenced.cursor.interruptTurn.mock.calls.length; + assert.equal( + (yield* service + .sendTurn({ threadId, input: "Review", expectedSession }) + .pipe(Effect.flip))._tag, + "ProviderSessionFenceError", + ); + assert.equal( + (yield* service.interruptTurn({ threadId, expectedSession }).pipe(Effect.flip))._tag, + "ProviderSessionFenceError", + ); + assert.equal(fenced.cursor.sendTurn.mock.calls.length, sends); + assert.equal(fenced.cursor.interruptTurn.mock.calls.length, interrupts); + }), + ); + + it.effect( + "serializes ordinary starts with a conditional send already at the provider boundary", + () => + Effect.gen(function* () { + const service = yield* ProviderService.ProviderService; + const threadId = asThreadId("fence-race"); + const input = { + threadId, + providerInstanceId: codexInstanceId, + runtimeMode: "full-access" as const, + }; + const session = yield* service.startSession(threadId, input); + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + fenced.codex.sendTurn.mockImplementationOnce(() => + Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as({ threadId, turnId: asTurnId("race-turn") }), + ), + ); + const send = yield* service + .sendTurn({ + threadId, + input: "Selected skill", + expectedSession: { + providerInstanceId: codexInstanceId, + providerSessionId: session.providerSessionId!, + activeTurnId: null, + readiness: "ready", + }, + }) + .pipe(Effect.forkChild); + yield* Deferred.await(entered); + const starts = fenced.codex.startSession.mock.calls.length; + const restart = yield* service.startSession(threadId, input).pipe(Effect.forkChild); + yield* Effect.yieldNow; + assert.strictEqual(fenced.codex.startSession.mock.calls.length, starts); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(send); + const restarted = yield* Fiber.join(restart); + assert.notStrictEqual(restarted.providerSessionId, session.providerSessionId); + }), + ); + + it.effect("rejects an ordinary send whose locked route changed to a blocking provider", () => + Effect.gen(function* () { + const service = yield* ProviderService.ProviderService; + const threadId = asThreadId("fence-driver-swap"); + yield* service.startSession(threadId, { + threadId, + providerInstanceId: codexInstanceId, + runtimeMode: "full-access", + }); + const replacementEntered = yield* Deferred.make(); + const releaseReplacement = yield* Deferred.make(); + const initialRouteRead = yield* Deferred.make(); + const startCursor = fenced.cursor.startSession.getMockImplementation()!; + fenced.cursor.startSession.mockImplementationOnce((input) => + Deferred.succeed(replacementEntered, undefined).pipe( + Effect.andThen(Deferred.await(releaseReplacement)), + Effect.andThen(startCursor(input)), + ), + ); + const replacement = yield* service + .startSession(threadId, { + threadId, + providerInstanceId: ProviderInstanceId.make("cursor"), + runtimeMode: "full-access", + }) + .pipe(Effect.forkChild); + yield* Deferred.await(replacementEntered); + fenced.codex.hasSession.mockImplementationOnce(() => + Deferred.succeed(initialRouteRead, undefined).pipe(Effect.as(true)), + ); + const sends = fenced.cursor.sendTurn.mock.calls.length; + const send = yield* service + .sendTurn({ threadId, input: "Raced ordinary request" }) + .pipe(Effect.flip, Effect.forkChild); + yield* Deferred.await(initialRouteRead); + yield* Deferred.succeed(releaseReplacement, undefined); + yield* Fiber.join(replacement); + assert.equal((yield* Fiber.join(send))._tag, "ProviderSessionFenceError"); + assert.equal(fenced.cursor.sendTurn.mock.calls.length, sends); + yield* service.sendTurn({ threadId, input: "Fresh request to Cursor" }); + assert.equal(fenced.cursor.sendTurn.mock.calls.length, sends + 1); + }), + ); + + it.effect("a replacement waits for Stop already attached to an in-flight admission", () => + Effect.gen(function* () { + const service = yield* ProviderService.ProviderService; + const threadId = asThreadId("fence-stop-replacement-race"); + const input = { + threadId, + providerInstanceId: codexInstanceId, + runtimeMode: "full-access" as const, + }; + const session = yield* service.startSession(threadId, input); + const sendEntered = yield* Deferred.make(); + const stopEntered = yield* Deferred.make(); + const releaseStop = yield* Deferred.make(); + fenced.codex.sendTurn.mockImplementationOnce(() => + Deferred.succeed(sendEntered, undefined).pipe( + Effect.andThen(Deferred.await(stopEntered)), + Effect.as({ threadId, turnId: asTurnId("before-stop") }), + ), + ); + fenced.codex.interruptTurn.mockImplementationOnce(() => + Deferred.succeed(stopEntered, undefined).pipe(Effect.andThen(Deferred.await(releaseStop))), + ); + const send = yield* service + .sendTurn({ + threadId, + input: "Selected skill", + expectedSession: { + providerInstanceId: codexInstanceId, + providerSessionId: session.providerSessionId!, + readiness: "ready", + activeTurnId: null, + }, + }) + .pipe(Effect.forkChild); + yield* Deferred.await(sendEntered); + const stop = yield* service.interruptTurn({ threadId }).pipe(Effect.forkChild); + yield* Deferred.await(stopEntered); + const starts = fenced.codex.startSession.mock.calls.length; + const replacement = yield* service.startSession(threadId, input).pipe(Effect.forkChild); + yield* Effect.yieldNow; + assert.equal(fenced.codex.startSession.mock.calls.length, starts); + yield* Deferred.succeed(releaseStop, undefined); + yield* Fiber.join(stop); + yield* Fiber.join(send); + assert.notEqual( + (yield* Fiber.join(replacement)).providerSessionId, + session.providerSessionId, + ); + }), + ); + + it.effect("an ordinary send reserves readiness before a competing conditional send", () => + Effect.gen(function* () { + const service = yield* ProviderService.ProviderService; + const threadId = asThreadId("fence-ordinary-race"); + const session = yield* service.startSession(threadId, { + threadId, + providerInstanceId: codexInstanceId, + runtimeMode: "full-access", + }); + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + const turnId = asTurnId("ordinary-winner"); + const before = fenced.codex.sendTurn.mock.calls.length; + fenced.codex.sendTurn.mockImplementationOnce(() => + Effect.gen(function* () { + yield* Deferred.succeed(entered, undefined); + yield* Deferred.await(release); + fenced.codex.updateSession(threadId, (current) => ({ + ...current, + status: "running", + activeTurnId: turnId, + })); + return { threadId, turnId }; + }), + ); + const ordinary = yield* service + .sendTurn({ threadId, input: "Ordinary request" }) + .pipe(Effect.forkChild); + yield* Deferred.await(entered); + const conditional = yield* service + .sendTurn({ + threadId, + input: "Selected skill", + expectedSession: { + providerInstanceId: codexInstanceId, + providerSessionId: session.providerSessionId!, + readiness: "ready", + activeTurnId: null, + }, + }) + .pipe(Effect.flip, Effect.forkChild); + yield* Effect.yieldNow; + assert.equal(fenced.codex.sendTurn.mock.calls.length, before + 1); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(ordinary); + assert.equal((yield* Fiber.join(conditional))._tag, "ProviderSessionFenceError"); + assert.equal(fenced.codex.sendTurn.mock.calls.length, before + 1); + }), + ); + + it.effect("rejects interrupts and fallback stops for a different turn", () => + Effect.gen(function* () { + const service = yield* ProviderService.ProviderService; + const threadId = asThreadId("fence-interrupt"); + const session = yield* service.startSession(threadId, { + threadId, + providerInstanceId: codexInstanceId, + runtimeMode: "full-access", + }); + fenced.codex.updateSession(threadId, (current) => ({ + ...current, + status: "running", + activeTurnId: asTurnId("new-turn"), + })); + const expectedSession = { + providerInstanceId: codexInstanceId, + providerSessionId: session.providerSessionId!, + activeTurnId: asTurnId("old-turn"), + readiness: "running" as const, + }; + const interrupts = fenced.codex.interruptTurn.mock.calls.length; + const stops = fenced.codex.stopSession.mock.calls.length; + assert.strictEqual( + (yield* service.interruptTurn({ threadId, expectedSession }).pipe(Effect.flip))._tag, + "ProviderSessionFenceError", + ); + assert.strictEqual( + (yield* service.stopSession({ threadId, expectedSession }).pipe(Effect.flip))._tag, + "ProviderSessionFenceError", + ); + assert.strictEqual(fenced.codex.interruptTurn.mock.calls.length, interrupts); + assert.strictEqual(fenced.codex.stopSession.mock.calls.length, stops); + assert.strictEqual( + (yield* service + .interruptTurn({ + threadId, + expectedSession: { ...expectedSession, activeTurnId: asTurnId("new-turn") }, + turnId: asTurnId("other-turn"), + }) + .pipe(Effect.flip))._tag, + "ProviderSessionFenceError", + ); + assert.strictEqual(fenced.codex.interruptTurn.mock.calls.length, interrupts); + yield* service.interruptTurn({ + threadId, + expectedSession: { ...expectedSession, activeTurnId: asTurnId("new-turn") }, + turnId: asTurnId("new-turn"), + }); + assert.strictEqual(fenced.codex.interruptTurn.mock.calls.length, interrupts + 1); + }), + ); +}); + it.effect( "ProviderServiceLive uploads feedback through the adapter that recovered the session", () => @@ -1958,7 +2380,7 @@ routing.layer("ProviderServiceLive routing", (it) => { Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; const threadId = asThreadId("thread-compact-timeout"); - yield* provider.startSession(threadId, { + const session = yield* provider.startSession(threadId, { provider: CODEX_DRIVER, providerInstanceId: codexInstanceId, threadId, @@ -1996,6 +2418,21 @@ routing.layer("ProviderServiceLive routing", (it) => { const blockedRetry = yield* provider.compactThread(threadId).pipe(Effect.result); assert.equal(blockedRetry._tag, "Failure"); assert.equal(routing.codex.compactThread.mock.calls.length, 1); + const sends = routing.codex.sendTurn.mock.calls.length; + const conditional = yield* provider + .sendTurn({ + threadId, + input: "Do not send while compaction may still be running", + expectedSession: { + providerInstanceId: codexInstanceId, + providerSessionId: session.providerSessionId!, + activeTurnId: null, + readiness: "ready", + }, + }) + .pipe(Effect.flip); + assert.equal(conditional._tag, "ProviderSessionFenceError"); + assert.equal(routing.codex.sendTurn.mock.calls.length, sends); routing.codex.emit({ type: "thread.state.changed", @@ -2973,66 +3410,80 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); - it.effect("does not persist running after a concurrent send is interrupted", () => - Effect.gen(function* () { - const provider = yield* ProviderService.ProviderService; - const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; - const sendStarted = yield* Deferred.make(); - const interrupted = yield* Deferred.make(); - routing.codex.sendTurn.mockImplementationOnce(() => + for (const conditional of [false, true]) { + it.effect( + `does not persist running after a concurrent ${conditional ? "conditional" : "ordinary"} send is interrupted`, + () => Effect.gen(function* () { - yield* Deferred.succeed(sendStarted, undefined); - yield* Deferred.await(interrupted); - return yield* Effect.interrupt; - }), - ); - routing.codex.interruptTurn.mockImplementationOnce(() => - Deferred.succeed(interrupted, undefined).pipe(Effect.asVoid), - ); - - const threadId = asThreadId("thread-interrupted-send-directory"); - const session = yield* provider.startSession(threadId, { - provider: ProviderDriverKind.make("codex"), - providerInstanceId: codexInstanceId, - threadId, - runtimeMode: "full-access", - }); - const sendExitFiber = yield* provider - .sendTurn({ - threadId: session.threadId, - input: "hold this prompt", - attachments: [], - }) - .pipe(Effect.exit, Effect.forkChild); - yield* Deferred.await(sendStarted); - yield* provider.interruptTurn({ threadId: session.threadId }); - const sendExit = yield* Fiber.join(sendExitFiber); + const provider = yield* ProviderService.ProviderService; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const sendStarted = yield* Deferred.make(); + const interrupted = yield* Deferred.make(); + routing.codex.sendTurn.mockImplementationOnce(() => + Effect.gen(function* () { + yield* Deferred.succeed(sendStarted, undefined); + yield* Deferred.await(interrupted); + return yield* Effect.interrupt; + }), + ); + routing.codex.interruptTurn.mockImplementationOnce(() => + Deferred.succeed(interrupted, undefined).pipe(Effect.asVoid), + ); - assert.equal(Exit.isFailure(sendExit), true); - if (Exit.isFailure(sendExit)) { - assert.equal(Cause.hasInterruptsOnly(sendExit.cause), true); - } - const persisted = yield* runtimeRepository.getByThreadId({ - threadId: session.threadId, - }); - assert.equal(Option.isSome(persisted), true); - if (Option.isSome(persisted)) { - // The directory folds both adapter "ready" and "running" into its - // runtime "running" state. The payload proves sendTurn did not upsert. - assert.equal(persisted.value.status, "running"); - const payload = persisted.value.runtimePayload; - assert.equal(payload !== null && typeof payload === "object", true); - if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) { - const runtimePayload = payload as { - activeTurnId?: string | null; - lastRuntimeEvent?: string | null; - }; - assert.equal(runtimePayload.activeTurnId ?? null, null); - assert.notEqual(runtimePayload.lastRuntimeEvent, "provider.sendTurn"); - } - } - }), - ); + const threadId = asThreadId("thread-interrupted-send-directory"); + const session = yield* provider.startSession(threadId, { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + const sendExitFiber = yield* provider + .sendTurn({ + threadId: session.threadId, + input: "hold this prompt", + attachments: [], + ...(conditional + ? { + expectedSession: { + providerInstanceId: codexInstanceId, + providerSessionId: session.providerSessionId!, + readiness: "ready" as const, + activeTurnId: null, + }, + } + : {}), + }) + .pipe(Effect.exit, Effect.forkChild); + yield* Deferred.await(sendStarted); + yield* provider.interruptTurn({ threadId: session.threadId }); + const sendExit = yield* Fiber.join(sendExitFiber); + + assert.equal(Exit.isFailure(sendExit), true); + if (Exit.isFailure(sendExit)) { + assert.equal(Cause.hasInterruptsOnly(sendExit.cause), true); + } + const persisted = yield* runtimeRepository.getByThreadId({ + threadId: session.threadId, + }); + assert.equal(Option.isSome(persisted), true); + if (Option.isSome(persisted)) { + // The directory folds both adapter "ready" and "running" into its + // runtime "running" state. The payload proves sendTurn did not upsert. + assert.equal(persisted.value.status, "running"); + const payload = persisted.value.runtimePayload; + assert.equal(payload !== null && typeof payload === "object", true); + if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) { + const runtimePayload = payload as { + activeTurnId?: string | null; + lastRuntimeEvent?: string | null; + }; + assert.equal(runtimePayload.activeTurnId ?? null, null); + assert.notEqual(runtimePayload.lastRuntimeEvent, "provider.sendTurn"); + } + } + }), + ); + } it.effect("reuses persisted resume cursor when startSession is called after a restart", () => Effect.gen(function* () { @@ -3653,11 +4104,14 @@ const recordedTurnAnalytics = makeRecordingAnalytics(); const secondaryCodexInstanceId = ProviderInstanceId.make("codex_work"); const primaryAnalyticsCodex = makeFakeCodexAdapter(); const secondaryAnalyticsCodex = makeFakeCodexAdapter(); +// Non-fenced adapters still allow overlapping acknowledgments; retain analytics coverage there. +const parallelAnalyticsCursor = makeFakeCodexAdapter(CURSOR_DRIVER); const turnAnalytics = makeProviderServiceLayer({ analyticsLayer: recordedTurnAnalytics.layer, registry: makeStaticInstanceRegistry([ [codexInstanceId, primaryAnalyticsCodex.adapter], [secondaryCodexInstanceId, secondaryAnalyticsCodex.adapter], + [cursorInstanceId, parallelAnalyticsCursor.adapter], ]), }); @@ -3831,8 +4285,8 @@ turnAnalytics.layer("ProviderServiceLive turn analytics", (it) => { const provider = yield* ProviderService.ProviderService; const threadId = asThreadId("thread-turn-analytics-overlap"); yield* provider.startSession(threadId, { - provider: CODEX_DRIVER, - providerInstanceId: codexInstanceId, + provider: CURSOR_DRIVER, + providerInstanceId: cursorInstanceId, threadId, runtimeMode: "full-access", }); @@ -3845,7 +4299,7 @@ turnAnalytics.layer("ProviderServiceLive turn analytics", (it) => { let initialStartCount = 0; const firstTurnId = asTurnId("turn-analytics-overlap-first"); const secondTurnId = asTurnId("turn-analytics-overlap-second"); - primaryAnalyticsCodex.sendTurn + parallelAnalyticsCursor.sendTurn .mockImplementationOnce((input) => Effect.gen(function* () { yield* Deferred.succeed(firstStarted, undefined); @@ -3878,7 +4332,7 @@ turnAnalytics.layer("ProviderServiceLive turn analytics", (it) => { input: "first", attachments: [], interactionMode: "default", - modelSelection: createModelSelection(codexInstanceId, "requested-first"), + modelSelection: createModelSelection(cursorInstanceId, "requested-first"), }) .pipe(Effect.forkChild); yield* Deferred.await(firstStarted); @@ -3888,7 +4342,7 @@ turnAnalytics.layer("ProviderServiceLive turn analytics", (it) => { input: "second", attachments: [], interactionMode: "plan", - modelSelection: createModelSelection(codexInstanceId, "requested-second"), + modelSelection: createModelSelection(cursorInstanceId, "requested-second"), }) .pipe(Effect.forkChild); yield* Deferred.await(secondStarted); @@ -3897,10 +4351,10 @@ turnAnalytics.layer("ProviderServiceLive turn analytics", (it) => { [firstTurnId, "first"], [secondTurnId, "second"], ] as const) { - primaryAnalyticsCodex.emit({ + parallelAnalyticsCursor.emit({ type: "turn.started", eventId: asEventId(`evt-turn-analytics-overlap-start-${suffix}`), - provider: CODEX_DRIVER, + provider: CURSOR_DRIVER, createdAt: "2026-01-01T00:00:00.000Z", threadId, turnId, @@ -3910,10 +4364,10 @@ turnAnalytics.layer("ProviderServiceLive turn analytics", (it) => { yield* Deferred.await(initialStartsObserved); yield* Deferred.succeed(secondRelease, undefined); yield* Fiber.join(secondSend); - primaryAnalyticsCodex.emit({ + parallelAnalyticsCursor.emit({ type: "turn.started", eventId: asEventId("evt-turn-analytics-overlap-start-second-duplicate"), - provider: CODEX_DRIVER, + provider: CURSOR_DRIVER, createdAt: "2026-01-01T00:00:00.000Z", threadId, turnId: secondTurnId, @@ -3925,10 +4379,10 @@ turnAnalytics.layer("ProviderServiceLive turn analytics", (it) => { [secondTurnId, "second"], [firstTurnId, "first"], ] as const) { - primaryAnalyticsCodex.emit({ + parallelAnalyticsCursor.emit({ type: "turn.completed", eventId: asEventId(`evt-turn-analytics-overlap-complete-${suffix}`), - provider: CODEX_DRIVER, + provider: CURSOR_DRIVER, createdAt: "2026-01-01T00:00:00.000Z", threadId, turnId, @@ -4120,12 +4574,12 @@ turnAnalytics.layer("ProviderServiceLive turn analytics", (it) => { const firstTurnId = asTurnId("turn-analytics-overlap-fast-first"); const secondTurnId = asTurnId("turn-analytics-overlap-fast-second"); yield* provider.startSession(threadId, { - provider: CODEX_DRIVER, - providerInstanceId: codexInstanceId, + provider: CURSOR_DRIVER, + providerInstanceId: cursorInstanceId, threadId, runtimeMode: "full-access", }); - primaryAnalyticsCodex.sendTurn + parallelAnalyticsCursor.sendTurn .mockImplementationOnce(() => Effect.gen(function* () { yield* Deferred.succeed(firstStarted, undefined); @@ -4151,7 +4605,7 @@ turnAnalytics.layer("ProviderServiceLive turn analytics", (it) => { input: "first fast completion", attachments: [], interactionMode: "default", - modelSelection: createModelSelection(codexInstanceId, "requested-first"), + modelSelection: createModelSelection(cursorInstanceId, "requested-first"), }) .pipe(Effect.forkChild); yield* Deferred.await(firstStarted); @@ -4162,7 +4616,7 @@ turnAnalytics.layer("ProviderServiceLive turn analytics", (it) => { input: "second fast completion", attachments: [], interactionMode: "plan", - modelSelection: createModelSelection(codexInstanceId, "requested-second"), + modelSelection: createModelSelection(cursorInstanceId, "requested-second"), }) .pipe(Effect.forkChild); yield* Deferred.await(secondStarted); @@ -4172,19 +4626,19 @@ turnAnalytics.layer("ProviderServiceLive turn analytics", (it) => { [firstTurnId, "first"], [secondTurnId, "second"], ] as const) { - primaryAnalyticsCodex.emit({ + parallelAnalyticsCursor.emit({ type: "turn.started", eventId: asEventId(`evt-turn-analytics-overlap-fast-start-${suffix}`), - provider: CODEX_DRIVER, + provider: CURSOR_DRIVER, createdAt: "2026-01-01T00:00:00.000Z", threadId, turnId, payload: { model: `native-${suffix}`, effort: `native-effort-${suffix}` }, }); - primaryAnalyticsCodex.emit({ + parallelAnalyticsCursor.emit({ type: "turn.completed", eventId: asEventId(`evt-turn-analytics-overlap-fast-complete-${suffix}`), - provider: CODEX_DRIVER, + provider: CURSOR_DRIVER, createdAt: "2026-01-01T00:00:00.000Z", threadId, turnId, @@ -4324,13 +4778,13 @@ turnAnalytics.layer("ProviderServiceLive turn analytics", (it) => { ); let startedCount = 0; yield* provider.startSession(threadId, { - provider: CODEX_DRIVER, - providerInstanceId: codexInstanceId, + provider: CURSOR_DRIVER, + providerInstanceId: cursorInstanceId, threadId, runtimeMode: "full-access", }); for (const turnId of turnIds) { - primaryAnalyticsCodex.sendTurn.mockImplementationOnce((input) => + parallelAnalyticsCursor.sendTurn.mockImplementationOnce((input) => Effect.gen(function* () { startedCount += 1; if (startedCount === turnIds.length) { @@ -4363,19 +4817,19 @@ turnAnalytics.layer("ProviderServiceLive turn analytics", (it) => { yield* Deferred.await(allStarted); for (const [index, turnId] of turnIds.entries()) { - primaryAnalyticsCodex.emit({ + parallelAnalyticsCursor.emit({ type: "turn.started", eventId: asEventId(`evt-turn-analytics-bounded-deferred-start-${index + 1}`), - provider: CODEX_DRIVER, + provider: CURSOR_DRIVER, createdAt: "2026-01-01T00:00:00.000Z", threadId, turnId, payload: { model: `native-bounded-${index + 1}` }, }); - primaryAnalyticsCodex.emit({ + parallelAnalyticsCursor.emit({ type: "turn.completed", eventId: asEventId(`evt-turn-analytics-bounded-deferred-complete-${index + 1}`), - provider: CODEX_DRIVER, + provider: CURSOR_DRIVER, createdAt: "2026-01-01T00:00:00.000Z", threadId, turnId, @@ -4402,12 +4856,12 @@ turnAnalytics.layer("ProviderServiceLive turn analytics", (it) => { const sendRelease = yield* Deferred.make(); const turnId = asTurnId("turn-analytics-stop-deferred"); yield* provider.startSession(threadId, { - provider: CODEX_DRIVER, - providerInstanceId: codexInstanceId, + provider: CURSOR_DRIVER, + providerInstanceId: cursorInstanceId, threadId, runtimeMode: "full-access", }); - primaryAnalyticsCodex.sendTurn + parallelAnalyticsCursor.sendTurn .mockImplementationOnce(() => Effect.gen(function* () { yield* Deferred.succeed(firstStarted, undefined); @@ -4437,19 +4891,19 @@ turnAnalytics.layer("ProviderServiceLive turn analytics", (it) => { Effect.forkChild, ); yield* Effect.yieldNow; - primaryAnalyticsCodex.emit({ + parallelAnalyticsCursor.emit({ type: "turn.started", eventId: asEventId("evt-turn-analytics-stop-deferred-start"), - provider: CODEX_DRIVER, + provider: CURSOR_DRIVER, createdAt: "2026-01-01T00:00:00.000Z", threadId, turnId, payload: { model: "native-stop" }, }); - primaryAnalyticsCodex.emit({ + parallelAnalyticsCursor.emit({ type: "turn.completed", eventId: asEventId("evt-turn-analytics-stop-deferred-complete"), - provider: CODEX_DRIVER, + provider: CURSOR_DRIVER, createdAt: "2026-01-01T00:00:00.000Z", threadId, turnId, diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index cdac979c4dfd..901eae89172a 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -34,6 +34,7 @@ import { type ProviderRuntimeEvent, type ProviderSession, type ServerSettings as ServerSettingsValue, + type ProviderSessionFence, } from "@t3tools/contracts"; import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCitations"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -41,6 +42,9 @@ import { causeErrorTag } from "@t3tools/shared/observability"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import * as DateTime from "effect/DateTime"; +import * as Crypto from "effect/Crypto"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as Semaphore from "effect/Semaphore"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -74,6 +78,7 @@ import { type ProviderAdapterError, ProviderValidationError, ProviderWorkspaceMissingError, + ProviderSessionFenceError, } from "../Errors.ts"; import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; import * as ProviderAdapterRegistry from "../Services/ProviderAdapterRegistry.ts"; @@ -856,6 +861,77 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }); yield* recordCompletedTurnProperties(properties); }); + const crypto = yield* Crypto.Crypto; + const sessionEpochs = new Map< + ThreadId, + { id: string; adapter: ProviderAdapterShape; createdAt: string } + >(); + const commandLocks = new Map(); + // Stop must still work while a provider is acknowledging a send. Its escape path + // captures that adapter and cannot recover or rebind to a different session. + const pendingSendInterrupts = new Map>(); + const withThreadCommand = (threadId: ThreadId, effect: Effect.Effect) => + Effect.suspend(() => { + const entry = commandLocks.get(threadId) ?? { semaphore: Semaphore.makeUnsafe(1), users: 0 }; + entry.users += 1; + commandLocks.set(threadId, entry); + return entry.semaphore + .withPermits(1)(effect) + .pipe( + Effect.ensuring( + Effect.sync(() => { + entry.users -= 1; + if (entry.users === 0) commandLocks.delete(threadId); + }), + ), + ); + }); + const assignSessionEpoch = Effect.fn("assignSessionEpoch")(function* ( + session: ProviderSession, + adapter: ProviderAdapterShape, + issuedSessionId?: string, + ) { + const id = + issuedSessionId ?? + (yield* crypto.randomUUIDv4.pipe( + Effect.mapError((cause) => + toValidationError( + "assignSessionEpoch", + "Could not create a provider session identity.", + cause, + ), + ), + )); + sessionEpochs.set(session.threadId, { id, adapter, createdAt: session.createdAt }); + return { ...session, providerSessionId: id }; + }); + const checkSessionFence = Effect.fn("checkSessionFence")(function* ( + threadId: ThreadId, + adapter: ProviderAdapterShape, + instanceId: ProviderInstanceId, + expected: ProviderSessionFence | undefined, + ) { + if (!expected) return; + const session = (yield* adapter.listSessions()).find((item) => item.threadId === threadId); + const epoch = sessionEpochs.get(threadId); + const currentAdapter = yield* registry.getByInstance(instanceId); + if ( + !session || + !epoch || + epoch.adapter !== adapter || + currentAdapter !== adapter || + epoch.createdAt !== session.createdAt || + epoch.id !== expected.providerSessionId || + instanceId !== expected.providerInstanceId || + session.status !== expected.readiness || + (session.activeTurnId ?? null) !== expected.activeTurnId + ) { + return yield* new ProviderSessionFenceError({ + threadId, + detail: "The selected provider session or active turn changed before execution.", + }); + } + }); /** * Whether the credential minted below may drive the user's browser. * @@ -1255,7 +1331,12 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( strategy: "adopt-existing", hasResumeCursor: existing.resumeCursor !== undefined, }); - return { adapter, session: existing } as const; + const adopted = yield* assignSessionEpoch( + existing, + adapter, + McpProviderSession.readMcpProviderSession(existing.threadId)?.providerSessionId, + ); + return { adapter, session: adopted } as const; } } @@ -1269,7 +1350,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const persistedCwd = readPersistedCwd(input.binding.runtimePayload); const persistedModelSelection = readPersistedModelSelection(input.binding.runtimePayload); - yield* prepareMcpSession(input.binding.threadId, bindingInstanceId); + const resumedCredential = yield* prepareMcpSession(input.binding.threadId, bindingInstanceId); const resumed = yield* adapter .startSession({ threadId: input.binding.threadId, @@ -1298,7 +1379,12 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( strategy: "resume-thread", hasResumeCursor: resumed.resumeCursor !== undefined, }); - return { adapter, session: resumed } as const; + const recoveredSession = yield* assignSessionEpoch( + resumed, + adapter, + resumedCredential?.config.providerSessionId, + ); + return { adapter, session: recoveredSession } as const; }).pipe( withMetrics({ counter: providerSessionsTotal, @@ -1500,7 +1586,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( } const adapter = yield* registry.getByInstance(resolvedInstanceId); yield* clearTurnAnalyticsSession(resolvedInstanceId, threadId); - yield* prepareMcpSession(threadId, resolvedInstanceId); + const credential = yield* prepareMcpSession(threadId, resolvedInstanceId); const session = yield* adapter .startSession({ ...input, @@ -1517,10 +1603,14 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( `Adapter/provider mismatch: requested '${adapter.provider}', received '${session.provider}'.`, ); } - const sessionWithInstance = { - ...session, - providerInstanceId: resolvedInstanceId, - }; + const sessionWithInstance = yield* assignSessionEpoch( + { + ...session, + providerInstanceId: resolvedInstanceId, + }, + adapter, + credential?.config.providerSessionId, + ); yield* stopStaleSessionsForThread({ threadId, @@ -1566,7 +1656,13 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }, ); - const sendTurn: ProviderServiceMethod<"sendTurn"> = Effect.fn("sendTurn")(function* (rawInput) { + const sendTurn: ( + rawInput: ProviderSendTurnInput, + locked?: boolean, + ) => ReturnType> = Effect.fn("sendTurn")(function* ( + rawInput, + locked = false, + ) { const parsed = yield* decodeInputOrValidationError({ operation: "ProviderService.sendTurn", schema: ProviderSendTurnInput, @@ -1700,13 +1796,37 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( `Provider '${routed.adapter.provider}' requires an explicit continuation prompt`, ); } - if (!routed.isActive) { + const supportsConditionalSend = + routed.adapter.provider === "codex" || routed.adapter.provider === "claudeAgent"; + if (supportsConditionalSend && !locked) + return yield* withThreadCommand(input.threadId, sendTurn(rawInput, true)); + if (!supportsConditionalSend && (input.expectedSession || locked)) + return yield* new ProviderSessionFenceError({ + threadId: input.threadId, + detail: + locked && !input.expectedSession + ? "The provider changed while this request waited for admission. Retry against the current session." + : "This provider does not yet support conditional turn admission.", + }); + if (!routed.isActive && !input.expectedSession) { routed = yield* resolveRoutableSession({ threadId: input.threadId, operation: "ProviderService.sendTurn", allowRecovery: true, }); } + if ( + input.expectedSession && + (input.expectedSession.readiness !== "ready" || + input.expectedSession.activeTurnId !== null || + pendingCompactions.has(input.threadId) || + timedOutNativeCompactions.has(input.threadId)) + ) { + return yield* new ProviderSessionFenceError({ + threadId: input.threadId, + detail: "A conditional send requires an idle provider session.", + }); + } metricProvider = routed.adapter.provider; metricModel = input.modelSelection?.model; yield* Effect.annotateCurrentSpan({ @@ -1719,6 +1839,42 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( // rather than issuing a new one: sessions that go a long time between // browser tool calls used to lose the toolkit outright. yield* McpSessionRegistry.touchActiveMcpThread(input.threadId); + yield* checkSessionFence( + input.threadId, + routed.adapter, + routed.instanceId, + input.expectedSession, + ); + let interruptCount = 0; + let interruptsDone = Deferred.makeUnsafe(); + if (supportsConditionalSend) { + const interruptPending: ProviderServiceMethod<"interruptTurn"> = (rawInterrupt) => + Effect.suspend(() => { + if (pendingSendInterrupts.get(input.threadId) !== interruptPending) + return withThreadCommand(input.threadId, interruptTurn(rawInterrupt)); + if (interruptCount++ === 0) interruptsDone = Deferred.makeUnsafe(); + return Effect.gen(function* () { + const interrupt = yield* decodeInputOrValidationError({ + operation: "ProviderService.interruptTurn", + schema: ProviderInterruptTurnInput, + payload: rawInterrupt, + }); + yield* routed.adapter.interruptTurn(routed.threadId, interrupt.turnId); + yield* analytics.record("provider.turn.interrupted", { + provider: routed.adapter.provider, + }); + }).pipe( + Effect.ensuring( + Effect.suspend(() => + --interruptCount === 0 + ? Deferred.succeed(interruptsDone, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ); + }); + pendingSendInterrupts.set(input.threadId, interruptPending); + } const analyticsModelSelection = input.modelSelection?.instanceId === routed.instanceId ? input.modelSelection : undefined; const turn = yield* Effect.acquireUseRelease( @@ -1732,7 +1888,14 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }), (turnMetadata) => Effect.gen(function* () { - const turn = yield* routed.adapter.sendTurn(input); + const turn = yield* routed.adapter.sendTurn(input).pipe( + Effect.ensuring( + Effect.suspend(() => { + pendingSendInterrupts.delete(input.threadId); + return interruptCount === 0 ? Effect.void : Deferred.await(interruptsDone); + }), + ), + ); yield* associateTurnAnalytics({ providerInstanceId: routed.instanceId, threadId: input.threadId, @@ -1794,54 +1957,63 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const compactThread: ProviderServiceMethod<"compactThread"> = Effect.fn("compactThread")( function* (threadId, modelSelection, requestId) { - const routed = yield* resolveRoutableSession({ + // Recovery and admission share the session fence lock. Completion waits + // outside it so Stop and fallback sends can still reach the provider. + const { routed, compaction, pending } = yield* withThreadCommand( threadId, - operation: "ProviderService.compactThread", - allowRecovery: true, - }); - yield* Effect.annotateCurrentSpan({ - "provider.operation": "compact-thread", - "provider.kind": routed.adapter.provider, - "provider.thread_id": threadId, - }); - yield* McpSessionRegistry.touchActiveMcpThread(threadId); - const compaction = routed.adapter.compaction; - if (compaction === undefined) { - return yield* toValidationError( - "ProviderService.compactThread", - `Provider '${routed.adapter.provider}' does not support context compaction.`, - ); - } - const completion = yield* Deferred.make(); - const pending: PendingCompaction = { - completion, - native: compaction.type === "native", - providerInstanceId: routed.instanceId, - requestId, - earlyEvents: [], - compactedEventObserved: false, - expectedTurnId: undefined, - }; - if (compaction.type === "native" && timedOutNativeCompactions.has(threadId)) { - return yield* new ProviderAdapterRequestError({ - provider: routed.adapter.provider, - method: "thread/compact", - detail: - "The previous context compaction may still be running. Restart the provider session before retrying.", - }); - } - const claimed = yield* Effect.sync(() => { - if (pendingCompactions.has(threadId)) return false; - pendingCompactions.set(threadId, pending); - return true; - }); - if (!claimed) { - return yield* new ProviderAdapterRequestError({ - provider: routed.adapter.provider, - method: "thread/compact", - detail: "Context compaction is already in progress.", - }); - } + Effect.gen(function* () { + const routed = yield* resolveRoutableSession({ + threadId, + operation: "ProviderService.compactThread", + allowRecovery: true, + }); + yield* Effect.annotateCurrentSpan({ + "provider.operation": "compact-thread", + "provider.kind": routed.adapter.provider, + "provider.thread_id": threadId, + }); + yield* McpSessionRegistry.touchActiveMcpThread(threadId); + const compaction = routed.adapter.compaction; + if (compaction === undefined) { + return yield* toValidationError( + "ProviderService.compactThread", + `Provider '${routed.adapter.provider}' does not support context compaction.`, + ); + } + const completion = yield* Deferred.make(); + const pending: PendingCompaction = { + completion, + native: compaction.type === "native", + providerInstanceId: routed.instanceId, + requestId, + earlyEvents: [], + compactedEventObserved: false, + expectedTurnId: undefined, + }; + if (compaction.type === "native" && timedOutNativeCompactions.has(threadId)) { + return yield* new ProviderAdapterRequestError({ + provider: routed.adapter.provider, + method: "thread/compact", + detail: + "The previous context compaction may still be running. Restart the provider session before retrying.", + }); + } + const claimed = yield* Effect.sync(() => { + if (pendingCompactions.has(threadId)) return false; + pendingCompactions.set(threadId, pending); + return true; + }); + if (!claimed) { + return yield* new ProviderAdapterRequestError({ + provider: routed.adapter.provider, + method: "thread/compact", + detail: "Context compaction is already in progress.", + }); + } + return { routed, compaction, pending }; + }), + ); + const completion = pending.completion; const clearPending = Effect.sync(() => { if (pendingCompactions.get(threadId) === pending) { pendingCompactions.delete(threadId); @@ -1928,7 +2100,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const routed = yield* resolveRoutableSession({ threadId: input.threadId, operation: "ProviderService.interruptTurn", - allowRecovery: true, + allowRecovery: input.expectedSession === undefined, }); metricProvider = routed.adapter.provider; yield* Effect.annotateCurrentSpan({ @@ -1937,7 +2109,39 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( "provider.thread_id": input.threadId, "provider.turn_id": input.turnId, }); - yield* routed.adapter.interruptTurn(routed.threadId, input.turnId); + if ( + input.expectedSession && + (input.expectedSession.readiness !== "running" || + input.expectedSession.activeTurnId === null || + (input.turnId !== undefined && input.turnId !== input.expectedSession.activeTurnId)) + ) { + return yield* new ProviderSessionFenceError({ + threadId: input.threadId, + detail: "A conditional interrupt must target the selected active turn.", + }); + } + yield* checkSessionFence( + input.threadId, + routed.adapter, + routed.instanceId, + input.expectedSession, + ); + if ( + input.expectedSession && + routed.adapter.provider !== "codex" && + routed.adapter.provider !== "claudeAgent" + ) + return yield* new ProviderSessionFenceError({ + threadId: input.threadId, + detail: "This provider does not yet support conditional interruption.", + }); + yield* input.expectedSession + ? routed.adapter.interruptTurn( + routed.threadId, + input.expectedSession.activeTurnId ?? undefined, + { preserveSession: true }, + ) + : routed.adapter.interruptTurn(routed.threadId, input.turnId); yield* analytics.record("provider.turn.interrupted", { provider: routed.adapter.provider, }); @@ -2050,6 +2254,12 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( "provider.thread_id": input.threadId, }); if (routed.isActive) { + yield* checkSessionFence( + input.threadId, + routed.adapter, + routed.instanceId, + input.expectedSession, + ); const session = (yield* routed.adapter.listSessions()).find( (session) => session.threadId === routed.threadId, ); @@ -2067,6 +2277,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( } timedOutNativeCompactions.delete(input.threadId); yield* clearTurnAnalyticsSession(routed.instanceId, input.threadId); + sessionEpochs.delete(input.threadId); yield* clearMcpSession(input.threadId); yield* directory.upsert({ threadId: input.threadId, @@ -2103,6 +2314,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( sessions.map((session) => ({ ...session, providerInstanceId: instanceId, + ...(sessionEpochs.get(session.threadId)?.adapter === adapter && + sessionEpochs.get(session.threadId)?.createdAt === session.createdAt + ? { providerSessionId: sessionEpochs.get(session.threadId)!.id } + : {}), })), ), ), @@ -2260,24 +2475,30 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( schema: ProviderUploadFeedbackInput, payload: rawInput, }); - let routed = yield* resolveRoutableSession({ - threadId: input.threadId, - operation: "ProviderService.uploadFeedback", - allowRecovery: false, - }); - if (routed.adapter.uploadFeedback === undefined) { - return yield* toValidationError( - "ProviderService.uploadFeedback", - `Provider '${routed.adapter.provider}' does not support feedback uploads.`, - ); - } - if (!routed.isActive) { - routed = yield* resolveRoutableSession({ - threadId: input.threadId, - operation: "ProviderService.uploadFeedback", - allowRecovery: true, - }); - } + const routed = yield* withThreadCommand( + input.threadId, + Effect.gen(function* () { + let routed = yield* resolveRoutableSession({ + threadId: input.threadId, + operation: "ProviderService.uploadFeedback", + allowRecovery: false, + }); + if (routed.adapter.uploadFeedback === undefined) { + return yield* toValidationError( + "ProviderService.uploadFeedback", + `Provider '${routed.adapter.provider}' does not support feedback uploads.`, + ); + } + if (!routed.isActive) { + routed = yield* resolveRoutableSession({ + threadId: input.threadId, + operation: "ProviderService.uploadFeedback", + allowRecovery: true, + }); + } + return routed; + }), + ); const uploadFeedback = routed.adapter.uploadFeedback; if (uploadFeedback === undefined) { return yield* toValidationError( @@ -2399,18 +2620,25 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ); return { - startSession, - sendTurn, + startSession: (threadId, input) => withThreadCommand(threadId, startSession(threadId, input)), + sendTurn: (input) => sendTurn(input), + interruptTurn: (input) => + Effect.suspend(() => { + const pending = + input.expectedSession === undefined + ? pendingSendInterrupts.get(input.threadId) + : undefined; + return pending ? pending(input) : withThreadCommand(input.threadId, interruptTurn(input)); + }), + respondToRequest: (input) => withThreadCommand(input.threadId, respondToRequest(input)), + respondToUserInput: (input) => withThreadCommand(input.threadId, respondToUserInput(input)), + stopSession: (input) => withThreadCommand(input.threadId, stopSession(input)), compactThread, - interruptTurn, - respondToRequest, - respondToUserInput, - stopSession, + assertConversationRollbackSupported, listSessions, getCapabilities, getInstanceInfo, - assertConversationRollbackSupported, - rollbackConversation, + rollbackConversation: (input) => withThreadCommand(input.threadId, rollbackConversation(input)), uploadFeedback, // Each access creates a fresh PubSub subscription so that multiple // consumers (ProviderRuntimeIngestion, CheckpointReactor, etc.) each @@ -2423,9 +2651,12 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( export const ProviderServiceLive = Layer.effect( ProviderService.ProviderService, - makeProviderService(), + makeProviderService().pipe(Effect.provide(NodeCrypto.layer)), ); export function makeProviderServiceLive(options?: ProviderServiceLiveOptions) { - return Layer.effect(ProviderService.ProviderService, makeProviderService(options)); + return Layer.effect( + ProviderService.ProviderService, + makeProviderService(options).pipe(Effect.provide(NodeCrypto.layer)), + ); } diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index c9b62fd79525..57c0f2789951 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -91,7 +91,11 @@ export interface ProviderAdapterShape { /** * Interrupt an active turn. */ - readonly interruptTurn: (threadId: ThreadId, turnId?: TurnId) => Effect.Effect; + readonly interruptTurn: ( + threadId: ThreadId, + turnId?: TurnId, + options?: { readonly preserveSession: boolean }, + ) => Effect.Effect; /** * Respond to an interactive approval request. diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 5228e296d68f..e01997b4e535 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -67,6 +67,7 @@ const decodeThreadTurnStartRequestedPayload = Schema.decodeUnknownEffect( const decodeOrchestrationLatestTurn = Schema.decodeUnknownEffect(OrchestrationLatestTurn); const decodeOrchestrationProposedPlan = Schema.decodeUnknownEffect(OrchestrationProposedPlan); const decodeOrchestrationSession = Schema.decodeUnknownEffect(OrchestrationSession); +const encodeOrchestrationSession = Schema.encodeEffect(OrchestrationSession); const decodeOrchestrationThread = Schema.decodeUnknownEffect(OrchestrationThread); const decodeOrchestrationThreadShell = Schema.decodeUnknownEffect(OrchestrationThreadShell); const encodeThreadCreatedPayload = Schema.encodeEffect(ThreadCreatedPayload); @@ -1333,6 +1334,36 @@ it.effect("decodes orchestration session runtime mode defaults", () => }), ); +it.effect("round-trips missing, null, and exact provider session identities", () => + Effect.gen(function* () { + const session = { + threadId: "thread-1", + status: "idle", + providerName: null, + activeTurnId: null, + lastError: null, + updatedAt: "2026-01-01T00:00:00.000Z", + }; + for (const identity of [ + {}, + { providerSessionId: null }, + { providerSessionId: "generation-1" }, + ]) { + const parsed = yield* decodeOrchestrationSession({ ...session, ...identity }); + const encoded = yield* encodeOrchestrationSession(parsed); + assert.strictEqual(parsed.providerSessionId, identity.providerSessionId); + assert.strictEqual(encoded.providerSessionId, identity.providerSessionId); + assert.deepStrictEqual(yield* decodeOrchestrationSession(encoded), parsed); + } + for (const providerSessionId of ["", " ", 123]) { + const result = yield* Effect.exit( + decodeOrchestrationSession({ ...session, providerSessionId }), + ); + assert.strictEqual(result._tag, "Failure"); + } + }), +); + it.effect("defaults proposed plan implementation metadata for historical rows", () => Effect.gen(function* () { const parsed = yield* decodeOrchestrationProposedPlan({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index dc8a0732198c..8ed1d7e4044e 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -553,6 +553,7 @@ export type OrchestrationSessionStatus = typeof OrchestrationSessionStatus.Type; export const OrchestrationSession = Schema.Struct({ threadId: ThreadId, + providerSessionId: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), status: OrchestrationSessionStatus, providerName: Schema.NullOr(TrimmedNonEmptyString), providerInstanceId: Schema.optional(ProviderInstanceId), @@ -1235,10 +1236,30 @@ const ThreadTurnStartBootstrap = Schema.Struct({ export type ThreadTurnStartBootstrap = typeof ThreadTurnStartBootstrap.Type; +/** Optional compare-and-dispatch condition for callers targeting a particular live session. */ +export const ProviderSessionFence = Schema.Struct({ + providerInstanceId: ProviderInstanceId, + providerSessionId: TrimmedNonEmptyString, + activeTurnId: Schema.NullOr(TurnId), + readiness: Schema.Literals(["ready", "running"]), +}); +export type ProviderSessionFence = typeof ProviderSessionFence.Type; + +export const ProviderCommandExecutionResult = Schema.Struct({ + commandId: CommandId, + operation: Schema.Literals(["send-turn", "interrupt-turn"]), + expectedSession: ProviderSessionFence, + status: Schema.Literals(["dispatching", "dispatched", "rejected", "uncertain"]), + detail: Schema.String, + turnId: Schema.NullOr(TurnId), +}); +export type ProviderCommandExecutionResult = typeof ProviderCommandExecutionResult.Type; + export const ThreadTurnStartCommand = Schema.Struct({ type: Schema.Literal("thread.turn.start"), commandId: CommandId, threadId: ThreadId, + expectedSession: Schema.optional(ProviderSessionFence), message: Schema.Struct({ messageId: MessageId, role: Schema.Literal("user"), @@ -1261,6 +1282,7 @@ const ClientThreadTurnStartCommand = Schema.Struct({ type: Schema.Literal("thread.turn.start"), commandId: CommandId, threadId: ThreadId, + expectedSession: Schema.optional(ProviderSessionFence), message: Schema.Struct({ messageId: MessageId, role: Schema.Literal("user"), @@ -1281,6 +1303,7 @@ const ThreadTurnInterruptCommand = Schema.Struct({ type: Schema.Literal("thread.turn.interrupt"), commandId: CommandId, threadId: ThreadId, + expectedSession: Schema.optional(ProviderSessionFence), turnId: Schema.optional(TurnId), createdAt: IsoDateTime, }); @@ -1812,6 +1835,7 @@ export const ThreadMessageSentPayload = Schema.Struct({ export const ThreadTurnStartRequestedPayload = Schema.Struct({ threadId: ThreadId, + expectedSession: Schema.optional(ProviderSessionFence), messageId: MessageId, modelSelection: Schema.optional(ModelSelection), titleSeed: Schema.optional(TrimmedNonEmptyString), @@ -1825,6 +1849,7 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({ export const ThreadTurnInterruptRequestedPayload = Schema.Struct({ threadId: ThreadId, + expectedSession: Schema.optional(ProviderSessionFence), turnId: Schema.optional(TurnId), createdAt: IsoDateTime, }); diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index df91839e8520..fe97f4a2f324 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -16,6 +16,7 @@ import { ProviderApprovalDecision, ProviderApprovalPolicy, ProviderInteractionMode, + ProviderSessionFence, ProviderRequestKind, ProviderSandboxMode, ProviderUserInputAnswers, @@ -33,6 +34,7 @@ const ProviderSessionStatus = Schema.Literals([ ]); export const ProviderSession = Schema.Struct({ + providerSessionId: Schema.optional(TrimmedNonEmptyString), provider: ProviderDriverKind, // Optional during the driver/instance migration. Once every producer // populates it (post-slice-4), routing flips to instance-id-only and the @@ -71,6 +73,7 @@ export const ProviderSendTurnInput = Schema.Struct({ /** Internal recovery signal. Allows an empty turn only for adapters that explicitly support promptless continuation. */ continuation: Schema.optional(Schema.Boolean), + expectedSession: Schema.optional(ProviderSessionFence), input: Schema.optional( TrimmedNonEmptyString.check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)), ), @@ -91,12 +94,14 @@ export type ProviderTurnStartResult = typeof ProviderTurnStartResult.Type; export const ProviderInterruptTurnInput = Schema.Struct({ threadId: ThreadId, + expectedSession: Schema.optional(ProviderSessionFence), turnId: Schema.optional(TurnId), }); export type ProviderInterruptTurnInput = typeof ProviderInterruptTurnInput.Type; export const ProviderStopSessionInput = Schema.Struct({ threadId: ThreadId, + expectedSession: Schema.optional(ProviderSessionFence), }); export type ProviderStopSessionInput = typeof ProviderStopSessionInput.Type; From f0781511dfd75922f5a28916175cabc236d2582f Mon Sep 17 00:00:00 2001 From: Lior Yasur Date: Wed, 16 Sep 2026 01:41:32 +0300 Subject: [PATCH 2/4] fix(server): close conditional admission and cancellation races --- .../Layers/ProviderCommandReactor.test.ts | 270 +++++++++++++----- .../Layers/ProviderCommandReactor.ts | 86 ++++-- apps/server/src/provider/Errors.ts | 1 + .../src/provider/Layers/ClaudeAdapter.test.ts | 104 +++++-- .../src/provider/Layers/ClaudeAdapter.ts | 146 ++++++---- .../src/provider/Layers/CodexAdapter.ts | 6 +- .../CodexCollabRuntime.integration.test.ts | 99 ++++++- .../provider/Layers/CodexSessionRuntime.ts | 176 +++++++----- .../provider/Layers/ProviderService.test.ts | 71 +++++ .../src/provider/Layers/ProviderService.ts | 207 +++++++++----- .../testFixtures/codexCollabMockPeer.mjs | 18 +- 11 files changed, 880 insertions(+), 304 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index f17b5ccee287..e0c4008d77bb 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -183,6 +183,9 @@ describe("ProviderCommandReactor", () => { readonly afterTurnStartDispatch?: () => Effect.Effect; readonly compactThreadEffect?: () => Effect.Effect; readonly conditionalBeforeStart?: "intent" | "dispatching"; + readonly onReplayHeadCaptured?: ( + engine: OrchestrationEngineService["Service"], + ) => Effect.Effect; readonly interruptTurnEffect?: () => Effect.Effect< void, ProviderAdapterRequestError | ProviderSessionFenceError @@ -461,7 +464,9 @@ describe("ProviderCommandReactor", () => { return engine.streamDomainEvents; }, subscribeDomainEvents: engine.subscribeDomainEvents, - latestSequence: engine.latestSequence, + latestSequence: engine.latestSequence.pipe( + Effect.tap(() => input?.onReplayHeadCaptured?.(engine) ?? Effect.void), + ), } satisfies OrchestrationEngineService["Service"]; }), ).pipe(Layer.provide(orchestrationLayer)); @@ -929,72 +934,138 @@ describe("ProviderCommandReactor", () => { ); }), ); - it("records conditional delivery without starting a different session and replays without resending", async () => { - const harness = await createHarness(); - const now = "2026-01-01T00:00:00.000Z"; - const threadId = ThreadId.make("thread-1"); - const expectedSession = { - providerInstanceId: ProviderInstanceId.make("codex"), - providerSessionId: "selected-generation", - activeTurnId: null, - readiness: "ready" as const, - }; - await harness.runEffect( - harness.engine.dispatch({ - type: "thread.session.set", - commandId: CommandId.make("set-selected-session"), - threadId, - session: { + it.each(["normal", "interrupt", "stop", "reject"] as const)( + "records conditional delivery without resending and keeps %s responsive", + async (operation) => { + const harness = await createHarness(); + const releaseSend = Deferred.makeUnsafe(); + if (operation === "reject") + harness.sendTurn.mockImplementationOnce(() => + Effect.fail( + new ProviderSessionFenceError({ threadId: "thread-1", detail: "No longer idle" }), + ), + ); + const expectedStatus = operation === "reject" ? "rejected" : "dispatched"; + if (operation === "interrupt" || operation === "stop") + harness.sendTurn.mockImplementationOnce(() => + Deferred.await(releaseSend).pipe( + Effect.as({ threadId: ThreadId.make("thread-1"), turnId: asTurnId("selected-turn") }), + ), + ); + const now = "2026-01-01T00:00:00.000Z"; + const threadId = ThreadId.make("thread-1"); + const expectedSession = { + providerInstanceId: ProviderInstanceId.make("codex"), + providerSessionId: "selected-generation", + activeTurnId: null, + readiness: "ready" as const, + }; + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("set-selected-session"), threadId, - providerName: "codex", - providerInstanceId: expectedSession.providerInstanceId, - providerSessionId: expectedSession.providerSessionId, - status: "ready", - runtimeMode: "approval-required", - activeTurnId: null, - lastError: null, - updatedAt: now, + session: { + threadId, + providerName: "codex", + providerInstanceId: expectedSession.providerInstanceId, + providerSessionId: expectedSession.providerSessionId, + status: "ready", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + const command = { + type: "thread.turn.start" as const, + threadId, + commandId: CommandId.make("conditional-send"), + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + expectedSession, + message: { + messageId: asMessageId("selected-message"), + role: "user" as const, + text: "Review [build](t3-context://v1/terminal/selected-context)", + attachments: [], + context: { + version: 1 as const, + records: [ + { + version: 1 as const, + kind: "terminal" as const, + contextId: ComposerContextId.make("selected-context"), + label: "build", + terminalId: "terminal-1", + terminalLabel: "Build", + lineStart: 1, + lineEnd: 1, + text: "compiled successfully", + }, + ], + }, }, + runtimeMode: "approval-required" as const, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, createdAt: now, - }), - ); - const command = { - type: "thread.turn.start" as const, - threadId, - commandId: CommandId.make("conditional-send"), - expectedSession, - message: { - messageId: asMessageId("selected-message"), - role: "user" as const, - text: "Review this patch", - attachments: [], - }, - runtimeMode: "approval-required" as const, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - createdAt: now, - }; - await harness.runEffect(harness.engine.dispatch(command)); - await waitFor(() => harness.sendTurn.mock.calls.length === 1); - await harness.drain(); - const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); - expect( - thread?.activities.find( - (activity) => - activity.kind === "provider.command.execution" && - (activity.payload as { status?: string }).status === "dispatched", - )?.payload, - ).toMatchObject({ - commandId: command.commandId, - expectedSession, - status: "dispatched", - operation: "send-turn", - }); - expect(harness.startSession).not.toHaveBeenCalled(); - expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ expectedSession }); - await harness.runEffect(harness.engine.dispatch(command)); - await harness.drain(); - expect(harness.sendTurn).toHaveBeenCalledTimes(1); - }); + }; + await harness.runEffect(harness.engine.dispatch(command)); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + if (operation === "interrupt" || operation === "stop") { + await harness.runEffect( + harness.engine.dispatch({ + type: operation === "stop" ? "thread.session.stop" : "thread.turn.interrupt", + commandId: CommandId.make(`during-send-${operation}`), + threadId, + createdAt: now, + }), + ); + await waitFor( + () => + (operation === "stop" ? harness.stopSession : harness.interruptTurn).mock.calls + .length === 1, + ); + await harness.runEffect(Deferred.succeed(releaseSend, undefined)); + } + await waitFor(async () => + (await harness.readModel()).threads.some((thread) => + thread.activities.some( + (activity) => + activity.kind === "provider.command.execution" && + (activity.payload as { status?: string }).status === expectedStatus, + ), + ), + ); + await harness.drain(); + const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); + expect( + thread?.activities.find( + (activity) => + activity.kind === "provider.command.execution" && + (activity.payload as { status?: string }).status === expectedStatus, + )?.payload, + ).toMatchObject({ + commandId: command.commandId, + expectedSession, + status: expectedStatus, + operation: "send-turn", + }); + if (operation === "reject") expect(await harness.readPendingTurnStarts()).toEqual([]); + expect(harness.startSession).not.toHaveBeenCalled(); + expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ + expectedSession, + modelSelection: command.modelSelection, + }); + expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ + input: expect.stringContaining("compiled successfully"), + }); + await harness.runEffect(harness.engine.dispatch(command)); + await harness.drain(); + expect(harness.sendTurn).toHaveBeenCalledTimes(1); + }, + ); for (const stage of ["intent", "dispatching"] as const) { it(`closes a ${stage} left across startup without resending`, async () => { @@ -1009,11 +1080,73 @@ describe("ProviderCommandReactor", () => { (activity.payload as { status?: string }).status === status, )?.payload, ).toMatchObject({ commandId: "interrupted-conditional", status }); + expect(await harness.readPendingTurnStarts()).toEqual([]); expect(harness.sendTurn).not.toHaveBeenCalled(); expect(harness.startSession).not.toHaveBeenCalled(); }); } + it("delivers a conditional command accepted while reactor startup reads its snapshot", async () => { + const threadId = ThreadId.make("thread-1"); + const now = "2026-01-01T00:00:00.000Z"; + const expectedSession = { + providerInstanceId: ProviderInstanceId.make("codex"), + providerSessionId: "startup-session", + activeTurnId: null, + readiness: "ready" as const, + }; + const harness = await createHarness({ + onReplayHeadCaptured: (engine) => + Effect.gen(function* () { + yield* engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("startup-session"), + threadId, + session: { + threadId, + providerName: "codex", + providerInstanceId: expectedSession.providerInstanceId, + providerSessionId: expectedSession.providerSessionId, + status: "ready", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }); + yield* engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("during-startup"), + threadId, + expectedSession, + message: { + messageId: asMessageId("during-startup"), + role: "user", + text: "Do not lose this command", + attachments: [], + }, + runtimeMode: "approval-required", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + createdAt: now, + }); + }).pipe(Effect.orDie), + }); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + await waitFor(async () => + (await harness.readModel()).threads.some((thread) => + thread.activities.some( + (activity) => + activity.kind === "provider.command.execution" && + (activity.payload as { status?: string }).status === "dispatched", + ), + ), + ); + await harness.drain(); + expect(harness.sendTurn).toHaveBeenCalledTimes(1); + expect(harness.startSession).not.toHaveBeenCalled(); + }); + it("records an interrupt fence rejection without the ordinary fallback stop", async () => { const harness = await createHarness({ interruptTurnEffect: () => @@ -1063,6 +1196,15 @@ describe("ProviderCommandReactor", () => { }), ); await waitFor(() => harness.interruptTurn.mock.calls.length === 1); + await waitFor(async () => + (await harness.readModel()).threads.some((thread) => + thread.activities.some( + (activity) => + activity.kind === "provider.command.execution" && + (activity.payload as { status?: string }).status === "rejected", + ), + ), + ); await harness.drain(); expect(harness.stopSession).not.toHaveBeenCalled(); const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 1b1fa94c64dd..2119ccfc20c9 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -271,6 +271,7 @@ const make = Effect.gen(function* () { { type: "thread.turn-start-requested" | "thread.turn-interrupt-requested" } >, execute: Effect.Effect, + background = false, ) { const expectedSession = event.payload.expectedSession; if (!expectedSession || !event.commandId) return; @@ -282,20 +283,45 @@ const make = Effect.gen(function* () { const started = yield* executionReceipts.getByCommandId({ commandId: markerId }); const operation = event.type === "thread.turn-start-requested" ? "send-turn" : "interrupt-turn"; const append = (result: ProviderCommandExecutionResult) => - orchestrationEngine.dispatch({ - type: "thread.activity.append", - commandId: result.status === "dispatching" ? markerId : resultId, - threadId: event.payload.threadId, - activity: { - id: EventId.make(`${result.status === "dispatching" ? markerId : resultId}`), - tone: result.status === "rejected" || result.status === "uncertain" ? "error" : "info", - kind: "provider.command.execution", - summary: result.detail, - payload: result, - turnId: result.turnId, + Effect.gen(function* () { + if ( + event.type === "thread.turn-start-requested" && + (result.status === "rejected" || result.status === "uncertain") + ) { + // Clear only this pending message before finalizing its durable receipt. + // A crash between the two writes still replays the dispatching marker. + const failureId = CommandId.make(`provider-execution:${originalCommandId}:failed`); + yield* orchestrationEngine.dispatch({ + type: "thread.activity.append", + commandId: failureId, + threadId: event.payload.threadId, + activity: { + id: EventId.make(failureId), + tone: "error", + kind: "provider.turn.start.failed", + summary: result.detail, + payload: { requestId: event.payload.messageId, detail: result.detail }, + turnId: null, + createdAt: event.payload.createdAt, + }, + createdAt: event.payload.createdAt, + }); + } + return yield* orchestrationEngine.dispatch({ + type: "thread.activity.append", + commandId: result.status === "dispatching" ? markerId : resultId, + threadId: event.payload.threadId, + activity: { + id: EventId.make(`${result.status === "dispatching" ? markerId : resultId}`), + tone: result.status === "rejected" || result.status === "uncertain" ? "error" : "info", + kind: "provider.command.execution", + summary: result.detail, + payload: result, + turnId: result.turnId, + createdAt: event.payload.createdAt, + }, createdAt: event.payload.createdAt, - }, - createdAt: event.payload.createdAt, + }); }); const base = { commandId: originalCommandId, @@ -316,7 +342,7 @@ const make = Effect.gen(function* () { status: "dispatching", detail: "Dispatching to the selected provider session.", }); - yield* execute.pipe( + const execution = execute.pipe( Effect.matchCauseEffect({ onSuccess: (turnId) => append({ @@ -335,6 +361,16 @@ const make = Effect.gen(function* () { }), }), ); + if (background) { + yield* execution.pipe( + Effect.catchCause((cause) => + Effect.logWarning("conditional provider execution receipt failed", { cause }), + ), + Effect.forkScoped, + ); + } else { + yield* execution; + } }); const appendProviderFailureActivity = (input: { @@ -1285,8 +1321,14 @@ const make = Effect.gen(function* () { ? providerService .sendTurn({ threadId: event.payload.threadId, - input: message.text, + input: projectComposerContextForProvider({ + text: message.text, + records: message.context?.records ?? [], + }), ...(message.attachments ? { attachments: message.attachments } : {}), + ...(event.payload.modelSelection + ? { modelSelection: event.payload.modelSelection } + : {}), expectedSession: event.payload.expectedSession, interactionMode: event.payload.interactionMode, }) @@ -1297,7 +1339,7 @@ const make = Effect.gen(function* () { detail: "The selected user message is no longer available.", }), ); - yield* executeConditionalCommand(event, execute); + yield* executeConditionalCommand(event, execute, true); return; } const key = turnStartKeyForEvent(event); @@ -1616,6 +1658,7 @@ const make = Effect.gen(function* () { : {}), }) .pipe(Effect.as(null)), + true, ); return; } @@ -1968,6 +2011,9 @@ const make = Effect.gen(function* () { const worker = yield* makeDrainableWorker(processDomainEventSafely); const start: ProviderCommandReactorShape["start"] = Effect.fn("start")(function* () { + // Subscribe before capturing the replay boundary so commands accepted during + // startup are either closed by recovery or delivered by the live subscription. + const domainEvents = yield* orchestrationEngine.subscribeDomainEvents; const startupSequence = yield* orchestrationEngine.latestSequence; const pendingTitles = yield* findPendingThreadTitles().pipe( Effect.catchCause((cause) => { @@ -1981,6 +2027,13 @@ const make = Effect.gen(function* () { }), ); const processEvent = Effect.fn("processEvent")(function* (event: OrchestrationEvent) { + if ( + event.sequence <= startupSequence && + (event.type === "thread.turn-start-requested" || + event.type === "thread.turn-interrupt-requested") && + event.payload.expectedSession + ) + return; if ( (event.type === "thread.meta-updated" && (event.payload.regenerateTitle === true || @@ -1999,7 +2052,6 @@ const make = Effect.gen(function* () { }); // Subscribe before returning, even while event handling waits for server activation. - const domainEvents = yield* orchestrationEngine.subscribeDomainEvents; yield* forkParked(Stream.runForEach(domainEvents, processEvent)); // ponytail: page the durable log on startup; add an indexed conditional-intent query if diff --git a/apps/server/src/provider/Errors.ts b/apps/server/src/provider/Errors.ts index 2aef09a0835a..26a1a5cd0e0a 100644 --- a/apps/server/src/provider/Errors.ts +++ b/apps/server/src/provider/Errors.ts @@ -214,6 +214,7 @@ export class ProviderSessionDirectoryPersistenceError extends Schema.TaggedError } export type ProviderAdapterError = + | ProviderSessionFenceError | ProviderAdapterValidationError | ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 72b379ee0227..747c0669897c 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -73,6 +73,7 @@ class FakeClaudeQuery implements AsyncIterable { public closeCalls = 0; public interruptCalls = 0; public closeError: unknown | undefined; + public permissionModeBarrier: Promise | undefined; emit(message: SDKMessage): void { if (this.done) { @@ -114,6 +115,7 @@ class FakeClaudeQuery implements AsyncIterable { readonly setPermissionMode = async (mode: PermissionMode): Promise => { this.setPermissionModeCalls.push(mode); + await this.permissionModeBarrier; }; readonly setMaxThinkingTokens = async (maxThinkingTokens: number | null): Promise => { @@ -2236,24 +2238,90 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("conditionally interrupts without closing or declaring the session idle", () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const adapter = yield* ClaudeAdapter; - const session = yield* adapter.startSession({ - threadId: THREAD_ID, - provider: ProviderDriverKind.make("claudeAgent"), - runtimeMode: "full-access", - }); - const turn = yield* adapter.sendTurn({ threadId: session.threadId, input: "Work" }); - yield* adapter.interruptTurn(session.threadId, turn.turnId, { preserveSession: true }); - assert.equal(harness.query.interruptCalls, 1); - assert.equal(harness.query.closeCalls, 0); - const current = (yield* adapter.listSessions())[0]; - assert.equal(current?.status, "running"); - assert.equal(current?.activeTurnId, turn.turnId); - }).pipe(Effect.provide(harness.layer)); - }); + it.effect( + "rejects exact-turn interruption without calling Claude's session-wide interrupt", + () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ threadId: session.threadId, input: "Work" }); + const error = yield* adapter + .interruptTurn(session.threadId, turn.turnId, { preserveSession: true }) + .pipe(Effect.flip); + assert.equal(error._tag, "ProviderSessionFenceError"); + assert.equal(harness.query.interruptCalls, 0); + assert.equal(harness.query.closeCalls, 0); + const current = (yield* adapter.listSessions())[0]; + assert.equal(current?.status, "running"); + assert.equal(current?.activeTurnId, turn.turnId); + }).pipe(Effect.provide(harness.layer)); + }, + ); + + it.effect( + "rejects conditional admission when background work starts during permission preparation", + () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + const release = Promise.withResolvers(); + harness.query.permissionModeBarrier = release.promise; + const send = yield* adapter + .sendTurn({ + threadId: THREAD_ID, + input: "Only when idle", + interactionMode: "plan", + expectedSession: { + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + providerSessionId: "selected", + readiness: "ready", + activeTurnId: null, + }, + }) + .pipe(Effect.result, Effect.forkChild); + while (harness.query.setPermissionModeCalls.length === 0) yield* Effect.yieldNow; + harness.query.emit({ + type: "assistant", + session_id: "sdk-background", + uuid: "background-uuid", + parent_tool_use_id: null, + message: { + id: "background-message", + model: "synthetic", + content: [{ type: "text", text: "Background work" }], + }, + } as unknown as SDKMessage); + let activeTurnId; + while (!activeTurnId) { + activeTurnId = (yield* adapter.listSessions())[0]?.activeTurnId; + yield* Effect.yieldNow; + } + release.resolve(); + const result = yield* Fiber.join(send); + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") + assert.equal(result.failure._tag, "ProviderSessionFenceError"); + assert.equal((yield* adapter.listSessions())[0]?.activeTurnId, activeTurnId); + // The rejected prompt must not be waiting ahead of the next ordinary send. + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "Ordinary follow-up" }); + const text = yield* Effect.promise(() => + readFirstPromptText(harness.getLastCreateQueryInput()), + ); + assert.include(text, "Ordinary follow-up"); + assert.notInclude(text, "Only when idle"); + }).pipe(Effect.provide(harness.layer)); + }, + ); const AUTH_FAILURE_ASSISTANT = { type: "assistant", diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index bd71531923d8..b37bd961b366 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -111,6 +111,7 @@ import { ProviderAdapterSessionClosedError, ProviderAdapterSessionNotFoundError, ProviderAdapterValidationError, + ProviderSessionFenceError, type ProviderAdapterError, } from "../Errors.ts"; import { type ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; @@ -4950,17 +4951,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( context.startInput = { ...context.startInput, modelSelection }; } - // A sendTurn while a real turn is running is a steer: the message is - // queued into the live SDK agent loop and the work continues as the same - // turn — no synthetic turn boundary. Stale synthetic turns (from - // background agent responses between user prompts) are auto-closed - // instead, so they don't block the user's next turn. - const steeringTurnState = - context.turnState && context.turnState.synthetic !== true ? context.turnState : null; - if (context.turnState && steeringTurnState === null) { - yield* completeTurn(context, "completed"); - } - if (modelSelection?.model) { const apiModelId = resolveClaudeCatalogApiModelId(modelCatalog, modelSelection); if (context.currentApiModelId !== apiModelId) { @@ -5000,46 +4990,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); } - const turnId = steeringTurnState?.turnId ?? TurnId.make(yield* randomUUIDv4); - if (steeringTurnState === null) { - const turnState: ClaudeTurnState = { - turnId, - startedAt: yield* nowIso, - items: [], - assistantTextBlocks: new Map(), - assistantTextBlockOrder: [], - capturedProposedPlanKeys: new Set(), - latestAssistantUsage: undefined, - compactedSinceLatestAssistantUsage: false, - hasSubagents: false, - nextSyntheticAssistantBlockIndex: -1, - authenticationFailureMessage: undefined, - rejectedRateLimitTypes: new Set(), - latestAssistantRateLimited: false, - }; - - const updatedAt = yield* nowIso; - context.turnState = turnState; - context.session = { - ...context.session, - status: "running", - activeTurnId: turnId, - updatedAt, - }; - - const turnStartedStamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "turn.started", - eventId: turnStartedStamp.eventId, - provider: PROVIDER, - createdAt: turnStartedStamp.createdAt, - threadId: context.session.threadId, - turnId, - payload: modelSelection?.model ? { model: modelSelection.model } : {}, - providerRefs: {}, - }); - } - // Re-scan on every send: skills are added and switched off mid-session, // and the scan is a few directory reads. A skill switched off via // skillOverrides, or reserved for the agent with `user-invocable: false`, @@ -5065,15 +5015,86 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ), }); - if (steeringTurnState === null) context.turnStartMessageIds.push(turnId); + // A sendTurn while a real turn is running is a steer: the message is + // queued into the live SDK agent loop and the work continues as the same + // turn — no synthetic turn boundary. Stale synthetic turns (from + // background agent responses between user prompts) are auto-closed + // instead, so they don't block the user's next turn. + const steeringTurnState = + context.turnState && context.turnState.synthetic !== true ? context.turnState : null; + if (!input.expectedSession && context.turnState && steeringTurnState === null) { + yield* completeTurn(context, "completed"); + } + + const turnId = steeringTurnState?.turnId ?? TurnId.make(yield* randomUUIDv4); + const startedAt = yield* nowIso; + const turnStartedStamp = yield* makeEventStamp(); + yield* Effect.suspend(() => { + if ( + input.expectedSession && + (context.stopped || + context.session.status !== "ready" || + context.turnState !== null || + context.session.activeTurnId !== undefined) + ) { + return new ProviderSessionFenceError({ + threadId: input.threadId, + detail: "The selected session is no longer idle at prompt admission.", + }); + } + if (steeringTurnState === null) { + const turnState: ClaudeTurnState = { + turnId, + startedAt, + items: [], + assistantTextBlocks: new Map(), + assistantTextBlockOrder: [], + capturedProposedPlanKeys: new Set(), + latestAssistantUsage: undefined, + compactedSinceLatestAssistantUsage: false, + hasSubagents: false, + nextSyntheticAssistantBlockIndex: -1, + authenticationFailureMessage: undefined, + rejectedRateLimitTypes: new Set(), + latestAssistantRateLimited: false, + }; + + const updatedAt = startedAt; + context.turnState = turnState; + context.session = { + ...context.session, + status: "running", + activeTurnId: turnId, + updatedAt, + }; + } + if (steeringTurnState === null) context.turnStartMessageIds.push(turnId); + // Reserve and enqueue without yielding to the SDK notification reader. + if ( + !Queue.offerUnsafe(context.promptQueue, { + type: "message", + message: + steeringTurnState === null + ? { ...message, uuid: turnId as NonNullable } + : message, + }) + ) + return Effect.fail(toRequestError(input.threadId, "turn/start", "Prompt queue is closed.")); + return Effect.void; + }); + if (steeringTurnState === null) { + yield* offerRuntimeEvent({ + type: "turn.started", + eventId: turnStartedStamp.eventId, + provider: PROVIDER, + createdAt: turnStartedStamp.createdAt, + threadId: context.session.threadId, + turnId, + payload: modelSelection?.model ? { model: modelSelection.model } : {}, + providerRefs: {}, + }); + } yield* updateResumeCursor(context); - yield* Queue.offer(context.promptQueue, { - type: "message", - message: - steeringTurnState === null - ? { ...message, uuid: turnId as NonNullable } - : message, - }).pipe(Effect.mapError((cause) => toRequestError(input.threadId, "turn/start", cause))); return { threadId: context.session.threadId, @@ -5088,11 +5109,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( function* (threadId, _turnId, options) { const context = yield* requireSession(threadId); if (options?.preserveSession) { - yield* Effect.tryPromise({ - try: () => context.query.interrupt(), - catch: (cause) => toRequestError(threadId, "turn/interrupt", cause), + return yield* new ProviderSessionFenceError({ + threadId, + detail: "Claude does not support interruption targeted to an exact turn.", }); - return; } // interrupt() can acknowledge while resumed background tasks keep the // CLI alive. Stop is a hard session boundary for Claude, so close the diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index b43755736ca3..24323510005a 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -138,6 +138,7 @@ function mapCodexRuntimeError( method: string, error: CodexSessionRuntimeError, ): ProviderAdapterError { + if (error._tag === "ProviderSessionFenceError") return error; if (isCodexAppServerProcessExitedError(error) || isCodexAppServerTransportError(error)) { return new ProviderAdapterSessionClosedError({ provider: PROVIDER, @@ -2529,6 +2530,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( : undefined; return yield* session.runtime .sendTurn({ + ...(input.expectedSession ? { requireIdle: true } : {}), ...(input.input !== undefined ? { input: input.input } : {}), ...(input.modelSelection?.instanceId === boundInstanceId ? { model: input.modelSelection.model } @@ -2556,9 +2558,9 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( return session; }); - const interruptTurn: CodexAdapterShape["interruptTurn"] = (threadId, turnId) => + const interruptTurn: CodexAdapterShape["interruptTurn"] = (threadId, turnId, options) => requireSession(threadId).pipe( - Effect.flatMap((session) => session.runtime.interruptTurn(turnId)), + Effect.flatMap((session) => session.runtime.interruptTurn(turnId, options?.preserveSession)), Effect.mapError((cause) => cause._tag === "ProviderAdapterSessionNotFoundError" ? cause diff --git a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts index 02b7a45f33ad..78d51f6b3a7c 100644 --- a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts +++ b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts @@ -14,7 +14,12 @@ import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; -import { type ProviderApprovalDecision, type ProviderEvent, ThreadId } from "@t3tools/contracts"; +import { + type ProviderApprovalDecision, + type ProviderEvent, + ThreadId, + TurnId, +} from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -579,6 +584,18 @@ describe("CodexSessionRuntime collab integration", () => { ); assert.isTrue(childBStarted._tag === "Some", "child B turnStarted never arrived"); + // A selected-turn interrupt must leave the fleet alone. + const selectedTurn = (yield* runtime.getSession).activeTurnId!; + yield* runtime.interruptTurn(selectedTurn, true); + assert.deepEqual( + NodeFS.readFileSync(interruptsPath, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)), + [{ threadId: ROOT, turnId: selectedTurn }], + ); + NodeFS.rmSync(interruptsPath); + // Stop everything. A's interrupt hangs forever — the bounded child // deadline must expire and the parent interrupt must still be sent. yield* runtime.interruptTurn(); @@ -654,6 +671,86 @@ describe("CodexSessionRuntime collab integration", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + for (const queued of [false, true]) { + it.live( + `rejects conditional admission after ${queued ? "an accepted queued turn" : "background work during MCP preparation"}`, + () => + Effect.gen(function* () { + const first = TurnId.make("first-turn"); + const second = TurnId.make("second-turn"); + const completed = { + method: "turn/completed", + params: { + threadId: ROOT, + turn: { ...wireFixture.responses.turnStart.turn, id: first, status: "completed" }, + }, + }; + const started = { + method: "turn/started", + params: { + threadId: ROOT, + turn: { ...wireFixture.responses.turnStart.turn, id: second }, + }, + }; + const script = { + rootThreadId: ROOT, + holdTurnOpen: true, + onlyFirstTurnStarts: true, + recordTurnStarts: true, + turnIds: [first, second], + notifications: [], + ...(queued + ? { notificationsByTurn: [[], [completed]] } + : { reloadAfterTurns: 0, reloadNotifications: [started] }), + }; + NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8"); + NodeFS.rmSync(`${scriptPath}.requests`, { force: true }); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + NodeFS.rmSync(scriptPath, { force: true }); + NodeFS.rmSync(`${scriptPath}.requests`, { force: true }); + }), + ); + const runtime = yield* makeCodexSessionRuntime({ + threadId: ThreadId.make("conditional-codex-admission"), + binaryPath: peerPath, + cwd: NodeOS.tmpdir(), + runtimeMode: "full-access", + appServerArgs: ["-c", 'mcp_servers.test.url="http://127.0.0.1:1"'], + environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, + }); + const completion = yield* Deferred.make(); + yield* runtime.events.pipe( + Stream.runForEach((event) => + event.method === "turn/completed" + ? Deferred.succeed(completion, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + Effect.forkScoped, + ); + yield* runtime.start(); + if (queued) { + yield* runtime.sendTurn({ input: "first" }); + yield* runtime.sendTurn({ input: "queued" }); + yield* Deferred.await(completion); + assert.equal((yield* runtime.getSession).status, "ready"); + } + const error = yield* runtime + .sendTurn({ input: "conditional", requireIdle: true }) + .pipe(Effect.flip); + assert.equal(error._tag, "ProviderSessionFenceError"); + const requests = NodeFS.existsSync(`${scriptPath}.requests`) + ? readRecordedRequests() + : []; + assert.equal( + requests.filter((request) => request.method === "turn/start").length, + queued ? 2 : 0, + ); + yield* runtime.close; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + } + const elicitationCases = [ { decision: "accept", diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index a04db1405912..1f80551482fc 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -1,3 +1,4 @@ +import { ProviderSessionFenceError } from "../Errors.ts"; import { ApprovalRequestId, DEFAULT_MODEL, @@ -184,6 +185,7 @@ export interface CodexSessionRuntimeOptions { } export interface CodexSessionRuntimeSendTurnInput { + readonly requireIdle?: boolean; readonly input?: string; readonly attachments?: ReadonlyArray<{ readonly type: "image"; @@ -212,7 +214,10 @@ export interface CodexSessionRuntimeShape { input: CodexSessionRuntimeSendTurnInput, ) => Effect.Effect; readonly compactThread: Effect.Effect; - readonly interruptTurn: (turnId?: TurnId) => Effect.Effect; + readonly interruptTurn: ( + turnId?: TurnId, + selectedTurnOnly?: boolean, + ) => Effect.Effect; readonly readThread: Effect.Effect; readonly rollbackThread: ( numTurns: number, @@ -233,6 +238,7 @@ export interface CodexSessionRuntimeShape { } export type CodexSessionRuntimeError = + | ProviderSessionFenceError | CodexErrors.CodexAppServerError | CodexSessionRuntimePendingApprovalNotFoundError | CodexSessionRuntimePendingUserInputNotFoundError @@ -1375,6 +1381,10 @@ export const makeCodexSessionRuntime = ( updatedAt: sessionCreatedAt, } satisfies ProviderSession; const sessionRef = yield* Ref.make(initialSession); + // Accepted follow-ups remain busy between completion and their start notification. + const outstandingTurns = new Set(); + const completedBeforeAcknowledgment = new Set(); + let pendingAcknowledgments = 0; const offerEvent = (event: ProviderEvent) => Queue.offer(events, event).pipe(Effect.asVoid); const emitEvent = (event: Omit) => @@ -2019,6 +2029,8 @@ export const makeCodexSessionRuntime = ( if (providerThreadId && payload.threadId !== providerThreadId) { return Effect.void; } + outstandingTurns.delete(payload.turn.id); + if (pendingAcknowledgments > 0) completedBeforeAcknowledgment.add(payload.turn.id); const lastError = payload.turn.status === "failed" && "error" in payload.turn && payload.turn.error ? payload.turn.error.message @@ -2432,66 +2444,97 @@ export const makeCodexSessionRuntime = ( yield* client.request("thread/compact/start", { threadId: providerThreadId }); }), sendTurn: (input) => - Effect.gen(function* () { - const providerThreadId = yield* readProviderThreadId; - if (hasConfiguredMcpServer(options.appServerArgs)) { - yield* client.request("config/mcpServer/reload", undefined).pipe( - Effect.catch((cause) => - Effect.logWarning("Failed to refresh Codex MCP tool catalog before turn.", { - cause, - }), + Effect.suspend(() => { + let admitted = false; + return Effect.gen(function* () { + const providerThreadId = yield* readProviderThreadId; + if (hasConfiguredMcpServer(options.appServerArgs)) { + yield* client.request("config/mcpServer/reload", undefined).pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to refresh Codex MCP tool catalog before turn.", { + cause, + }), + ), + ); + } + const normalizedModel = normalizeCodexModelSlug( + input.model ?? (yield* Ref.get(sessionRef)).model, + ); + const params = yield* buildTurnStartParams({ + threadId: providerThreadId, + runtimeMode: options.runtimeMode, + ...(input.input ? { prompt: input.input } : {}), + ...(input.attachments ? { attachments: input.attachments } : {}), + ...(normalizedModel ? { model: normalizedModel } : {}), + ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), + ...(input.effort ? { effort: input.effort } : {}), + ...(input.interactionMode ? { interactionMode: input.interactionMode } : {}), + // Derived from the session's own credential rather than the + // setting, so the prompt describes the tools this turn actually + // has even if the setting changed after the session started. + browserToolsAvailable: configuredMcpToolAvailability( + options.appServerArgs, + options.mcpCapabilities, ), + }); + const rawResponse = yield* Effect.suspend( + (): Effect.Effect => { + const session = Ref.getUnsafe(sessionRef); + if ( + input.requireIdle && + (session.status !== "ready" || + session.activeTurnId !== undefined || + outstandingTurns.size > 0 || + pendingAcknowledgments > 0) + ) { + return new ProviderSessionFenceError({ + threadId: options.threadId, + detail: "The selected session is no longer idle at turn admission.", + }); + } + pendingAcknowledgments++; + admitted = true; + return client.raw.request("turn/start", params); + }, ); - } - const normalizedModel = normalizeCodexModelSlug( - input.model ?? (yield* Ref.get(sessionRef)).model, - ); - const params = yield* buildTurnStartParams({ - threadId: providerThreadId, - runtimeMode: options.runtimeMode, - ...(input.input ? { prompt: input.input } : {}), - ...(input.attachments ? { attachments: input.attachments } : {}), - ...(normalizedModel ? { model: normalizedModel } : {}), - ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), - ...(input.effort ? { effort: input.effort } : {}), - ...(input.interactionMode ? { interactionMode: input.interactionMode } : {}), - // Derived from the session's own credential rather than the - // setting, so the prompt describes the tools this turn actually - // has even if the setting changed after the session started. - browserToolsAvailable: configuredMcpToolAvailability( - options.appServerArgs, - options.mcpCapabilities, - ), - }); - const rawResponse = yield* client.raw.request("turn/start", params); - const response = yield* decodeV2TurnStartResponse(rawResponse).pipe( - Effect.mapError((error) => - CodexErrors.CodexAppServerProtocolParseError.fromSchemaError( - "decode-response-payload", - error, - { method: "turn/start" }, + const response = yield* decodeV2TurnStartResponse(rawResponse).pipe( + Effect.mapError((error) => + CodexErrors.CodexAppServerProtocolParseError.fromSchemaError( + "decode-response-payload", + error, + { method: "turn/start" }, + ), ), + ); + const turnId = TurnId.make(response.turn.id); + if (!completedBeforeAcknowledgment.has(turnId)) outstandingTurns.add(turnId); + yield* updateSession(sessionRef, (session) => ({ + status: "running", + // Codex accepts follow-ups while the current turn is still + // running. The response contains the queued turn id, but + // turn/interrupt only accepts the id that is active now. + activeTurnId: session.activeTurnId ?? turnId, + ...(normalizedModel ? { model: normalizedModel } : {}), + })); + const resumedProviderThreadId = currentProviderThreadId(yield* Ref.get(sessionRef)); + return { + threadId: options.threadId, + turnId, + ...(resumedProviderThreadId + ? { resumeCursor: { threadId: resumedProviderThreadId } } + : {}), + } satisfies ProviderTurnStartResult; + }).pipe( + Effect.ensuring( + Effect.sync(() => { + // A rejected precheck never opened an acknowledgment. + if (admitted) pendingAcknowledgments--; + if (pendingAcknowledgments === 0) completedBeforeAcknowledgment.clear(); + }), ), ); - const turnId = TurnId.make(response.turn.id); - yield* updateSession(sessionRef, (session) => ({ - status: "running", - // Codex accepts follow-ups while the current turn is still - // running. The response contains the queued turn id, but - // turn/interrupt only accepts the id that is active now. - activeTurnId: session.activeTurnId ?? turnId, - ...(normalizedModel ? { model: normalizedModel } : {}), - })); - const resumedProviderThreadId = currentProviderThreadId(yield* Ref.get(sessionRef)); - return { - threadId: options.threadId, - turnId, - ...(resumedProviderThreadId - ? { resumeCursor: { threadId: resumedProviderThreadId } } - : {}), - } satisfies ProviderTurnStartResult; }), - interruptTurn: (turnId) => + interruptTurn: (turnId, selectedTurnOnly) => Effect.gen(function* () { const providerThreadId = yield* readProviderThreadId; const session = yield* Ref.get(sessionRef); @@ -2504,17 +2547,18 @@ export const makeCodexSessionRuntime = ( // (review finding). Per-child and overall deadlines guarantee the // parent interrupt below always runs. const liveChildTurns = yield* Ref.get(collabChildLiveTurnsRef); - yield* Effect.forEach( - Array.from(liveChildTurns.entries()), - ([childThreadId, childTurnId]) => - client - .request("turn/interrupt", { - threadId: childThreadId, - turnId: childTurnId, - }) - .pipe(Effect.timeoutOption("3 seconds"), Effect.ignore), - { concurrency: 8, discard: true }, - ).pipe(Effect.timeoutOption("10 seconds"), Effect.ignore); + if (!selectedTurnOnly) + yield* Effect.forEach( + Array.from(liveChildTurns.entries()), + ([childThreadId, childTurnId]) => + client + .request("turn/interrupt", { + threadId: childThreadId, + turnId: childTurnId, + }) + .pipe(Effect.timeoutOption("3 seconds"), Effect.ignore), + { concurrency: 8, discard: true }, + ).pipe(Effect.timeoutOption("10 seconds"), Effect.ignore); const effectiveTurnId = turnId ?? session.activeTurnId; if (!effectiveTurnId) { return; diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index aa710f429af4..f6de91cdaafe 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -1223,6 +1223,77 @@ unsupportedRollback.layer("ProviderServiceLive unsupported rewind", (it) => { const fenced = makeProviderServiceLayer(); fenced.layer("ProviderService session fences", (it) => { + for (const operation of ["send", "compaction"] as const) { + it.effect(`Stop cancels a stalled ${operation} acknowledgment and releases replacement`, () => + Effect.gen(function* () { + const service = yield* ProviderService.ProviderService; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const threadId = asThreadId(`stop-stalled-${operation}`); + const start = { + threadId, + providerInstanceId: codexInstanceId, + runtimeMode: "full-access" as const, + }; + const session = yield* service.startSession(threadId, start); + const entered = yield* Deferred.make(); + const stalled = Deferred.succeed(entered, undefined).pipe(Effect.andThen(Effect.never)); + if (operation === "send") fenced.codex.sendTurn.mockImplementationOnce(() => stalled); + else fenced.codex.compactThread.mockImplementationOnce(() => stalled); + const admission = yield* ( + operation === "send" + ? service + .sendTurn({ + threadId, + input: "Work", + expectedSession: { + providerInstanceId: codexInstanceId, + providerSessionId: session.providerSessionId!, + activeTurnId: null, + readiness: "ready", + }, + }) + .pipe(Effect.asVoid) + : service.compactThread(threadId) + ).pipe(Effect.result, Effect.forkChild); + yield* Deferred.await(entered); + yield* service.stopSession({ threadId }); + assert.equal((yield* Fiber.join(admission))._tag, "Failure"); + const binding = Option.getOrThrow(yield* directory.getBinding(threadId)); + assert.equal(binding.status, "stopped"); + const replacement = yield* service.startSession(threadId, start); + assert.notEqual(replacement.providerSessionId, session.providerSessionId); + yield* service.stopSession({ threadId }); + }), + ); + } + + it.effect("rejects a conditional stop for an exited session without changing its binding", () => + Effect.gen(function* () { + const service = yield* ProviderService.ProviderService; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const threadId = asThreadId("conditional-stop-exited"); + const session = yield* service.startSession(threadId, { + threadId, + providerInstanceId: codexInstanceId, + runtimeMode: "full-access", + }); + yield* fenced.codex.stopSession(threadId); + const before = yield* directory.getBinding(threadId); + const result = yield* service + .stopSession({ + threadId, + expectedSession: { + providerInstanceId: codexInstanceId, + providerSessionId: session.providerSessionId!, + activeTurnId: null, + readiness: "ready", + }, + }) + .pipe(Effect.flip); + assert.equal(result._tag, "ProviderSessionFenceError"); + assert.deepEqual(yield* directory.getBinding(threadId), before); + }), + ); for (const operation of ["compaction", "feedback"] as const) { it.effect(`${operation} recovery waits for an admitted conditional send`, () => Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 901eae89172a..eb1f89cd826c 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -870,6 +870,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( // Stop must still work while a provider is acknowledging a send. Its escape path // captures that adapter and cannot recover or rebind to a different session. const pendingSendInterrupts = new Map>(); + const pendingSendStops = new Map>(); const withThreadCommand = (threadId: ThreadId, effect: Effect.Effect) => Effect.suspend(() => { const entry = commandLocks.get(threadId) ?? { semaphore: Semaphore.makeUnsafe(1), users: 0 }; @@ -1796,6 +1797,18 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( `Provider '${routed.adapter.provider}' requires an explicit continuation prompt`, ); } + if ( + input.expectedSession && + (input.expectedSession.readiness !== "ready" || + input.expectedSession.activeTurnId !== null || + pendingCompactions.has(input.threadId) || + timedOutNativeCompactions.has(input.threadId)) + ) { + return yield* new ProviderSessionFenceError({ + threadId: input.threadId, + detail: "A conditional send requires an idle provider session.", + }); + } const supportsConditionalSend = routed.adapter.provider === "codex" || routed.adapter.provider === "claudeAgent"; if (supportsConditionalSend && !locked) @@ -1815,18 +1828,6 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( allowRecovery: true, }); } - if ( - input.expectedSession && - (input.expectedSession.readiness !== "ready" || - input.expectedSession.activeTurnId !== null || - pendingCompactions.has(input.threadId) || - timedOutNativeCompactions.has(input.threadId)) - ) { - return yield* new ProviderSessionFenceError({ - threadId: input.threadId, - detail: "A conditional send requires an idle provider session.", - }); - } metricProvider = routed.adapter.provider; metricModel = input.modelSelection?.model; yield* Effect.annotateCurrentSpan({ @@ -1845,36 +1846,6 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( routed.instanceId, input.expectedSession, ); - let interruptCount = 0; - let interruptsDone = Deferred.makeUnsafe(); - if (supportsConditionalSend) { - const interruptPending: ProviderServiceMethod<"interruptTurn"> = (rawInterrupt) => - Effect.suspend(() => { - if (pendingSendInterrupts.get(input.threadId) !== interruptPending) - return withThreadCommand(input.threadId, interruptTurn(rawInterrupt)); - if (interruptCount++ === 0) interruptsDone = Deferred.makeUnsafe(); - return Effect.gen(function* () { - const interrupt = yield* decodeInputOrValidationError({ - operation: "ProviderService.interruptTurn", - schema: ProviderInterruptTurnInput, - payload: rawInterrupt, - }); - yield* routed.adapter.interruptTurn(routed.threadId, interrupt.turnId); - yield* analytics.record("provider.turn.interrupted", { - provider: routed.adapter.provider, - }); - }).pipe( - Effect.ensuring( - Effect.suspend(() => - --interruptCount === 0 - ? Deferred.succeed(interruptsDone, undefined).pipe(Effect.asVoid) - : Effect.void, - ), - ), - ); - }); - pendingSendInterrupts.set(input.threadId, interruptPending); - } const analyticsModelSelection = input.modelSelection?.instanceId === routed.instanceId ? input.modelSelection : undefined; const turn = yield* Effect.acquireUseRelease( @@ -1888,14 +1859,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }), (turnMetadata) => Effect.gen(function* () { - const turn = yield* routed.adapter.sendTurn(input).pipe( - Effect.ensuring( - Effect.suspend(() => { - pendingSendInterrupts.delete(input.threadId); - return interruptCount === 0 ? Effect.void : Deferred.await(interruptsDone); - }), - ), - ); + const turn = yield* supportsConditionalSend + ? withPendingAdmission(routed, "turn/start", routed.adapter.sendTurn(input)) + : routed.adapter.sendTurn(input); yield* associateTurnAnalytics({ providerInstanceId: routed.instanceId, threadId: input.threadId, @@ -1957,6 +1923,12 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const compactThread: ProviderServiceMethod<"compactThread"> = Effect.fn("compactThread")( function* (threadId, modelSelection, requestId) { + if (pendingCompactions.has(threadId)) { + return yield* toValidationError( + "ProviderService.compactThread", + "Context compaction is already in progress.", + ); + } // Recovery and admission share the session fence lock. Completion waits // outside it so Stop and fallback sends can still reach the provider. const { routed, compaction, pending } = yield* withThreadCommand( @@ -2019,8 +1991,15 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( pendingCompactions.delete(threadId); } }); - const awaitNativeCompaction = (start: Effect.Effect) => - start.pipe( + const awaitNativeCompaction = (start: () => Effect.Effect) => + withThreadCommand( + threadId, + Effect.suspend(() => + pendingCompactions.get(threadId) === pending + ? withPendingAdmission(routed, "thread/compact", start()) + : Effect.void, + ), + ).pipe( Effect.andThen(Deferred.await(completion)), Effect.timeout(COMPACTION_COMPLETION_TIMEOUT), Effect.catchTag("TimeoutError", (cause) => @@ -2054,7 +2033,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ); const terminal = yield* ( compaction.type === "native" - ? awaitNativeCompaction(compaction.start(routed.threadId, modelSelection)) + ? awaitNativeCompaction(() => compaction.start(routed.threadId, modelSelection)) : Effect.gen(function* () { const turn = yield* sendTurn({ threadId, @@ -2126,11 +2105,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( routed.instanceId, input.expectedSession, ); - if ( - input.expectedSession && - routed.adapter.provider !== "codex" && - routed.adapter.provider !== "claudeAgent" - ) + if (input.expectedSession && routed.adapter.provider !== "codex") return yield* new ProviderSessionFenceError({ threadId: input.threadId, detail: "This provider does not yet support conditional interruption.", @@ -2233,8 +2208,11 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ); }); - const stopSession: ProviderServiceMethod<"stopSession"> = Effect.fn("stopSession")( - function* (rawInput) { + const stopSession: ( + input: Parameters>[0], + admittedRoute?: Effect.Success>, + ) => ReturnType> = Effect.fn("stopSession")( + function* (rawInput, admittedRoute) { const input = yield* decodeInputOrValidationError({ operation: "ProviderService.stopSession", schema: ProviderStopSessionInput, @@ -2242,24 +2220,26 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }); let metricProvider = "unknown"; return yield* Effect.gen(function* () { - const routed = yield* resolveRoutableSession({ - threadId: input.threadId, - operation: "ProviderService.stopSession", - allowRecovery: false, - }); + const routed = + admittedRoute ?? + (yield* resolveRoutableSession({ + threadId: input.threadId, + operation: "ProviderService.stopSession", + allowRecovery: false, + })); metricProvider = routed.adapter.provider; yield* Effect.annotateCurrentSpan({ "provider.operation": "stop-session", "provider.kind": routed.adapter.provider, "provider.thread_id": input.threadId, }); + yield* checkSessionFence( + input.threadId, + routed.adapter, + routed.instanceId, + input.expectedSession, + ); if (routed.isActive) { - yield* checkSessionFence( - input.threadId, - routed.adapter, - routed.instanceId, - input.expectedSession, - ); const session = (yield* routed.adapter.listSessions()).find( (session) => session.threadId === routed.threadId, ); @@ -2305,6 +2285,84 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }, ); + // The caller holds the thread lock. Stop and interrupt can reach the captured + // adapter while an acknowledgment is pending; replacement waits for both. + const withPendingAdmission = ( + routed: Effect.Success>, + method: "turn/start" | "thread/compact", + admission: Effect.Effect, + ) => + Effect.gen(function* () { + const threadId = routed.threadId; + const stopped = Deferred.makeUnsafe(); + let wasStopped = false; + let pendingActions = 0; + let actionsDone = Deferred.makeUnsafe(); + const tracked = (action: Effect.Effect) => + Effect.suspend(() => { + if (pendingActions++ === 0) actionsDone = Deferred.makeUnsafe(); + return action.pipe( + Effect.ensuring( + Effect.suspend(() => + --pendingActions === 0 + ? Deferred.succeed(actionsDone, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ); + }); + const interruptPending: ProviderServiceMethod<"interruptTurn"> = (rawInput) => + Effect.suspend(() => { + if (pendingSendInterrupts.get(threadId) !== interruptPending) + return withThreadCommand(threadId, interruptTurn(rawInput)); + return tracked( + Effect.gen(function* () { + const input = yield* decodeInputOrValidationError({ + operation: "ProviderService.interruptTurn", + schema: ProviderInterruptTurnInput, + payload: rawInput, + }); + yield* routed.adapter.interruptTurn(threadId, input.turnId); + yield* analytics.record("provider.turn.interrupted", { + provider: routed.adapter.provider, + }); + }), + ); + }); + const stopPending: ProviderServiceMethod<"stopSession"> = (input) => + Effect.suspend(() => { + if (pendingSendStops.get(threadId) !== stopPending) + return withThreadCommand(threadId, stopSession(input)); + return tracked( + stopSession(input, routed).pipe( + Effect.tap(() => { + wasStopped = true; + return Deferred.succeed(stopped, undefined); + }), + ), + ); + }); + pendingSendInterrupts.set(threadId, interruptPending); + pendingSendStops.set(threadId, stopPending); + const stoppedError = new ProviderAdapterRequestError({ + provider: routed.adapter.provider, + method, + detail: "The provider session was stopped before acknowledgment completed.", + }); + const result = yield* admission.pipe( + Effect.raceFirst(Deferred.await(stopped).pipe(Effect.andThen(Effect.fail(stoppedError)))), + Effect.ensuring( + Effect.suspend(() => { + pendingSendInterrupts.delete(threadId); + pendingSendStops.delete(threadId); + return pendingActions === 0 ? Effect.void : Deferred.await(actionsDone); + }), + ), + ); + if (wasStopped) return yield* stoppedError; + return result; + }); + const listSessions: ProviderServiceMethod<"listSessions"> = Effect.fn("listSessions")( function* () { const currentAdapters = yield* getAdapterEntries; @@ -2632,7 +2690,12 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }), respondToRequest: (input) => withThreadCommand(input.threadId, respondToRequest(input)), respondToUserInput: (input) => withThreadCommand(input.threadId, respondToUserInput(input)), - stopSession: (input) => withThreadCommand(input.threadId, stopSession(input)), + stopSession: (input) => + Effect.suspend(() => { + const pending = + input.expectedSession === undefined ? pendingSendStops.get(input.threadId) : undefined; + return pending ? pending(input) : withThreadCommand(input.threadId, stopSession(input)); + }), compactThread, assertConversationRollbackSupported, listSessions, diff --git a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs index fa567d75cf8a..ae2975071d21 100644 --- a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs +++ b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs @@ -118,6 +118,11 @@ rl.on("line", (line) => { return; } if (method === "turn/start") { + if (script.recordTurnStarts) + NodeFS.appendFileSync( + `${process.env.T3_CODEX_COLLAB_SCRIPT}.requests`, + `${JSON.stringify({ method, params: message.params })}\n`, + ); const turnId = script.turnIds?.[turnStartCount]; const turn = turnId ? { ...fixture.responses.turnStart.turn, id: turnId } @@ -133,7 +138,8 @@ rl.on("line", (line) => { params: { threadId: rootThreadId, turn }, }); } - for (const notification of script.notifications) { + for (const notification of script.notificationsByTurn?.[turnStartCount - 1] ?? + script.notifications) { write({ jsonrpc: "2.0", method: notification.method, params: notification.params }); } for (const request of script.serverRequests ?? []) { @@ -151,6 +157,16 @@ rl.on("line", (line) => { } return; } + if ( + method === "config/mcpServer/reload" && + script.reloadNotifications && + turnStartCount === script.reloadAfterTurns + ) { + for (const notification of script.reloadNotifications) + write({ jsonrpc: "2.0", ...notification }); + setTimeout(() => write({ id, result: {} }), 20); + return; + } if (method === "turn/interrupt") { // Record which thread/turn was interrupted (append-only sidecar file the // test reads) so Stop coverage can assert every live child was reached. From 4ab11d9561bd3fe55a8b9c288960cc246a4cb222 Mon Sep 17 00:00:00 2001 From: Lior Yasur Date: Wed, 16 Sep 2026 01:56:17 +0300 Subject: [PATCH 3/4] fix(server): retain runtime fence identity and allow idle Claude admission --- .../Layers/ProviderCommandReactor.test.ts | 2 +- .../Layers/ProviderRuntimeIngestion.test.ts | 17 ++++++++++++ .../Layers/ProviderRuntimeIngestion.ts | 3 +++ .../src/provider/Layers/ClaudeAdapter.test.ts | 27 +++++++++++++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 2 +- .../CodexCollabRuntime.integration.test.ts | 1 + 6 files changed, 50 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index e0c4008d77bb..e4534dd17efd 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -274,7 +274,7 @@ describe("ProviderCommandReactor", () => { ), ); }); - const sendTurn = vi.fn((_: unknown) => + const sendTurn = vi.fn((_: unknown): ReturnType => Effect.succeed({ threadId: ThreadId.make("thread-1"), turnId: asTurnId("turn-1"), diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 062a91f8cb53..393be5c20450 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -3564,6 +3564,22 @@ describe("ProviderRuntimeIngestion", () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; + await harness.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("selected-error-session"), + threadId: asThreadId("thread-1"), + session: { + threadId: asThreadId("thread-1"), + providerName: "codex", + providerSessionId: "selected-generation", + status: "ready", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }); harness.emit({ type: "runtime.error", eventId: asEventId("evt-runtime-error"), @@ -3583,6 +3599,7 @@ describe("ProviderRuntimeIngestion", () => { entry.session?.activeTurnId === "turn-3" && entry.session?.lastError === "runtime exploded", ); + expect(thread.session).toMatchObject({ providerSessionId: "selected-generation" }); expect(thread.session?.status).toBe("error"); expect(thread.session?.lastError).toBe("runtime exploded"); }); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 100a14b81a09..622341d696a8 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -2053,6 +2053,9 @@ const make = Effect.gen(function* () { session: { threadId: thread.id, status: "error", + ...(thread.session?.providerSessionId + ? { providerSessionId: thread.session.providerSessionId } + : {}), providerName: event.provider, ...(event.providerInstanceId !== undefined ? { providerInstanceId: event.providerInstanceId } diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 747c0669897c..8320f578d988 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -2263,6 +2263,33 @@ describe("ClaudeAdapterLive", () => { }, ); + it.effect("admits a conditional prompt into an idle Claude session", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + const result = yield* adapter.sendTurn({ + threadId: THREAD_ID, + input: "Selected idle session", + expectedSession: { + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + providerSessionId: "selected", + readiness: "ready", + activeTurnId: null, + }, + }); + const text = yield* Effect.promise(() => + readFirstPromptText(harness.getLastCreateQueryInput()), + ); + assert.include(text, "Selected idle session"); + assert.equal((yield* adapter.listSessions())[0]?.activeTurnId, result.turnId); + }).pipe(Effect.provide(harness.layer)); + }); + it.effect( "rejects conditional admission when background work starts during permission preparation", () => { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index b37bd961b366..7251c961a3a0 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -5034,7 +5034,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( input.expectedSession && (context.stopped || context.session.status !== "ready" || - context.turnState !== null || + context.turnState !== undefined || context.session.activeTurnId !== undefined) ) { return new ProviderSessionFenceError({ diff --git a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts index 78d51f6b3a7c..14522d206bc7 100644 --- a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts +++ b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts @@ -703,6 +703,7 @@ describe("CodexSessionRuntime collab integration", () => { ? { notificationsByTurn: [[], [completed]] } : { reloadAfterTurns: 0, reloadNotifications: [started] }), }; + // @effect-diagnostics-next-line preferSchemaOverJson:off NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8"); NodeFS.rmSync(`${scriptPath}.requests`, { force: true }); yield* Effect.addFinalizer(() => From a5fdcc9c3fc9674c625751ebddefd4168f3a7d7b Mon Sep 17 00:00:00 2001 From: Lior Yasur Date: Wed, 16 Sep 2026 02:12:27 +0300 Subject: [PATCH 4/4] fix(server): retain session identity during ordinary turn preparation --- .../Layers/ProviderCommandReactor.test.ts | 11 ++++++++++- .../orchestration/Layers/ProviderCommandReactor.ts | 3 +++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index e4534dd17efd..8d536b6a45ee 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -3369,7 +3369,10 @@ describe("ProviderCommandReactor", () => { }); it("reuses the same provider session when runtime mode is unchanged", async () => { - const harness = await createHarness(); + const harness = await createHarness({ + startSessionEffect: (session) => + Effect.succeed({ ...session, providerSessionId: "reused-generation" }), + }); const now = "2026-01-01T00:00:00.000Z"; await Effect.runPromise( @@ -3391,6 +3394,9 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.startSession.mock.calls.length === 1); await waitFor(() => harness.sendTurn.mock.calls.length === 1); + expect((await harness.readModel()).threads[0]?.session?.providerSessionId).toBe( + "reused-generation", + ); await Effect.runPromise( harness.engine.dispatch({ @@ -3412,6 +3418,9 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.sendTurn.mock.calls.length === 2); expect(harness.startSession.mock.calls.length).toBe(1); expect(harness.stopSession.mock.calls.length).toBe(0); + expect((await harness.readModel()).threads[0]?.session?.providerSessionId).toBe( + "reused-generation", + ); }); it("restarts an existing Codex thread on a compatible requested instance", async () => { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 2119ccfc20c9..bb7b09cd461e 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -752,6 +752,9 @@ const make = Effect.gen(function* () { status: "starting", providerName: activeSession?.provider ?? preferredProvider, providerInstanceId: activeSession?.providerInstanceId ?? desiredInstanceId, + ...(thread.session?.providerSessionId + ? { providerSessionId: thread.session.providerSessionId } + : {}), runtimeMode: desiredRuntimeMode, activeTurnId: null, lastError: null,