From 8bd598cda57c7ace739e7376c345f60db2d868f6 Mon Sep 17 00:00:00 2001 From: Raul Date: Wed, 26 Aug 2026 15:07:17 +0200 Subject: [PATCH 01/10] feat(codex): continue provider threads across clients --- apps/server/src/bin.test.ts | 7 + .../Layers/ProjectionPipeline.import.test.ts | 157 ++++ .../Layers/ProjectionPipeline.ts | 52 +- .../src/orchestration/decider.import.test.ts | 116 +++ apps/server/src/orchestration/decider.ts | 28 + apps/server/src/orchestration/http.ts | 19 +- .../src/provider/Layers/CodexAdapter.ts | 51 ++ .../Layers/CodexSessionRuntime.test.ts | 6 +- .../Layers/CodexThreadDiscovery.test.ts | 244 ++++++ .../provider/Layers/CodexThreadDiscovery.ts | 260 ++++++ .../provider/Layers/ProviderService.test.ts | 44 +- .../src/provider/Layers/ProviderService.ts | 9 +- .../Layers/ProviderThreadReconciler.test.ts | 786 ++++++++++++++++++ .../Layers/ProviderThreadReconciler.ts | 751 +++++++++++++++++ .../src/provider/Services/ProviderAdapter.ts | 44 + .../Services/ProviderThreadContinuity.ts | 29 + apps/server/src/server.test.ts | 168 +++- apps/server/src/server.ts | 2 + apps/server/src/ws.ts | 28 +- docs/internals/overview.md | 10 +- docs/internals/providers.md | 27 + docs/user/providers-codex.md | 13 + packages/contracts/src/orchestration.test.ts | 20 + packages/contracts/src/orchestration.ts | 16 + 24 files changed, 2875 insertions(+), 12 deletions(-) create mode 100644 apps/server/src/orchestration/Layers/ProjectionPipeline.import.test.ts create mode 100644 apps/server/src/orchestration/decider.import.test.ts create mode 100644 apps/server/src/provider/Layers/CodexThreadDiscovery.test.ts create mode 100644 apps/server/src/provider/Layers/CodexThreadDiscovery.ts create mode 100644 apps/server/src/provider/Layers/ProviderThreadReconciler.test.ts create mode 100644 apps/server/src/provider/Layers/ProviderThreadReconciler.ts create mode 100644 apps/server/src/provider/Services/ProviderThreadContinuity.ts diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 929080b38c4d..09d9c7c132ab 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -31,6 +31,7 @@ import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSna import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; import { orchestrationHttpApiLayer } from "./orchestration/http.ts"; +import { ProviderThreadContinuity } from "./provider/Services/ProviderThreadContinuity.ts"; import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; import { @@ -131,6 +132,12 @@ const withLiveProjectCliServer = (baseDir: string, run: () => Effect.Ef ), ), Layer.provideMerge(makeProjectPersistenceLayer(config)), + Layer.provide( + Layer.succeed( + ProviderThreadContinuity, + ProviderThreadContinuity.of({ reconcileThread: () => Effect.succeed(false) }), + ), + ), Layer.provideMerge( NodeHttpServer.layer(NodeHttp.createServer, { host: "127.0.0.1", diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.import.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.import.test.ts new file mode 100644 index 000000000000..b6deb442715b --- /dev/null +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.import.test.ts @@ -0,0 +1,157 @@ +import { + CommandId, + CorrelationId, + EventId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { ServerConfig } from "../../config.ts"; +import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; +import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; +import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; + +const TestLayer = OrchestrationProjectionPipelineLive.pipe( + Layer.provideMerge(OrchestrationEventStoreLive), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-projection-import-test-" }), + ), + Layer.provideMerge(SqlitePersistenceMemory), + Layer.provideMerge(NodeServices.layer), +); + +it.layer(TestLayer)("historical provider message projection", (it) => { + it.effect("projects an imported turn as completed without runtime or checkpoint state", () => + Effect.gen(function* () { + const pipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const projectId = ProjectId.make("project-import"); + const threadId = ThreadId.make("thread-import"); + const turnId = TurnId.make("provider-turn-import"); + const createdAt = "2026-08-26T10:00:00.000Z"; + + yield* eventStore.append({ + type: "project.created", + eventId: EventId.make("event-import-project"), + aggregateKind: "project", + aggregateId: projectId, + occurredAt: createdAt, + commandId: CommandId.make("command-import-project"), + causationEventId: null, + correlationId: CorrelationId.make("command-import-project"), + metadata: {}, + payload: { + projectId, + title: "Imported project", + workspaceRoot: "/tmp/imported-project", + defaultModelSelection: null, + scripts: [], + createdAt, + updatedAt: createdAt, + }, + }); + yield* eventStore.append({ + type: "thread.created", + eventId: EventId.make("event-import-thread"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: createdAt, + commandId: CommandId.make("command-import-thread"), + causationEventId: null, + correlationId: CorrelationId.make("command-import-thread"), + metadata: {}, + payload: { + threadId, + projectId, + title: "Imported Codex thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.6-sol", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }); + + for (const [ordinal, role, text] of [ + [1, "user", "External prompt"], + [2, "assistant", "External response"], + ] as const) { + const occurredAt = `2026-08-26T10:00:0${ordinal}.000Z`; + yield* eventStore.append({ + type: "thread.message-sent", + eventId: EventId.make(`event-import-message-${ordinal}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt, + commandId: CommandId.make(`provider-import:message-${ordinal}`), + causationEventId: null, + correlationId: CorrelationId.make(`provider-import:message-${ordinal}`), + metadata: {}, + payload: { + threadId, + messageId: MessageId.make(`message-import-${ordinal}`), + role, + text, + turnId, + streaming: false, + historical: true, + createdAt: occurredAt, + updatedAt: occurredAt, + }, + }); + } + + yield* pipeline.bootstrap; + + const turns = yield* sql<{ + readonly state: string; + readonly pendingMessageId: string | null; + readonly assistantMessageId: string | null; + }>` + SELECT + state, + pending_message_id AS "pendingMessageId", + assistant_message_id AS "assistantMessageId" + FROM projection_turns + WHERE thread_id = ${threadId} AND turn_id = ${turnId} + `; + assert.deepEqual(turns, [ + { + state: "completed", + pendingMessageId: null, + assistantMessageId: "message-import-2", + }, + ]); + + const threads = yield* sql<{ readonly latestTurnId: string | null }>` + SELECT latest_turn_id AS "latestTurnId" + FROM projection_threads + WHERE thread_id = ${threadId} + `; + assert.deepEqual(threads, [{ latestTurnId: turnId }]); + + const checkpoints = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM projection_turns + WHERE thread_id = ${threadId} AND checkpoint_turn_count IS NOT NULL + `; + assert.deepEqual(checkpoints, [{ count: 0 }]); + }), + ); +}); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 8eb8cdb561b8..7d20cd127ae4 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -133,7 +133,9 @@ function isStalePendingApprovalFailureDetail(detail: string | null): boolean { // A full refresh loads all thread history, so skip events that cannot change the summary. function shouldRefreshThreadShellSummary(event: OrchestrationEvent): boolean { if (event.type === "thread.message-sent") { - return event.payload.role === "user"; + // The turn projector refreshes historical messages after both message and + // turn rows exist. Refreshing here runs too early and clears latestTurnId. + return event.payload.historical !== true && event.payload.role === "user"; } if (event.type !== "thread.activity-appended") { @@ -890,6 +892,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti } yield* projectionThreadRepository.upsert({ ...existingRow.value, + latestTurnId: + event.type === "thread.message-sent" && + event.payload.historical === true && + event.payload.turnId !== null + ? event.payload.turnId + : existingRow.value.latestTurnId, updatedAt: event.occurredAt, }); if (shouldRefreshThreadShellSummary(event)) { @@ -1327,7 +1335,41 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti } case "thread.message-sent": { - if (event.payload.turnId === null || event.payload.role !== "assistant") { + if (event.payload.turnId === null) { + return; + } + if (event.payload.role === "user") { + if (event.payload.historical !== true) { + return; + } + const existingTurn = yield* projectionTurnRepository.getByTurnId({ + threadId: event.payload.threadId, + turnId: event.payload.turnId, + }); + if (Option.isNone(existingTurn)) { + yield* projectionTurnRepository.upsertByTurnId({ + turnId: event.payload.turnId, + threadId: event.payload.threadId, + // Imported user history is already settled; it is not a queued + // message waiting for ProviderCommandReactor to start. + pendingMessageId: null, + sourceProposedPlanThreadId: null, + sourceProposedPlanId: null, + assistantMessageId: null, + state: "completed", + requestedAt: event.payload.createdAt, + startedAt: event.payload.createdAt, + completedAt: event.payload.updatedAt, + checkpointTurnCount: null, + checkpointRef: null, + checkpointStatus: null, + checkpointFiles: [], + }); + } + yield* refreshThreadShellSummary(event.payload.threadId); + return; + } + if (event.payload.role !== "assistant") { return; } // A completed assistant message only settles the turn once the @@ -1364,6 +1406,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti startedAt: existingTurn.value.startedAt ?? event.payload.createdAt, requestedAt: existingTurn.value.requestedAt ?? event.payload.createdAt, }); + if (event.payload.historical === true) { + yield* refreshThreadShellSummary(event.payload.threadId); + } return; } yield* projectionTurnRepository.upsertByTurnId({ @@ -1382,6 +1427,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti checkpointStatus: null, checkpointFiles: [], }); + if (event.payload.historical === true) { + yield* refreshThreadShellSummary(event.payload.threadId); + } return; } diff --git a/apps/server/src/orchestration/decider.import.test.ts b/apps/server/src/orchestration/decider.import.test.ts new file mode 100644 index 000000000000..874ad0bfc4a2 --- /dev/null +++ b/apps/server/src/orchestration/decider.import.test.ts @@ -0,0 +1,116 @@ +import { + CommandId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, + TurnId, + type OrchestrationReadModel, +} 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-08-21T03:22:43.000Z"; + +const readModel: OrchestrationReadModel = { + snapshotSequence: 0, + projects: [], + threads: [ + { + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Imported Codex thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.6-sol", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, + pinnedAt: null, + pinOrderKey: null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }, + ], + updatedAt: NOW, +}; + +it.layer(NodeServices.layer)("provider history import decider", (it) => { + it.effect("projects an imported assistant message without requesting a provider turn", () => + Effect.gen(function* () { + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.message.import", + commandId: CommandId.make("provider-import:message-1"), + threadId: ThreadId.make("thread-1"), + messageId: MessageId.make("message-1"), + role: "assistant", + text: "Imported response", + turnId: TurnId.make("turn-1"), + createdAt: NOW, + }, + readModel, + }); + + const events = Array.isArray(result) ? result : [result]; + expect(events).toHaveLength(1); + expect(events[0]?.type).toBe("thread.message-sent"); + if (events[0]?.type === "thread.message-sent") { + expect(events[0].payload).toMatchObject({ + role: "assistant", + text: "Imported response", + turnId: "turn-1", + streaming: false, + historical: true, + }); + } + }), + ); + + it.effect("projects imported user history with its provider turn identity", () => + Effect.gen(function* () { + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.message.import", + commandId: CommandId.make("provider-import:user-message-1"), + threadId: ThreadId.make("thread-1"), + messageId: MessageId.make("user-message-1"), + role: "user", + text: "Imported prompt", + turnId: TurnId.make("turn-1"), + createdAt: NOW, + }, + readModel, + }); + + const events = Array.isArray(result) ? result : [result]; + expect(events[0]).toMatchObject({ + type: "thread.message-sent", + payload: { + role: "user", + text: "Imported prompt", + turnId: "turn-1", + streaming: false, + historical: true, + }, + }); + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index f3fdd462f437..101635d4ede8 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -1255,6 +1255,34 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.message.import": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.message-sent", + payload: { + threadId: command.threadId, + messageId: command.messageId, + role: command.role, + text: command.text, + turnId: command.turnId, + streaming: false, + historical: true, + createdAt: command.createdAt, + updatedAt: command.createdAt, + }, + }; + } + case "thread.message.assistant.complete": { yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/http.ts b/apps/server/src/orchestration/http.ts index f7147106c7a9..4552d64f4706 100644 --- a/apps/server/src/orchestration/http.ts +++ b/apps/server/src/orchestration/http.ts @@ -18,6 +18,7 @@ import { } from "../auth/http.ts"; import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; +import { ProviderThreadContinuity } from "../provider/Services/ProviderThreadContinuity.ts"; export const orchestrationHttpApiLayer = HttpApiBuilder.group( EnvironmentHttpApi, @@ -25,6 +26,7 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( Effect.fnUntraced(function* (handlers) { const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; const orchestrationEngine = yield* OrchestrationEngineService; + const providerThreadContinuity = yield* ProviderThreadContinuity; return handlers .handle( @@ -65,6 +67,14 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( Effect.fn("environment.orchestration.threadSnapshot")(function* (args) { yield* annotateEnvironmentRequest(args.endpoint.name); yield* requireEnvironmentScope(AuthOrchestrationReadScope); + yield* providerThreadContinuity.reconcileThread(args.params.threadId).pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to reconcile provider thread before HTTP snapshot", { + threadId: args.params.threadId, + detail: cause.message, + }), + ), + ); const snapshot = yield* projectionSnapshotQuery .getThreadDetailSnapshot( args.params.threadId, @@ -96,7 +106,14 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( const normalizedCommand = yield* normalizeDispatchCommand(args.payload).pipe( Effect.catch(() => failEnvironmentInvalidRequest("invalid_command")), ); - return yield* orchestrationEngine.dispatch(normalizedCommand).pipe( + const continuityPreflight = + normalizedCommand.type === "thread.turn.start" + ? providerThreadContinuity + .reconcileThread(normalizedCommand.threadId) + .pipe(Effect.asVoid) + : Effect.void; + return yield* continuityPreflight.pipe( + Effect.andThen(orchestrationEngine.dispatch(normalizedCommand)), Effect.tapError(() => cleanupFailedUploadedAttachments(args.payload, normalizedCommand), ), diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 0f7d999662e9..4b33c3918cda 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -65,6 +65,7 @@ import { } from "./CodexSessionRuntime.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; import { resolveCodexLaunchArgs } from "./codexLaunchArgs.ts"; +import { discoverCodexThreads, readCodexPersistedThread } from "./CodexThreadDiscovery.ts"; const isCodexAppServerProcessExitedError = Schema.is(CodexErrors.CodexAppServerProcessExitedError); const isCodexAppServerTransportError = Schema.is(CodexErrors.CodexAppServerTransportError); const isCodexSessionRuntimeThreadIdMissingError = Schema.is( @@ -1884,6 +1885,35 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( })), ); + let completedInitialPersistedDiscovery = false; + const discoverPersistedThreads: NonNullable = ( + input, + ) => + Effect.scoped( + discoverCodexThreads(codexConfig, options?.environment, { + excludeProviderThreadIds: input?.excludeProviderThreadIds ?? new Set(), + cursorByProviderThreadId: input?.cursorByProviderThreadId ?? new Map(), + stopAfterKnownPage: completedInitialPersistedDiscovery, + }).pipe( + Effect.tap(() => + Effect.sync(() => { + completedInitialPersistedDiscovery = true; + }), + ), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + Effect.timeout("2 minutes"), + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "thread/list", + detail: cause.message, + cause, + }), + ), + ), + ); + const rollbackThread: CodexAdapterShape["rollbackThread"] = (threadId, numTurns) => { if (!Number.isInteger(numTurns) || numTurns < 1) { return Effect.fail( @@ -1973,6 +2003,25 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( yield* stopSessionInternal(session); }); + const readPersistedThread: NonNullable = ( + providerThreadId, + ) => + Effect.scoped( + readCodexPersistedThread(codexConfig, options?.environment, providerThreadId).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + Effect.timeout("2 minutes"), + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "thread/read", + detail: cause.message, + cause, + }), + ), + ), + ); + const listSessions: CodexAdapterShape["listSessions"] = () => Effect.forEach( Array.from(sessions.values()).filter((session) => !session.stopped), @@ -2006,6 +2055,8 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( sendTurn, interruptTurn, readThread, + discoverPersistedThreads, + readPersistedThread, rollbackThread, uploadFeedback, respondToRequest, diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 51604abb23f9..fa06d6a27ed2 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -815,7 +815,7 @@ describe("openCodexThread", () => { }), ); - it.effect("propagates non-recoverable resume failures", () => + it.effect("does not fork a fresh thread when the durable thread is owned elsewhere", () => Effect.gen(function* () { const client = { request: ( @@ -826,7 +826,7 @@ describe("openCodexThread", () => { return Effect.fail( new CodexErrors.CodexAppServerRequestError({ code: -32603, - errorMessage: "timed out waiting for server", + errorMessage: "thread is currently owned by another Codex process", }), ); } @@ -847,7 +847,7 @@ describe("openCodexThread", () => { }).pipe(Effect.flip); NodeAssert.ok(isCodexAppServerRequestError(error)); - NodeAssert.equal(error.errorMessage, "timed out waiting for server"); + NodeAssert.equal(error.errorMessage, "thread is currently owned by another Codex process"); }), ); }); diff --git a/apps/server/src/provider/Layers/CodexThreadDiscovery.test.ts b/apps/server/src/provider/Layers/CodexThreadDiscovery.test.ts new file mode 100644 index 000000000000..0c59da7b83d8 --- /dev/null +++ b/apps/server/src/provider/Layers/CodexThreadDiscovery.test.ts @@ -0,0 +1,244 @@ +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import type * as EffectCodexSchema from "effect-codex-app-server/schema"; + +import { + listCodexThreadsForRead, + readCodexThreadSnapshots, + selectCodexThreadsForRead, + toPersistedThread, +} from "./CodexThreadDiscovery.ts"; + +type ReadThread = EffectCodexSchema.V2ThreadReadResponse["thread"]; +type ListedThread = EffectCodexSchema.V2ThreadListResponse["data"][number]; + +function makeReadThread(overrides: Record = {}): ReadThread { + return { + id: "provider-thread-1", + cwd: "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/work/project", + name: null, + preview: "Preview title", + createdAt: 1_777_777_700, + updatedAt: 1_777_777_760, + source: "cli", + status: { type: "idle" }, + threadSource: null, + cliVersion: "1.2.3", + modelProvider: "openai", + gitInfo: null, + turns: [], + ...overrides, + } as unknown as ReadThread; +} + +function makeListedThread(id: string, overrides: Record = {}): ListedThread { + return { + ...makeReadThread({ id, turns: undefined }), + ephemeral: false, + ...overrides, + } as unknown as ListedThread; +} + +it("maps persisted Codex turns to ordered user and assistant history", () => { + const thread = { + id: "provider-thread-1", + cwd: "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/work/project", + name: null, + preview: "Investigate the importer\nMore context", + createdAt: 1_777_777_700, + updatedAt: 1_777_777_760, + source: "cli", + status: { type: "idle" }, + threadSource: null, + cliVersion: "1.2.3", + modelProvider: "openai", + gitInfo: null, + turns: [ + { + id: "turn-1", + startedAt: 1_777_777_710, + completedAt: 1_777_777_750, + status: "completed", + items: [ + { + id: "user-1", + type: "userMessage", + content: [ + { type: "text", text: "Investigate the importer" }, + { type: "localImage", path: "/tmp/screenshot.png" }, + ], + }, + { + id: "tool-1", + type: "commandExecution", + command: "pwd", + commandActions: [], + cwd: "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/work/project", + status: "completed", + }, + { + id: "assistant-1", + type: "agentMessage", + text: "The importer is ready.", + }, + ], + }, + ], + } as unknown as EffectCodexSchema.V2ThreadReadResponse["thread"]; + + expect(toPersistedThread(thread)).toMatchObject({ + providerThreadId: "provider-thread-1", + cwd: "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/work/project", + title: "Investigate the importer", + sourceMetadata: { source: "cli", cliVersion: "1.2.3" }, + messages: [ + { + id: "user-1", + sourceOrdinal: 0, + role: "user", + text: "Investigate the importer", + turnId: "turn-1", + }, + { + id: "assistant-1", + sourceOrdinal: 1, + role: "assistant", + text: "The importer is ready.", + turnId: "turn-1", + }, + ], + }); +}); + +it("prefers a persisted name and falls back when all title text is blank", () => { + expect(toPersistedThread(makeReadThread({ name: " Named thread " })).title).toBe( + "Named thread", + ); + expect(toPersistedThread(makeReadThread({ name: " ", preview: "\n" })).title).toBe( + "Imported Codex thread", + ); +}); + +it("omits partial and empty messages while applying timestamp fallbacks", () => { + const persisted = toPersistedThread( + makeReadThread({ + status: { type: "inProgress" }, + turns: [ + { + id: "turn-active", + startedAt: 1_777_777_710, + completedAt: null, + status: "inProgress", + items: [{ id: "active-agent", type: "agentMessage", text: "Partial" }], + }, + { + id: "turn-complete", + startedAt: null, + completedAt: null, + status: "completed", + items: [ + { + id: "empty-user", + type: "userMessage", + content: [{ type: "text", text: " " }], + }, + { id: "empty-agent", type: "agentMessage", text: "" }, + { id: "agent", type: "agentMessage", text: "Complete" }, + ], + }, + ], + }), + ); + + expect(persisted.messages).toEqual([ + expect.objectContaining({ + id: "agent", + text: "Complete", + createdAt: "2026-05-03T03:09:20.000Z", + }), + ]); + expect(persisted.discoveryCursor).toBe("2026-05-03T03:09:20.000Z:inProgress"); +}); + +it("selects only user-visible threads whose discovery cursor changed", () => { + const unchanged = makeListedThread("unchanged"); + const statusChanged = makeListedThread("status-changed", { status: { type: "idle" } }); + const selected = selectCodexThreadsForRead( + [ + unchanged, + statusChanged, + makeListedThread("new"), + makeListedThread("ephemeral", { ephemeral: true }), + makeListedThread("subagent", { source: { subAgent: { threadId: "parent" } } }), + makeListedThread("memory", { threadSource: "memory_consolidation" }), + makeListedThread("native"), + ], + { + excludeProviderThreadIds: new Set(["native"]), + cursorByProviderThreadId: new Map([ + ["unchanged", "2026-05-03T03:09:20.000Z:idle"], + ["status-changed", "2026-05-03T03:09:20.000Z:active"], + ]), + }, + ); + + expect(selected.map((thread) => thread.id)).toEqual(["status-changed", "new"]); +}); + +it.effect("keeps readable threads when a sibling disappears between list and read", () => + Effect.gen(function* () { + const threads = [makeListedThread("deleted"), makeListedThread("healthy")]; + const discovered = yield* readCodexThreadSnapshots(threads, (threadId) => + threadId === "deleted" + ? Effect.fail({ _tag: "ThreadReadFailed" as const }) + : Effect.succeed({ + thread: makeReadThread({ id: threadId, preview: "Healthy thread" }), + } as EffectCodexSchema.V2ThreadReadResponse), + ); + + expect(discovered.map((thread) => thread.providerThreadId)).toEqual(["healthy"]); + }), +); + +it.effect("stops incremental paging after the first fully known page", () => + Effect.gen(function* () { + const requestedCursors: Array = []; + const known = makeListedThread("known"); + const pages = new Map([ + [ + undefined, + { + data: [makeListedThread("new")], + nextCursor: "known-page", + } as EffectCodexSchema.V2ThreadListResponse, + ], + [ + "known-page", + { data: [known], nextCursor: "older-page" } as EffectCodexSchema.V2ThreadListResponse, + ], + [ + "older-page", + { + data: [makeListedThread("older-unseen")], + nextCursor: null, + } as EffectCodexSchema.V2ThreadListResponse, + ], + ]); + + const selected = yield* listCodexThreadsForRead( + { + excludeProviderThreadIds: new Set(), + cursorByProviderThreadId: new Map([["known", "2026-05-03T03:09:20.000Z:idle"]]), + stopAfterKnownPage: true, + }, + (cursor) => + Effect.sync(() => { + requestedCursors.push(cursor); + return pages.get(cursor)!; + }), + ); + + expect(selected.map((thread) => thread.id)).toEqual(["new"]); + expect(requestedCursors).toEqual([undefined, "known-page"]); + }), +); diff --git a/apps/server/src/provider/Layers/CodexThreadDiscovery.ts b/apps/server/src/provider/Layers/CodexThreadDiscovery.ts new file mode 100644 index 000000000000..cf93eb0ab5ef --- /dev/null +++ b/apps/server/src/provider/Layers/CodexThreadDiscovery.ts @@ -0,0 +1,260 @@ +import { type CodexSettings, TurnId } from "@t3tools/contracts"; +import type { + ProviderPersistedThread, + ProviderPersistedThreadDiscoveryInput, +} from "../Services/ProviderAdapter.ts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as CodexClient from "effect-codex-app-server/client"; +import * as CodexErrors from "effect-codex-app-server/errors"; +import type * as EffectCodexSchema from "effect-codex-app-server/schema"; + +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import { expandHomePath } from "../../pathExpansion.ts"; +import { buildCodexInitializeParams } from "./CodexProvider.ts"; +import { codexSessionAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts"; + +const PAGE_SIZE = 100; +const THREAD_READ_CONCURRENCY = 4; +const FORCE_KILL_AFTER = "2 seconds" as const; + +function diagnosticErrorType(cause: unknown): string { + if (cause instanceof Error) return cause.name; + if (typeof cause === "object" && cause !== null && "_tag" in cause) { + return String(cause._tag); + } + return typeof cause; +} + +function unixSecondsToIso(seconds: number): string { + return DateTime.formatIso(DateTime.makeUnsafe(seconds * 1_000)); +} + +function discoveryCursorForThread( + thread: EffectCodexSchema.V2ThreadListResponse["data"][number], +): string { + return `${unixSecondsToIso(thread.updatedAt)}:${thread.status.type}`; +} + +export function selectCodexThreadsForRead( + threads: ReadonlyArray, + discoveryInput?: ProviderPersistedThreadDiscoveryInput, +): ReadonlyArray { + return threads.filter((thread) => { + if ( + thread.ephemeral || + (typeof thread.source === "object" && "subAgent" in thread.source) || + thread.threadSource === "memory_consolidation" || + discoveryInput?.excludeProviderThreadIds.has(thread.id) === true + ) { + return false; + } + const knownCursor = discoveryInput?.cursorByProviderThreadId.get(thread.id); + return knownCursor !== discoveryCursorForThread(thread); + }); +} + +function titleForThread(thread: EffectCodexSchema.V2ThreadReadResponse["thread"]): string { + const name = thread.name?.trim(); + if (name) return name; + const preview = thread.preview.trim().split("\n", 1)[0]?.trim(); + return preview || "Imported Codex thread"; +} + +function messagesForThread( + thread: EffectCodexSchema.V2ThreadReadResponse["thread"], +): ProviderPersistedThread["messages"] { + const messages: Array = []; + for (const turn of thread.turns) { + if (turn.status === "inProgress") continue; + const startedAt = unixSecondsToIso(turn.startedAt ?? thread.createdAt); + const completedAt = unixSecondsToIso(turn.completedAt ?? turn.startedAt ?? thread.updatedAt); + for (const item of turn.items) { + if (item.type === "userMessage") { + const text = item.content + .filter((content) => content.type === "text") + .map((content) => content.text) + .join("\n") + .trim(); + if (text) { + messages.push({ + id: item.id, + sourceOrdinal: messages.length, + role: "user", + text, + turnId: TurnId.make(turn.id), + createdAt: startedAt, + }); + } + } + if (item.type === "agentMessage" && item.text.length > 0) { + messages.push({ + id: item.id, + sourceOrdinal: messages.length, + role: "assistant", + text: item.text, + turnId: TurnId.make(turn.id), + createdAt: completedAt, + }); + } + } + } + return messages; +} + +export function toPersistedThread( + thread: EffectCodexSchema.V2ThreadReadResponse["thread"], +): ProviderPersistedThread { + return { + providerThreadId: thread.id, + cwd: thread.cwd, + title: titleForThread(thread), + createdAt: unixSecondsToIso(thread.createdAt), + updatedAt: unixSecondsToIso(thread.updatedAt), + discoveryCursor: discoveryCursorForThread(thread), + sourceMetadata: { + source: thread.source, + threadSource: thread.threadSource ?? null, + status: thread.status, + sessionId: thread.sessionId, + forkedFromId: thread.forkedFromId ?? null, + parentThreadId: thread.parentThreadId ?? null, + cliVersion: thread.cliVersion, + modelProvider: thread.modelProvider, + gitInfo: thread.gitInfo ?? null, + }, + messages: messagesForThread(thread), + }; +} + +export function readCodexThreadSnapshots( + threads: ReadonlyArray, + readThread: (threadId: string) => Effect.Effect, +): Effect.Effect> { + return Effect.forEach( + threads, + (thread) => + readThread(thread.id).pipe( + Effect.map((response) => Option.some(toPersistedThread(response.thread))), + Effect.catch((cause) => + Effect.logWarning("skipped unreadable persisted Codex thread", { + providerThreadId: thread.id, + errorType: diagnosticErrorType(cause), + }).pipe(Effect.as(Option.none())), + ), + ), + { concurrency: THREAD_READ_CONCURRENCY }, + ).pipe(Effect.map((threads) => threads.filter(Option.isSome).map((thread) => thread.value))); +} + +const makeCodexDiscoveryClient = Effect.fn("makeCodexDiscoveryClient")(function* ( + config: CodexSettings, + environment?: NodeJS.ProcessEnv, +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const runtimeScope = yield* Scope.Scope; + const resolvedHomePath = config.homePath ? expandHomePath(config.homePath) : undefined; + const env = { + ...environment, + ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), + }; + const extendEnv = environment === undefined; + const launchArgs = resolveCodexLaunchArgs(config.launchArgs, environment); + const appServerArgs = codexSessionAppServerArgs(undefined, launchArgs); + const spawnCommand = yield* resolveSpawnCommand(config.binaryPath, appServerArgs, { + env, + extendEnv, + }); + const child = yield* spawner + .spawn( + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + cwd: process.cwd(), + env, + extendEnv, + forceKillAfter: FORCE_KILL_AFTER, + shell: spawnCommand.shell, + }), + ) + .pipe( + Effect.provideService(Scope.Scope, runtimeScope), + Effect.mapError( + (cause) => + new CodexErrors.CodexAppServerSpawnError({ + command: `${config.binaryPath} app-server`, + cause, + }), + ), + ); + + yield* child.stderr.pipe(Stream.decodeText(), Stream.runDrain, Effect.forkIn(runtimeScope)); + + const clientContext = yield* CodexClient.layerChildProcess(child).pipe( + Layer.build, + Effect.provideService(Scope.Scope, runtimeScope), + ); + const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe( + Effect.provide(clientContext), + ); + yield* client.request("initialize", buildCodexInitializeParams()); + yield* client.notify("initialized", undefined); + return client; +}); + +export const readCodexPersistedThread = Effect.fn("readCodexPersistedThread")(function* ( + config: CodexSettings, + environment: NodeJS.ProcessEnv | undefined, + providerThreadId: string, +) { + const client = yield* makeCodexDiscoveryClient(config, environment); + const response = yield* client.request("thread/read", { + threadId: providerThreadId, + includeTurns: true, + }); + return toPersistedThread(response.thread); +}); + +export const listCodexThreadsForRead = ( + discoveryInput: ProviderPersistedThreadDiscoveryInput | undefined, + requestPage: ( + cursor: string | undefined, + ) => Effect.Effect, +): Effect.Effect, E> => + Effect.gen(function* () { + const selected: Array = []; + let cursor: string | null | undefined; + do { + const page = yield* requestPage(cursor ?? undefined); + const changed = selectCodexThreadsForRead(page.data, discoveryInput); + selected.push(...changed); + if (discoveryInput?.stopAfterKnownPage === true && changed.length === 0) { + break; + } + cursor = page.nextCursor; + } while (cursor); + return selected; + }).pipe(Effect.withSpan("listCodexThreadsForRead")); + +export const discoverCodexThreads = Effect.fn("discoverCodexThreads")(function* ( + config: CodexSettings, + environment?: NodeJS.ProcessEnv, + discoveryInput?: ProviderPersistedThreadDiscoveryInput, +) { + const client = yield* makeCodexDiscoveryClient(config, environment); + const listed = yield* listCodexThreadsForRead(discoveryInput, (cursor) => + client.request("thread/list", { + ...(cursor !== undefined ? { cursor } : {}), + limit: PAGE_SIZE, + sortKey: "updated_at", + sortDirection: "desc", + }), + ); + + return yield* readCodexThreadSnapshots(listed, (threadId) => + client.request("thread/read", { threadId, includeTurns: true }), + ); +}); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index bd89dc4f8812..be4ab6173ba9 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -14,7 +14,6 @@ import type { } from "@t3tools/contracts"; import { ApprovalRequestId, - EnvironmentId, EventId, ProviderDriverKind, ProviderInstanceId, @@ -928,6 +927,47 @@ it.effect( ); routing.layer("ProviderServiceLive routing", (it) => { + it.effect("backfills continuation identity while recovering a legacy binding", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const threadId = asThreadId("thread-legacy-continuation"); + + yield* directory.upsert({ + threadId, + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + resumeCursor: { opaque: "legacy-resume" }, + runtimeMode: "full-access", + runtimePayload: { cwd: "/tmp/legacy-continuation" }, + }); + + yield* provider.sendTurn({ + threadId, + input: "resume legacy thread", + attachments: [], + }); + + const binding = yield* directory.getBinding(threadId); + assert.equal(Option.isSome(binding), true); + if (Option.isSome(binding)) { + const payload = binding.value.runtimePayload; + assert.equal(payload !== null && typeof payload === "object", true); + if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) { + assert.equal( + "continuationKey" in payload ? payload.continuationKey : undefined, + "codex:instance:codex", + ); + } + } + + yield* provider.stopSession({ threadId }); + routing.codex.startSession.mockClear(); + routing.codex.sendTurn.mockClear(); + routing.codex.stopSession.mockClear(); + }), + ); + it.effect("routes provider operations and rollback conversation", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; @@ -1491,12 +1531,14 @@ routing.layer("ProviderServiceLive routing", (it) => { const runtimePayload = payload as { cwd: string; model: string | null; + continuationKey: string; activeTurnId: string | null; lastError: string | null; lastRuntimeEvent: string | null; }; assert.equal(runtimePayload.cwd, session.cwd); assert.equal(runtimePayload.model, null); + assert.equal(runtimePayload.continuationKey, "codex:instance:codex"); assert.equal(runtimePayload.activeTurnId, `turn-${String(session.threadId)}`); assert.equal(runtimePayload.lastError, null); assert.equal(runtimePayload.lastRuntimeEvent, "provider.sendTurn"); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 1fceb20044f9..50ec07189229 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -136,6 +136,7 @@ function toRuntimePayloadFromSession( session: ProviderSession, extra?: { readonly modelSelection?: unknown; + readonly continuationKey?: string; readonly lastRuntimeEvent?: string; readonly lastRuntimeEventAt?: string; }, @@ -146,6 +147,7 @@ function toRuntimePayloadFromSession( activeTurnId: session.activeTurnId ?? null, lastError: session.lastError ?? null, ...(extra?.modelSelection !== undefined ? { modelSelection: extra.modelSelection } : {}), + ...(extra?.continuationKey !== undefined ? { continuationKey: extra.continuationKey } : {}), ...(extra?.lastRuntimeEvent !== undefined ? { lastRuntimeEvent: extra.lastRuntimeEvent } : {}), ...(extra?.lastRuntimeEventAt !== undefined ? { lastRuntimeEventAt: extra.lastRuntimeEventAt } @@ -317,6 +319,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( threadId: ThreadId, extra?: { readonly modelSelection?: unknown; + readonly continuationKey?: string; readonly lastRuntimeEvent?: string; readonly lastRuntimeEventAt?: string; }, @@ -326,6 +329,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( "ProviderService.upsertSessionBinding", session, ); + const instanceInfo = yield* registry.getInstanceInfo(providerInstanceId); yield* directory.upsert({ threadId, provider: session.provider, @@ -333,7 +337,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( runtimeMode: session.runtimeMode, status: toRuntimeStatus(session), ...(session.resumeCursor !== undefined ? { resumeCursor: session.resumeCursor } : {}), - runtimePayload: toRuntimePayloadFromSession(session, extra), + runtimePayload: toRuntimePayloadFromSession(session, { + ...extra, + continuationKey: instanceInfo.continuationIdentity.continuationKey, + }), }); }); diff --git a/apps/server/src/provider/Layers/ProviderThreadReconciler.test.ts b/apps/server/src/provider/Layers/ProviderThreadReconciler.test.ts new file mode 100644 index 000000000000..723674b2a8e6 --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderThreadReconciler.test.ts @@ -0,0 +1,786 @@ +import { + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + TurnId, + type OrchestrationCommand, +} from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; + +import type { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts"; +import type { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import type { ProviderInstance } from "../ProviderDriver.ts"; +import type { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; +import type { ProviderPersistedThread } from "../Services/ProviderAdapter.ts"; +import { + groupPersistedThreadDiscoveryCandidates, + providerThreadDiscoveryExclusions, + continuationIdentityDigest, + recoverReconciliationCause, + reconcilePersistedThread, + resolvePersistedContinuationKey, +} from "./ProviderThreadReconciler.ts"; + +const CONTINUATION_KEY = "codex:home:/work/.codex"; +const CONTINUATION_IDENTITY = "8da0416c4575b56e6f63b88a"; +const PROVIDER_THREAD_ID = "0198cb4a-thread"; +const providerIdentity = `${CONTINUATION_KEY.length}:${CONTINUATION_KEY}${PROVIDER_THREAD_ID}`; +const importedThreadId = ThreadId.make(`imported:${CONTINUATION_IDENTITY}:${PROVIDER_THREAD_ID}`); + +const instance = { + instanceId: ProviderInstanceId.make("codex-work"), + driverKind: ProviderDriverKind.make("codex"), + continuationIdentity: { + driverKind: ProviderDriverKind.make("codex"), + continuationKey: CONTINUATION_KEY, + }, +} as ProviderInstance; + +const persistedThread: ProviderPersistedThread = { + providerThreadId: PROVIDER_THREAD_ID, + cwd: "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/work/external", + title: "External work", + createdAt: "2026-08-21T03:22:43.000Z", + updatedAt: "2026-08-21T03:24:43.000Z", + discoveryCursor: "2026-08-21T03:24:43.000Z:idle", + sourceMetadata: { source: "cli" }, + messages: [ + { + id: "user-item", + sourceOrdinal: 0, + role: "user", + text: "Investigate this", + turnId: TurnId.make("turn-1"), + createdAt: "2026-08-21T03:24:43.000Z", + }, + { + id: "assistant-item", + sourceOrdinal: 1, + role: "assistant", + text: "Done", + turnId: TurnId.make("turn-1"), + createdAt: "2026-08-21T03:24:43.000Z", + }, + ], +}; + +it("keeps deterministic fallback candidates for each shared Codex home", () => { + const makeDiscoveryInstance = (instanceId: string, continuationKey: string) => + ({ + ...instance, + instanceId: ProviderInstanceId.make(instanceId), + continuationIdentity: { + driverKind: ProviderDriverKind.make("codex"), + continuationKey, + }, + enabled: true, + adapter: { discoverPersistedThreads: () => Effect.succeed([]) }, + }) as unknown as ProviderInstance; + + const groups = groupPersistedThreadDiscoveryCandidates([ + makeDiscoveryInstance("codex-z", CONTINUATION_KEY), + makeDiscoveryInstance("codex-a", CONTINUATION_KEY), + makeDiscoveryInstance("codex-other", "codex:home:/other/.codex"), + ]); + + expect(groups.map((group) => group.map((candidate) => candidate.instanceId))).toEqual([ + ["codex-other"], + ["codex-a", "codex-z"], + ]); +}); + +it("recovers a continuation key after the owning instance is removed", () => { + expect( + resolvePersistedContinuationKey( + "codex-removed", + { continuationKey: CONTINUATION_KEY }, + new Map([["codex-next", CONTINUATION_KEY]]), + ), + ).toBe(CONTINUATION_KEY); +}); + +it("uses opaque continuation identities and prefers persisted ownership", () => { + expect(continuationIdentityDigest(CONTINUATION_KEY)).toBe(CONTINUATION_IDENTITY); + expect(continuationIdentityDigest(CONTINUATION_KEY)).not.toContain("work"); + expect( + resolvePersistedContinuationKey( + "codex-reconfigured", + { continuationKey: CONTINUATION_KEY }, + new Map([["codex-reconfigured", "codex:home:/different/.codex"]]), + ), + ).toBe(CONTINUATION_KEY); +}); + +it("excludes only legacy native threads without a resolvable continuation group", () => { + expect(Array.from(providerThreadDiscoveryExclusions(new Set(["legacy-native-thread"])))).toEqual([ + "legacy-native-thread", + ]); +}); + +it.effect("does not recover a reconciliation interruption", () => + Effect.gen(function* () { + const exit = yield* Effect.exit( + recoverReconciliationCause(Cause.interrupt(), "should not log", {}, undefined), + ); + + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + expect(Cause.hasInterrupts(exit.cause)).toBe(true); + } + }), +); + +it.effect("imports an unmatched Codex thread into the unassigned project", () => + Effect.gen(function* () { + const commands: OrchestrationCommand[] = []; + const bindings: Array[0]> = []; + const engine = { + dispatch: (command: OrchestrationCommand) => + Effect.sync(() => { + commands.push(command); + return { sequence: commands.length }; + }), + readEvents: () => Stream.empty, + streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), + } as OrchestrationEngineService["Service"]; + const directory = { + upsert: (binding: Parameters[0]) => + Effect.sync(() => { + bindings.push(binding); + }), + } as unknown as ProviderSessionDirectory["Service"]; + const snapshots = { + getThreadShellById: () => Effect.succeed(Option.none()), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.succeed(Option.none()), + } as unknown as ProjectionSnapshotQuery["Service"]; + const identities = new Map(); + + yield* reconcilePersistedThread({ + instance, + thread: persistedThread, + model: "gpt-5.6-sol", + threadByProviderIdentity: identities, + directory, + snapshots, + engine, + unassignedWorkspaceRoot: "/t3/provider-imports/codex-work", + }); + + expect(commands.map((command) => command.type)).toEqual([ + "project.create", + "thread.create", + "thread.message.import", + "thread.message.import", + ]); + expect(commands[1]).toMatchObject({ + type: "thread.create", + title: "External work", + worktreePath: "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/work/external", + }); + expect(commands[2]).toMatchObject({ + type: "thread.message.import", + role: "user", + turnId: "turn-1", + }); + expect(commands[3]).toMatchObject({ + type: "thread.message.import", + role: "assistant", + turnId: "turn-1", + }); + const importedMessageIds = commands + .filter((command) => command.type === "thread.message.import") + .map((command) => command.messageId); + expect(importedMessageIds.toSorted()).toEqual(importedMessageIds); + expect(bindings).toHaveLength(1); + expect(bindings[0]).toMatchObject({ + providerInstanceId: "codex-work", + runtimeMode: "full-access", + status: "stopped", + resumeCursor: { threadId: "0198cb4a-thread" }, + runtimePayload: { + imported: true, + providerDiscoveryCursor: "2026-08-21T03:24:43.000Z:idle", + }, + }); + }), +); + +it.effect("reuses the group project when discovery ownership changes", () => + Effect.gen(function* () { + const commands: OrchestrationCommand[] = []; + const handoffInstance = { + ...instance, + instanceId: ProviderInstanceId.make("codex-next"), + } as ProviderInstance; + + yield* reconcilePersistedThread({ + instance: handoffInstance, + thread: persistedThread, + model: "gpt-5.6-sol", + threadByProviderIdentity: new Map(), + directory: { + upsert: () => Effect.void, + } as unknown as ProviderSessionDirectory["Service"], + snapshots: { + getThreadShellById: () => Effect.succeed(Option.none()), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShellById: () => + Effect.succeed( + Option.some({ + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex-work"), + model: "gpt-5.6-sol", + }, + }), + ), + } as unknown as ProjectionSnapshotQuery["Service"], + engine: { + dispatch: (command: OrchestrationCommand) => + Effect.sync(() => { + commands.push(command); + return { sequence: commands.length }; + }), + } as unknown as OrchestrationEngineService["Service"], + unassignedWorkspaceRoot: "/t3/provider-imports/shared-home", + }); + + expect(commands.map((command) => command.type)).toEqual([ + "project.meta.update", + "thread.create", + "thread.message.import", + "thread.message.import", + ]); + expect(commands[0]).toMatchObject({ + type: "project.meta.update", + defaultModelSelection: { instanceId: "codex-next", model: "gpt-5.6-sol" }, + }); + }), +); + +it.effect("migrates an existing imported thread when discovery ownership changes", () => + Effect.gen(function* () { + const commands: OrchestrationCommand[] = []; + const bindings: Array[0]> = []; + const handoffInstance = { + ...instance, + instanceId: ProviderInstanceId.make("codex-next"), + } as ProviderInstance; + + yield* reconcilePersistedThread({ + instance: handoffInstance, + thread: persistedThread, + model: "gpt-5.6-sol", + threadByProviderIdentity: new Map([[providerIdentity, importedThreadId]]), + directory: { + upsert: (binding: Parameters[0]) => + Effect.sync(() => { + bindings.push(binding); + }), + } as unknown as ProviderSessionDirectory["Service"], + snapshots: { + getThreadShellById: () => + Effect.succeed( + Option.some({ + id: importedThreadId, + modelSelection: { + instanceId: ProviderInstanceId.make("codex-work"), + model: "gpt-5.6-sol", + }, + }), + ), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShellById: () => + Effect.succeed( + Option.some({ + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex-work"), + model: "gpt-5.6-sol", + }, + }), + ), + getThreadDetailById: () => + Effect.succeed( + Option.some({ + messages: [ + { id: "existing-user", role: "user", text: "Investigate this" }, + { id: "existing-assistant", role: "assistant", text: "Done" }, + ], + }), + ), + } as unknown as ProjectionSnapshotQuery["Service"], + engine: { + dispatch: (command: OrchestrationCommand) => + Effect.sync(() => { + commands.push(command); + return { sequence: commands.length }; + }), + } as unknown as OrchestrationEngineService["Service"], + unassignedWorkspaceRoot: "/t3/provider-imports/shared-home", + }); + + expect(commands.map((command) => command.type)).toEqual([ + "project.meta.update", + "thread.meta.update", + ]); + expect(bindings[0]).toMatchObject({ + providerInstanceId: "codex-next", + runtimePayload: { + modelSelection: { instanceId: "codex-next", model: "gpt-5.6-sol" }, + }, + }); + }), +); + +it.effect("imports a new thread directly into its matching project", () => + Effect.gen(function* () { + const commands: OrchestrationCommand[] = []; + const engine = { + dispatch: (command: OrchestrationCommand) => + Effect.sync(() => { + commands.push(command); + return { sequence: commands.length }; + }), + } as unknown as OrchestrationEngineService["Service"]; + + yield* reconcilePersistedThread({ + instance, + thread: persistedThread, + model: "gpt-5.6-sol", + threadByProviderIdentity: new Map(), + directory: { + upsert: () => Effect.void, + } as unknown as ProviderSessionDirectory["Service"], + snapshots: { + getThreadShellById: () => Effect.succeed(Option.none()), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.some({ id: "project-1" })), + } as unknown as ProjectionSnapshotQuery["Service"], + engine, + unassignedWorkspaceRoot: "/unused", + }); + + expect(commands.map((command) => command.type)).toEqual([ + "thread.create", + "thread.message.import", + "thread.message.import", + ]); + expect(commands[0]).toMatchObject({ + type: "thread.create", + projectId: "project-1", + worktreePath: null, + }); + }), +); + +it.effect("does not advance the discovery watermark after a partial message import", () => + Effect.gen(function* () { + const bindings: Array[0]> = []; + const exit = yield* Effect.exit( + reconcilePersistedThread({ + instance, + thread: persistedThread, + model: "gpt-5.6-sol", + threadByProviderIdentity: new Map(), + directory: { + upsert: (binding: Parameters[0]) => + Effect.sync(() => { + bindings.push(binding); + }), + } as unknown as ProviderSessionDirectory["Service"], + snapshots: { + getThreadShellById: () => Effect.succeed(Option.none()), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.some({ id: "project-1" })), + } as unknown as ProjectionSnapshotQuery["Service"], + engine: { + dispatch: (command: OrchestrationCommand) => + command.type === "thread.message.import" && command.role === "assistant" + ? Effect.die("message import failed") + : Effect.succeed({ sequence: 1 }), + } as unknown as OrchestrationEngineService["Service"], + unassignedWorkspaceRoot: "/unused", + }), + ); + + expect(exit._tag).toBe("Failure"); + expect(bindings).toEqual([]); + }), +); + +it.effect("reconciles externally appended turns into the original native T3 thread", () => + Effect.gen(function* () { + const nativeThreadId = ThreadId.make("native-t3-thread"); + const nativeInstanceId = ProviderInstanceId.make("codex-native"); + const identities = new Map([[providerIdentity, nativeThreadId]]); + const commands: OrchestrationCommand[] = []; + const bindings: Array[0]> = []; + const externallyContinued: ProviderPersistedThread = { + ...persistedThread, + updatedAt: "2026-08-21T03:30:43.000Z", + discoveryCursor: "2026-08-21T03:30:43.000Z:idle", + messages: [ + ...persistedThread.messages, + { + id: "external-user-item", + sourceOrdinal: 2, + role: "user", + text: "Continue from the Codex app", + turnId: TurnId.make("turn-2"), + createdAt: "2026-08-21T03:29:43.000Z", + }, + { + id: "external-assistant-item", + sourceOrdinal: 3, + role: "assistant", + text: "External work completed", + turnId: TurnId.make("turn-2"), + createdAt: "2026-08-21T03:30:43.000Z", + }, + ], + }; + + yield* reconcilePersistedThread({ + instance, + thread: externallyContinued, + model: "gpt-5.6-sol", + threadByProviderIdentity: identities, + directory: { + upsert: (binding: Parameters[0]) => + Effect.sync(() => { + bindings.push(binding); + }), + } as unknown as ProviderSessionDirectory["Service"], + snapshots: { + getThreadShellById: (threadId: ThreadId) => + Effect.succeed( + threadId === nativeThreadId + ? Option.some({ + id: nativeThreadId, + modelSelection: { instanceId: nativeInstanceId, model: "gpt-native" }, + }) + : Option.none(), + ), + getThreadDetailById: () => + Effect.succeed( + Option.some({ + messages: [ + { id: "t3-user", role: "user", text: "Investigate this", turnId: null }, + { + id: "t3-assistant", + role: "assistant", + text: "Done", + turnId: TurnId.make("turn-1"), + }, + ], + }), + ), + } as unknown as ProjectionSnapshotQuery["Service"], + engine: { + dispatch: (command: OrchestrationCommand) => + Effect.sync(() => { + commands.push(command); + return { sequence: commands.length }; + }), + } as unknown as OrchestrationEngineService["Service"], + unassignedWorkspaceRoot: "/unused", + }); + + expect(commands.map((command) => command.type)).toEqual([ + "thread.message.import", + "thread.message.import", + ]); + expect(commands).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "thread.message.import", + threadId: nativeThreadId, + turnId: "turn-2", + text: "Continue from the Codex app", + }), + expect.objectContaining({ + type: "thread.message.import", + threadId: nativeThreadId, + turnId: "turn-2", + text: "External work completed", + }), + ]), + ); + expect(bindings).toHaveLength(1); + expect(bindings[0]).toMatchObject({ + threadId: nativeThreadId, + providerInstanceId: nativeInstanceId, + resumeCursor: { threadId: PROVIDER_THREAD_ID }, + runtimePayload: { + continuationKey: CONTINUATION_KEY, + providerDiscoveryCursor: "2026-08-21T03:30:43.000Z:idle", + modelSelection: { instanceId: nativeInstanceId, model: "gpt-native" }, + }, + }); + expect(bindings[0]?.runtimePayload).not.toMatchObject({ imported: true }); + }), +); + +it.effect("does not duplicate native user messages with generated attachment path text", () => + Effect.gen(function* () { + const nativeThreadId = ThreadId.make("native-t3-thread-with-attachment"); + const commands: OrchestrationCommand[] = []; + + yield* reconcilePersistedThread({ + instance, + thread: { + ...persistedThread, + messages: [ + { + ...persistedThread.messages[0]!, + text: 'Review this image\n\n[Attached image "screenshot.png" is saved at: /tmp/attachments/image-1.png]', + }, + persistedThread.messages[1]!, + ], + }, + model: "gpt-5.6-sol", + threadByProviderIdentity: new Map([[providerIdentity, nativeThreadId]]), + directory: { + upsert: () => Effect.void, + } as unknown as ProviderSessionDirectory["Service"], + snapshots: { + getThreadShellById: () => + Effect.succeed( + Option.some({ + id: nativeThreadId, + modelSelection: { instanceId: instance.instanceId, model: "gpt-5.6-sol" }, + }), + ), + getThreadDetailById: () => + Effect.succeed( + Option.some({ + messages: [ + { + id: "t3-user", + role: "user", + text: "Review this image", + attachments: [ + { + type: "image", + id: "image-1", + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 123, + }, + ], + }, + { id: "t3-assistant", role: "assistant", text: "Done" }, + ], + }), + ), + } as unknown as ProjectionSnapshotQuery["Service"], + engine: { + dispatch: (command: OrchestrationCommand) => + Effect.sync(() => { + commands.push(command); + return { sequence: commands.length }; + }), + } as unknown as OrchestrationEngineService["Service"], + unassignedWorkspaceRoot: "/unused", + }); + + expect(commands).toEqual([]); + }), +); + +it.effect("does not recreate an archived or deleted native T3 thread as an import", () => + Effect.gen(function* () { + const commands: OrchestrationCommand[] = []; + const bindings: Array[0]> = []; + + yield* reconcilePersistedThread({ + instance, + thread: persistedThread, + model: "gpt-5.6-sol", + threadByProviderIdentity: new Map([ + [providerIdentity, ThreadId.make("inactive-native-thread")], + ]), + directory: { + upsert: (binding: Parameters[0]) => + Effect.sync(() => { + bindings.push(binding); + }), + } as unknown as ProviderSessionDirectory["Service"], + snapshots: { + getThreadShellById: () => Effect.succeed(Option.none()), + } as unknown as ProjectionSnapshotQuery["Service"], + engine: { + dispatch: (command: OrchestrationCommand) => + Effect.sync(() => { + commands.push(command); + return { sequence: commands.length }; + }), + } as unknown as OrchestrationEngineService["Service"], + unassignedWorkspaceRoot: "/unused", + }); + + expect(commands).toEqual([]); + expect(bindings).toEqual([ + expect.objectContaining({ + threadId: "inactive-native-thread", + resumeCursor: { threadId: PROVIDER_THREAD_ID }, + runtimePayload: { + continuationKey: CONTINUATION_KEY, + providerUpdatedAt: persistedThread.updatedAt, + providerDiscoveryCursor: persistedThread.discoveryCursor, + sourceMetadata: persistedThread.sourceMetadata, + }, + }), + ]); + }), +); + +it.effect("keeps a deleted imported thread tombstoned by its provider binding", () => + Effect.gen(function* () { + const commands: OrchestrationCommand[] = []; + const bindings: Array[0]> = []; + + yield* reconcilePersistedThread({ + instance, + thread: persistedThread, + model: "gpt-5.6-sol", + threadByProviderIdentity: new Map([[providerIdentity, importedThreadId]]), + directory: { + upsert: (binding: Parameters[0]) => + Effect.sync(() => { + bindings.push(binding); + }), + } as unknown as ProviderSessionDirectory["Service"], + snapshots: { + getThreadShellById: () => Effect.succeed(Option.none()), + } as unknown as ProjectionSnapshotQuery["Service"], + engine: { + dispatch: (command: OrchestrationCommand) => + Effect.sync(() => { + commands.push(command); + return { sequence: commands.length }; + }), + } as unknown as OrchestrationEngineService["Service"], + unassignedWorkspaceRoot: "/unused", + }); + + expect(commands).toEqual([]); + expect(bindings).toEqual([ + expect.objectContaining({ + threadId: importedThreadId, + resumeCursor: { threadId: PROVIDER_THREAD_ID }, + runtimePayload: expect.objectContaining({ + providerDiscoveryCursor: persistedThread.discoveryCursor, + }), + }), + ]); + }), +); + +it.effect("does not re-import turns that T3 already projected under different message ids", () => + Effect.gen(function* () { + const commands: OrchestrationCommand[] = []; + const identities = new Map([[providerIdentity, importedThreadId]]); + + yield* reconcilePersistedThread({ + instance, + thread: persistedThread, + model: "gpt-5.6-sol", + threadByProviderIdentity: identities, + directory: { + upsert: () => Effect.void, + } as unknown as ProviderSessionDirectory["Service"], + snapshots: { + getThreadShellById: () => + Effect.succeed( + Option.some({ + id: importedThreadId, + modelSelection: { + instanceId: instance.instanceId, + model: "gpt-5.6-sol", + }, + }), + ), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.some({ id: "project-1" })), + getThreadDetailById: () => + Effect.succeed( + Option.some({ + messages: [ + { id: "t3-user-id", role: "user", text: "Investigate this" }, + { id: "assistant:user-item", role: "assistant", text: "Done" }, + ], + }), + ), + } as unknown as ProjectionSnapshotQuery["Service"], + engine: { + dispatch: (command: OrchestrationCommand) => + Effect.sync(() => { + commands.push(command); + return { sequence: commands.length }; + }), + } as unknown as OrchestrationEngineService["Service"], + unassignedWorkspaceRoot: "/unused", + }); + + expect(commands).toEqual([]); + }), +); + +it.effect("appends only messages missing after a deterministic imported id", () => + Effect.gen(function* () { + const commands: OrchestrationCommand[] = []; + + yield* reconcilePersistedThread({ + instance, + thread: persistedThread, + model: "gpt-5.6-sol", + threadByProviderIdentity: new Map([[providerIdentity, importedThreadId]]), + directory: { + upsert: () => Effect.void, + } as unknown as ProviderSessionDirectory["Service"], + snapshots: { + getThreadShellById: () => + Effect.succeed( + Option.some({ + id: importedThreadId, + modelSelection: { + instanceId: instance.instanceId, + model: "gpt-5.6-sol", + }, + }), + ), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.some({ id: "project-1" })), + getThreadDetailById: () => + Effect.succeed( + Option.some({ + messages: [ + { + id: `provider:${CONTINUATION_IDENTITY}:${PROVIDER_THREAD_ID}:message:0000000000:user-item`, + role: "user", + text: "Investigate this", + }, + ], + }), + ), + } as unknown as ProjectionSnapshotQuery["Service"], + engine: { + dispatch: (command: OrchestrationCommand) => + Effect.sync(() => { + commands.push(command); + return { sequence: commands.length }; + }), + } as unknown as OrchestrationEngineService["Service"], + unassignedWorkspaceRoot: "/unused", + }); + + expect(commands).toHaveLength(1); + expect(commands[0]).toMatchObject({ + type: "thread.message.import", + role: "assistant", + text: "Done", + }); + }), +); diff --git a/apps/server/src/provider/Layers/ProviderThreadReconciler.ts b/apps/server/src/provider/Layers/ProviderThreadReconciler.ts new file mode 100644 index 000000000000..3006a99f2f01 --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderThreadReconciler.ts @@ -0,0 +1,751 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeCrypto from "node:crypto"; + +import { + CommandId, + DEFAULT_MODEL_BY_PROVIDER, + MessageId, + ProjectId, + ProviderDriverKind, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; + +import { ServerConfig } from "../../config.ts"; +import { forkParked } from "../../serverActivation.ts"; +import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import type { ProviderInstance } from "../ProviderDriver.ts"; +import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; +import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; +import { + ProviderThreadContinuity, + ProviderThreadContinuityError, +} from "../Services/ProviderThreadContinuity.ts"; +import type { ProviderPersistedThread } from "../Services/ProviderAdapter.ts"; + +const CODEX = ProviderDriverKind.make("codex"); +const ACTIVE_RECONCILE_INTERVAL = Duration.seconds(30); +const IDLE_RECONCILE_INTERVAL = Duration.minutes(2); +const ResumeCursor = Schema.Struct({ threadId: Schema.String }); +const isResumeCursor = Schema.is(ResumeCursor); +const ImportedRuntimePayload = Schema.Struct({ + continuationKey: Schema.optional(Schema.String), + providerUpdatedAt: Schema.optional(Schema.String), + providerDiscoveryCursor: Schema.optional(Schema.String), +}); +const isImportedRuntimePayload = Schema.is(ImportedRuntimePayload); + +export function continuationIdentityDigest(continuationKey: string): string { + return NodeCrypto.createHash("sha256").update(continuationKey).digest("hex").slice(0, 24); +} + +function diagnosticErrorType(cause: unknown): string { + if (cause instanceof Error) return cause.name; + if (typeof cause === "object" && cause !== null && "_tag" in cause) { + return String(cause._tag); + } + return typeof cause; +} + +export function recoverReconciliationCause( + cause: Cause.Cause, + message: string, + annotations: Record, + fallback: A, +): Effect.Effect { + return Cause.hasInterrupts(cause) + ? Effect.failCause(cause) + : Effect.logWarning(message, annotations).pipe(Effect.as(fallback)); +} + +export function resolvePersistedContinuationKey( + providerInstanceId: string, + runtimePayload: unknown, + continuationKeyByInstanceId: ReadonlyMap, +): string | undefined { + return ( + (isImportedRuntimePayload(runtimePayload) ? runtimePayload.continuationKey : undefined) ?? + continuationKeyByInstanceId.get(providerInstanceId) + ); +} + +export function providerThreadDiscoveryExclusions( + unresolvedNativeProviderThreadIds: ReadonlySet, +): ReadonlySet { + // Threads whose Codex home cannot be resolved are the only unsafe reads. + // Resolved native bindings must be discovered so external turns can flow + // back into their original T3 thread. + return new Set(unresolvedNativeProviderThreadIds); +} + +function providerIdentityKey(continuationKey: string, providerThreadId: string): string { + return `${continuationKey.length}:${continuationKey}${providerThreadId}`; +} + +function importedThreadIdFor(continuationKey: string, providerThreadId: string): ThreadId { + return ThreadId.make( + `imported:${continuationIdentityDigest(continuationKey)}:${providerThreadId}`, + ); +} + +function importedThreadId(instance: ProviderInstance, providerThreadId: string): ThreadId { + return importedThreadIdFor(instance.continuationIdentity.continuationKey, providerThreadId); +} + +function unassignedProjectId(instance: ProviderInstance): ProjectId { + return ProjectId.make( + `provider-imports:${continuationIdentityDigest(instance.continuationIdentity.continuationKey)}`, + ); +} + +function importCommandId(...parts: ReadonlyArray): CommandId { + return CommandId.make(`provider-import:${parts.join(":")}`); +} + +function importedMessageId( + continuationKey: string, + providerThreadId: string, + providerMessageId: string, + sourceOrdinal: number, +): MessageId { + const orderedOrdinal = String(sourceOrdinal).padStart(10, "0"); + return MessageId.make( + `provider:${continuationIdentityDigest(continuationKey)}:${providerThreadId}:message:${orderedOrdinal}:${providerMessageId}`, + ); +} + +function projectedAttachmentMessageKey(message: { + readonly role: string; + readonly text: string; + readonly attachments?: + | ReadonlyArray<{ readonly type: string; readonly name: string }> + | undefined; +}): string | undefined { + if (message.role !== "user" || !message.attachments || message.attachments.length === 0) { + return undefined; + } + return JSON.stringify([ + message.text, + message.attachments.map((attachment) => [attachment.type, attachment.name]), + ]); +} + +function persistedAttachmentMessageKey(message: { + readonly role: string; + readonly text: string; +}): string | undefined { + if (message.role !== "user") return undefined; + const lines = message.text.split("\n"); + const attachments: Array = []; + while (lines.length > 0) { + const line = lines.at(-1)!; + const match = /^\[Attached (\S+) "(.*)" is saved at: .+\]$/.exec(line); + if (!match) break; + attachments.unshift([match[1]!, match[2]!]); + lines.pop(); + } + if (attachments.length === 0) return undefined; + if (lines.length === 0) return JSON.stringify(["", attachments]); + if (lines.at(-1) !== "") return undefined; + lines.pop(); + return JSON.stringify([lines.join("\n"), attachments]); +} + +export function groupPersistedThreadDiscoveryCandidates( + instances: ReadonlyArray, +): ReadonlyArray> { + const candidatesByContinuationKey = new Map(); + const discoverable = instances + .filter( + (instance) => + instance.enabled && + instance.driverKind === CODEX && + instance.adapter.discoverPersistedThreads !== undefined, + ) + .sort((left, right) => left.instanceId.localeCompare(right.instanceId)); + for (const instance of discoverable) { + const continuationKey = instance.continuationIdentity.continuationKey; + const candidates = candidatesByContinuationKey.get(continuationKey) ?? []; + candidates.push(instance); + candidatesByContinuationKey.set(continuationKey, candidates); + } + return Array.from(candidatesByContinuationKey.entries()) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([, candidates]) => candidates); +} + +export const reconcilePersistedProviderThreads = Effect.fn("reconcilePersistedProviderThreads")( + function* () { + const registry = yield* ProviderInstanceRegistry; + const directory = yield* ProviderSessionDirectory; + const snapshots = yield* ProjectionSnapshotQuery; + const engine = yield* OrchestrationEngineService; + const serverConfig = yield* ServerConfig; + const path = yield* Path.Path; + const instances = yield* registry.listInstances; + const bindings = yield* directory.listBindings(); + const discoveryCandidateGroups = groupPersistedThreadDiscoveryCandidates(instances); + const continuationKeyByInstanceId = new Map( + instances + .filter((instance) => instance.driverKind === CODEX) + .map( + (instance) => + [instance.instanceId, instance.continuationIdentity.continuationKey] as const, + ), + ); + const threadByProviderIdentity = new Map(); + const cursorByThreadIdByContinuation = new Map>(); + const importedOwnerInstanceIdsByContinuation = new Map>(); + const unresolvedNativeProviderThreadIds = new Set(); + + for (const binding of bindings) { + if ( + binding.provider !== CODEX || + !binding.providerInstanceId || + !isResumeCursor(binding.resumeCursor) + ) { + continue; + } + const continuationKey = resolvePersistedContinuationKey( + binding.providerInstanceId, + binding.runtimePayload, + continuationKeyByInstanceId, + ); + if (!continuationKey) { + unresolvedNativeProviderThreadIds.add(binding.resumeCursor.threadId); + continue; + } + threadByProviderIdentity.set( + providerIdentityKey(continuationKey, binding.resumeCursor.threadId), + binding.threadId, + ); + const expectedImportedId = importedThreadIdFor( + continuationKey, + binding.resumeCursor.threadId, + ); + if (binding.threadId === expectedImportedId) { + const importedOwners = + importedOwnerInstanceIdsByContinuation.get(continuationKey) ?? new Set(); + importedOwners.add(binding.providerInstanceId); + importedOwnerInstanceIdsByContinuation.set(continuationKey, importedOwners); + } + // Native T3 threads participate in discovery too. Their provider cursor + // is persisted after reconciliation just like an imported thread, so a + // periodic pass does not re-read unchanged transcripts. + if (isImportedRuntimePayload(binding.runtimePayload)) { + const providerCursor = + binding.runtimePayload.providerDiscoveryCursor ?? + binding.runtimePayload.providerUpdatedAt; + if (providerCursor) { + const cursors = cursorByThreadIdByContinuation.get(continuationKey) ?? new Map(); + cursors.set(binding.resumeCursor.threadId, providerCursor); + cursorByThreadIdByContinuation.set(continuationKey, cursors); + } + } + } + + const discoveredCounts = yield* Effect.forEach( + discoveryCandidateGroups, + (candidates) => + Effect.gen(function* () { + let selected: + | { + readonly instance: ProviderInstance; + readonly model: string; + readonly discovered: ReadonlyArray; + } + | undefined; + for (const instance of candidates) { + const attempt = yield* Effect.gen(function* () { + const discover = instance.adapter.discoverPersistedThreads; + if (!discover) return undefined; + const providerSnapshot = yield* instance.snapshot.getSnapshot; + const model = + providerSnapshot.models.find((entry) => entry.isDefault)?.slug ?? + providerSnapshot.models[0]?.slug ?? + DEFAULT_MODEL_BY_PROVIDER[CODEX] ?? + "default"; + const continuationKey = instance.continuationIdentity.continuationKey; + const importedOwners = importedOwnerInstanceIdsByContinuation.get(continuationKey); + const ownerChanged = + importedOwners !== undefined && + Array.from(importedOwners).some( + (ownerInstanceId) => ownerInstanceId !== instance.instanceId, + ); + const discovered = yield* discover({ + excludeProviderThreadIds: providerThreadDiscoveryExclusions( + unresolvedNativeProviderThreadIds, + ), + cursorByProviderThreadId: ownerChanged + ? new Map() + : (cursorByThreadIdByContinuation.get(continuationKey) ?? new Map()), + }); + return { instance, model, discovered } as const; + }).pipe( + Effect.catch((cause) => + Effect.logWarning("persisted provider thread discovery candidate failed", { + provider: instance.driverKind, + providerInstanceId: instance.instanceId, + continuationIdentity: continuationIdentityDigest( + instance.continuationIdentity.continuationKey, + ), + errorType: diagnosticErrorType(cause), + }).pipe(Effect.as(undefined)), + ), + ); + if (attempt) { + selected = attempt; + break; + } + } + if (!selected) return 0; + const { instance, model, discovered } = selected; + + yield* Effect.forEach( + discovered, + (thread) => + reconcilePersistedThread({ + instance, + thread, + model, + threadByProviderIdentity, + directory, + snapshots, + engine, + unassignedWorkspaceRoot: path.join( + serverConfig.stateDir, + "provider-imports", + continuationIdentityDigest(instance.continuationIdentity.continuationKey), + ), + }).pipe( + Effect.catchCause((cause) => + recoverReconciliationCause( + cause, + "skipped persisted provider thread during reconciliation", + { + provider: instance.driverKind, + providerInstanceId: instance.instanceId, + providerThreadId: thread.providerThreadId, + }, + undefined, + ), + ), + ), + { concurrency: 1, discard: true }, + ); + return discovered.length; + }).pipe( + Effect.catchCause((cause) => + recoverReconciliationCause( + cause, + "persisted provider thread discovery failed", + { + provider: CODEX, + continuationIdentity: candidates[0] + ? continuationIdentityDigest(candidates[0].continuationIdentity.continuationKey) + : undefined, + }, + 0, + ), + ), + ), + { concurrency: "unbounded" }, + ); + return discoveredCounts.reduce((total, count) => total + count, 0); + }, +); + +export const reconcilePersistedThread = Effect.fn("reconcilePersistedProviderThread")( + function* (input: { + readonly instance: ProviderInstance; + readonly thread: ProviderPersistedThread; + readonly model: string; + readonly threadByProviderIdentity: Map; + readonly directory: ProviderSessionDirectory["Service"]; + readonly snapshots: ProjectionSnapshotQuery["Service"]; + readonly engine: OrchestrationEngineService["Service"]; + readonly unassignedWorkspaceRoot: string; + }) { + const continuationKey = input.instance.continuationIdentity.continuationKey; + const continuationIdentity = continuationIdentityDigest(continuationKey); + const identity = providerIdentityKey(continuationKey, input.thread.providerThreadId); + const expectedThreadId = importedThreadId(input.instance, input.thread.providerThreadId); + const boundThreadId = input.threadByProviderIdentity.get(identity); + const targetThreadId = boundThreadId ?? expectedThreadId; + const importsNewThread = boundThreadId === undefined; + const managesImportedThread = targetThreadId === expectedThreadId; + + const existingThread = yield* input.snapshots.getThreadShellById(targetThreadId); + // A binding whose T3 thread is archived or deleted acts as a tombstone and + // must never be recreated as a deterministic import. Archived threads can + // reconcile after restoration; deleted threads remain tombstoned. + if (!importsNewThread && Option.isNone(existingThread)) { + yield* input.directory.upsert({ + threadId: targetThreadId, + provider: CODEX, + resumeCursor: { threadId: input.thread.providerThreadId }, + runtimePayload: { + continuationKey, + providerUpdatedAt: input.thread.updatedAt, + providerDiscoveryCursor: input.thread.discoveryCursor, + sourceMetadata: input.thread.sourceMetadata, + }, + }); + return; + } + + const matchingProject = managesImportedThread + ? yield* input.snapshots.getActiveProjectByWorkspaceRoot(input.thread.cwd) + : Option.none(); + const projectId = Option.isSome(matchingProject) + ? matchingProject.value.id + : unassignedProjectId(input.instance); + + if (managesImportedThread && Option.isNone(matchingProject)) { + const existingUnassignedProject = yield* input.snapshots.getProjectShellById(projectId); + if (Option.isNone(existingUnassignedProject)) { + if (Option.isNone(existingThread)) { + yield* input.engine.dispatch({ + type: "project.create", + commandId: importCommandId(continuationIdentity, "unassigned-project"), + projectId, + title: "Unassigned Codex threads", + workspaceRoot: input.unassignedWorkspaceRoot, + defaultModelSelection: { instanceId: input.instance.instanceId, model: input.model }, + createdAt: input.thread.createdAt, + }); + } + } else if ( + existingUnassignedProject.value.defaultModelSelection?.instanceId !== + input.instance.instanceId || + existingUnassignedProject.value.defaultModelSelection.model !== input.model + ) { + yield* input.engine.dispatch({ + type: "project.meta.update", + commandId: importCommandId( + continuationIdentity, + "unassigned-project", + "owner", + input.instance.instanceId, + input.model, + ), + projectId, + defaultModelSelection: { instanceId: input.instance.instanceId, model: input.model }, + }); + } + } + + if (importsNewThread && Option.isNone(existingThread)) { + yield* input.engine.dispatch({ + type: "thread.create", + commandId: importCommandId(continuationIdentity, input.thread.providerThreadId, "create"), + threadId: targetThreadId, + projectId, + title: input.thread.title, + modelSelection: { instanceId: input.instance.instanceId, model: input.model }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: Option.isSome(matchingProject) ? null : input.thread.cwd, + createdAt: input.thread.createdAt, + }); + } else if ( + targetThreadId === expectedThreadId && + Option.isSome(existingThread) && + (existingThread.value.modelSelection.instanceId !== input.instance.instanceId || + existingThread.value.modelSelection.model !== input.model) + ) { + yield* input.engine.dispatch({ + type: "thread.meta.update", + commandId: importCommandId( + continuationIdentity, + input.thread.providerThreadId, + "owner", + input.instance.instanceId, + input.model, + ), + threadId: targetThreadId, + modelSelection: { instanceId: input.instance.instanceId, model: input.model }, + }); + } + + const existingDetail = Option.isSome(existingThread) + ? yield* input.snapshots.getThreadDetailById(targetThreadId) + : Option.none(); + const projectedMessages = Option.isSome(existingDetail) ? existingDetail.value.messages : []; + const projectedIndexById = new Map( + projectedMessages.map((message, index) => [message.id, index]), + ); + const projectedIndexesByContent = new Map>(); + const projectedIndexesByAttachmentContent = new Map(); + for (const [index, message] of projectedMessages.entries()) { + const indexesByText = projectedIndexesByContent.get(message.role) ?? new Map(); + const indexes = indexesByText.get(message.text) ?? []; + indexes.push(index); + indexesByText.set(message.text, indexes); + projectedIndexesByContent.set(message.role, indexesByText); + const attachmentKey = projectedAttachmentMessageKey(message); + if (attachmentKey !== undefined) { + const attachmentIndexes = projectedIndexesByAttachmentContent.get(attachmentKey) ?? []; + attachmentIndexes.push(index); + projectedIndexesByAttachmentContent.set(attachmentKey, attachmentIndexes); + } + } + const nextContentPosition = new Map>(); + const nextAttachmentPosition = new Map(); + let projectedIndex = 0; + const missingMessages = input.thread.messages.filter((message) => { + const deterministicId = importedMessageId( + continuationKey, + input.thread.providerThreadId, + message.id, + message.sourceOrdinal, + ); + const exactIndex = projectedIndexById.get(deterministicId); + if (exactIndex !== undefined && exactIndex >= projectedIndex) { + projectedIndex = exactIndex + 1; + return false; + } + + const indexes = projectedIndexesByContent.get(message.role)?.get(message.text) ?? []; + const positionsByText = nextContentPosition.get(message.role) ?? new Map(); + let contentPosition = positionsByText.get(message.text) ?? 0; + while (contentPosition < indexes.length && indexes[contentPosition]! < projectedIndex) { + contentPosition += 1; + } + positionsByText.set(message.text, contentPosition + 1); + nextContentPosition.set(message.role, positionsByText); + const matchingIndex = indexes[contentPosition]; + if (matchingIndex !== undefined) { + projectedIndex = matchingIndex + 1; + return false; + } + const attachmentKey = persistedAttachmentMessageKey(message); + if (attachmentKey !== undefined) { + const attachmentIndexes = projectedIndexesByAttachmentContent.get(attachmentKey) ?? []; + let attachmentPosition = nextAttachmentPosition.get(attachmentKey) ?? 0; + while ( + attachmentPosition < attachmentIndexes.length && + attachmentIndexes[attachmentPosition]! < projectedIndex + ) { + attachmentPosition += 1; + } + nextAttachmentPosition.set(attachmentKey, attachmentPosition + 1); + const attachmentMatchingIndex = attachmentIndexes[attachmentPosition]; + if (attachmentMatchingIndex !== undefined) { + projectedIndex = attachmentMatchingIndex + 1; + return false; + } + } + return true; + }); + + yield* Effect.forEach( + missingMessages, + (message) => + input.engine.dispatch({ + type: "thread.message.import", + commandId: importCommandId( + continuationIdentity, + input.thread.providerThreadId, + "message", + message.id, + ), + threadId: targetThreadId, + messageId: importedMessageId( + continuationKey, + input.thread.providerThreadId, + message.id, + message.sourceOrdinal, + ), + role: message.role, + text: message.text, + turnId: TurnId.make(message.turnId), + createdAt: message.createdAt, + }), + { concurrency: 1, discard: true }, + ); + + // Advance the discovery watermark only after every deterministic message + // command has landed. A crash mid-import then retries safely on the next + // pass instead of permanently hiding the remaining history. + const ownerModelSelection = + targetThreadId === expectedThreadId || Option.isNone(existingThread) + ? { instanceId: input.instance.instanceId, model: input.model } + : existingThread.value.modelSelection; + yield* input.directory.upsert({ + threadId: targetThreadId, + provider: CODEX, + providerInstanceId: ownerModelSelection.instanceId, + ...(boundThreadId === undefined + ? { runtimeMode: "full-access" as const, status: "stopped" as const } + : {}), + resumeCursor: { threadId: input.thread.providerThreadId }, + runtimePayload: { + ...(targetThreadId === expectedThreadId ? { imported: true } : {}), + continuationKey, + providerUpdatedAt: input.thread.updatedAt, + providerDiscoveryCursor: input.thread.discoveryCursor, + sourceMetadata: input.thread.sourceMetadata, + modelSelection: ownerModelSelection, + }, + }); + input.threadByProviderIdentity.set(identity, targetThreadId); + }, +); + +export const reconcilePersistedProviderThreadById = Effect.fn( + "reconcilePersistedProviderThreadById", +)(function* (threadId: ThreadId) { + const registry = yield* ProviderInstanceRegistry; + const directory = yield* ProviderSessionDirectory; + const snapshots = yield* ProjectionSnapshotQuery; + const engine = yield* OrchestrationEngineService; + const serverConfig = yield* ServerConfig; + const path = yield* Path.Path; + const bindingOption = yield* directory.getBinding(threadId); + if (Option.isNone(bindingOption)) return false; + const binding = bindingOption.value; + if ( + binding.provider !== CODEX || + binding.providerInstanceId === undefined || + !isResumeCursor(binding.resumeCursor) + ) { + return false; + } + + const instances = yield* registry.listInstances; + const continuationKeyByInstanceId = new Map( + instances + .filter((instance) => instance.driverKind === CODEX) + .map( + (instance) => [instance.instanceId, instance.continuationIdentity.continuationKey] as const, + ), + ); + const continuationKey = resolvePersistedContinuationKey( + binding.providerInstanceId, + binding.runtimePayload, + continuationKeyByInstanceId, + ); + if (continuationKey === undefined) return false; + + const candidates = instances + .filter( + (instance) => + instance.enabled && + instance.driverKind === CODEX && + instance.continuationIdentity.continuationKey === continuationKey && + instance.adapter.readPersistedThread !== undefined, + ) + .toSorted((left, right) => { + const leftOwns = left.instanceId === binding.providerInstanceId ? 0 : 1; + const rightOwns = right.instanceId === binding.providerInstanceId ? 0 : 1; + return leftOwns - rightOwns || left.instanceId.localeCompare(right.instanceId); + }); + const instance = candidates[0]; + if (instance === undefined || instance.adapter.readPersistedThread === undefined) return false; + + const [persistedThread, existingThread, providerSnapshot] = yield* Effect.all([ + instance.adapter.readPersistedThread(binding.resumeCursor.threadId), + snapshots.getThreadShellById(threadId), + instance.snapshot.getSnapshot, + ]); + const model = Option.isSome(existingThread) + ? existingThread.value.modelSelection.model + : (providerSnapshot.models.find((entry) => entry.isDefault)?.slug ?? + providerSnapshot.models[0]?.slug ?? + DEFAULT_MODEL_BY_PROVIDER[CODEX] ?? + "default"); + + yield* reconcilePersistedThread({ + instance, + thread: persistedThread, + model, + threadByProviderIdentity: new Map([ + [providerIdentityKey(continuationKey, persistedThread.providerThreadId), threadId], + ]), + directory, + snapshots, + engine, + unassignedWorkspaceRoot: path.join( + serverConfig.stateDir, + "provider-imports", + continuationIdentityDigest(continuationKey), + ), + }); + return true; +}); + +export const ProviderThreadReconcilerLive = Layer.effect( + ProviderThreadContinuity, + Effect.gen(function* () { + const continuityContext = yield* Effect.context< + | OrchestrationEngineService + | Path.Path + | ProjectionSnapshotQuery + | ProviderInstanceRegistry + | ProviderSessionDirectory + | ServerConfig + >(); + const registry = yield* ProviderInstanceRegistry; + const changes = yield* registry.subscribeChanges; + const reconcileSemaphore = yield* Semaphore.make(1); + const reconcileAll = reconcileSemaphore.withPermits(1)( + reconcilePersistedProviderThreads().pipe( + Effect.provideContext(continuityContext), + Effect.catchCause((cause) => + recoverReconciliationCause( + cause, + "persisted provider thread reconciliation failed", + {}, + 0, + ), + ), + ), + ); + const reconcileThread = Effect.fn("ProviderThreadContinuity.reconcileThread")(function* ( + threadId: ThreadId, + ) { + return yield* reconcileSemaphore + .withPermits(1)(reconcilePersistedProviderThreadById(threadId)) + .pipe( + Effect.provideContext(continuityContext), + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.interrupt + : Effect.fail( + new ProviderThreadContinuityError({ + threadId, + operation: "reconcile", + message: Cause.pretty(cause), + cause, + }), + ), + ), + ); + }); + + yield* forkParked( + Effect.forever( + reconcileAll.pipe( + Effect.flatMap((discoveredCount) => + Effect.sleep(discoveredCount > 0 ? ACTIVE_RECONCILE_INTERVAL : IDLE_RECONCILE_INTERVAL), + ), + ), + ), + ); + yield* forkParked(Effect.forever(PubSub.take(changes).pipe(Effect.andThen(reconcileAll)))); + + return ProviderThreadContinuity.of({ reconcileThread }); + }), +); diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index 634745832b37..fcf5e88ac8a2 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -44,6 +44,33 @@ export interface ProviderThreadSnapshot { readonly turns: ReadonlyArray; } +export interface ProviderPersistedThreadMessage { + readonly id: string; + readonly sourceOrdinal: number; + readonly role: "user" | "assistant"; + readonly text: string; + readonly turnId: TurnId; + readonly createdAt: string; +} + +export interface ProviderPersistedThread { + readonly providerThreadId: string; + readonly cwd: string; + readonly title: string; + readonly createdAt: string; + readonly updatedAt: string; + readonly discoveryCursor: string; + readonly sourceMetadata: unknown; + readonly messages: ReadonlyArray; +} + +export interface ProviderPersistedThreadDiscoveryInput { + readonly excludeProviderThreadIds: ReadonlySet; + readonly cursorByProviderThreadId: ReadonlyMap; + /** Stop paging once a full page contains no new or changed eligible threads. */ + readonly stopAfterKnownPage?: boolean; +} + export interface ProviderAdapterShape { /** * Provider kind implemented by this adapter. @@ -108,6 +135,23 @@ export interface ProviderAdapterShape { */ readonly readThread: (threadId: ThreadId) => Effect.Effect; + /** + * Discover durable provider threads that may have been created by another + * compatible client. Adapters omit this capability when their provider has + * no supported persisted-thread listing API. + */ + readonly discoverPersistedThreads?: ( + input?: ProviderPersistedThreadDiscoveryInput, + ) => Effect.Effect, TError>; + + /** + * Read one durable provider thread by its native identity without acquiring + * write ownership. Used for exact convergence before a local continuation. + */ + readonly readPersistedThread?: ( + providerThreadId: string, + ) => Effect.Effect; + /** * Roll back a provider thread by N turns. */ diff --git a/apps/server/src/provider/Services/ProviderThreadContinuity.ts b/apps/server/src/provider/Services/ProviderThreadContinuity.ts new file mode 100644 index 000000000000..55f1e6041edb --- /dev/null +++ b/apps/server/src/provider/Services/ProviderThreadContinuity.ts @@ -0,0 +1,29 @@ +import { ThreadId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +export class ProviderThreadContinuityError extends Schema.TaggedErrorClass()( + "ProviderThreadContinuityError", + { + threadId: ThreadId, + operation: Schema.Literals(["read", "reconcile"]), + message: Schema.String, + cause: Schema.Defect(), + }, +) {} + +export interface ProviderThreadContinuityShape { + /** + * Converge one already-bound T3 thread with its durable provider transcript. + * Returns false for providers or bindings without a continuity capability. + */ + readonly reconcileThread: ( + threadId: ThreadId, + ) => Effect.Effect; +} + +export class ProviderThreadContinuity extends Context.Service< + ProviderThreadContinuity, + ProviderThreadContinuityShape +>()("t3/provider/Services/ProviderThreadContinuity") {} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index cd0582ebca49..a3a5c97d0286 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -115,6 +115,7 @@ import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; import { PersistenceSqlError } from "./persistence/Errors.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; import * as ProviderService from "./provider/Services/ProviderService.ts"; +import * as ProviderThreadContinuity from "./provider/Services/ProviderThreadContinuity.ts"; import { ProviderAdapterRequestError } from "./provider/Errors.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; @@ -390,6 +391,9 @@ const buildAppUnderTest = (options?: { keybindings?: Partial; providerRegistry?: Partial; providerService?: Partial; + providerThreadContinuity?: Partial< + ProviderThreadContinuity.ProviderThreadContinuity["Service"] + >; serverSettings?: Partial; externalLauncher?: Partial; vcsDriver?: Partial; @@ -649,6 +653,10 @@ const buildAppUnderTest = (options?: { uploadFeedback: () => Effect.die("Provider feedback is not stubbed in this test"), ...options?.layers?.providerService, }), + Layer.mock(ProviderThreadContinuity.ProviderThreadContinuity)({ + reconcileThread: () => Effect.succeed(false), + ...options?.layers?.providerThreadContinuity, + }), ), ), Layer.provide( @@ -5256,6 +5264,106 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("reconciles an existing provider thread before dispatching a new turn", () => + Effect.gen(function* () { + const effects: string[] = []; + yield* buildAppUnderTest({ + layers: { + providerThreadContinuity: { + reconcileThread: (threadId) => + Effect.sync(() => { + effects.push(`reconcile:${threadId}`); + return true; + }), + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + effects.push(`dispatch:${command.type}`); + return { sequence: 1 }; + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-continuity-turn-start"), + threadId: defaultThreadId, + message: { + messageId: MessageId.make("msg-continuity-turn-start"), + role: "user", + text: "Continue after Codex", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: "2026-01-01T00:00:00.000Z", + }), + ), + ); + + assert.equal(response.sequence, 1); + assert.deepEqual(effects, [`reconcile:${defaultThreadId}`, "dispatch:thread.turn.start"]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("does not dispatch a turn when provider reconciliation fails", () => + Effect.gen(function* () { + let dispatchCount = 0; + yield* buildAppUnderTest({ + layers: { + providerThreadContinuity: { + reconcileThread: (threadId) => + Effect.fail( + new ProviderThreadContinuity.ProviderThreadContinuityError({ + threadId, + operation: "reconcile", + message: "Codex thread/read failed", + cause: new Error("Codex thread/read failed"), + }), + ), + }, + orchestrationEngine: { + dispatch: () => + Effect.sync(() => { + dispatchCount += 1; + return { sequence: 1 }; + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-continuity-failed-turn-start"), + threadId: defaultThreadId, + message: { + messageId: MessageId.make("msg-continuity-failed-turn-start"), + role: "user", + text: "Do not fork", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: "2026-01-01T00:00:00.000Z", + }).pipe(Effect.result), + ), + ); + + assert.equal(result._tag, "Failure"); + assert.equal(dispatchCount, 0); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc projects.writeFile errors", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -6230,14 +6338,25 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("marks a socket thread snapshot as synchronized when requested", () => + it.effect("reconciles before sending a socket thread snapshot", () => Effect.gen(function* () { const thread = makeDefaultOrchestrationReadModel().threads[0]!; + const effects: string[] = []; yield* buildAppUnderTest({ layers: { + providerThreadContinuity: { + reconcileThread: (threadId) => + Effect.sync(() => { + effects.push(`reconcile:${threadId}`); + return true; + }), + }, projectionSnapshotQuery: { getThreadDetailSnapshot: () => - Effect.succeed(Option.some({ snapshotSequence: 1, thread })), + Effect.sync(() => { + effects.push("snapshot"); + return Option.some({ snapshotSequence: 1, thread }); + }), }, }, }); @@ -6254,6 +6373,42 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(items[0]?.kind, "snapshot"); assert.deepEqual(items[1], { kind: "synchronized" }); + assert.deepEqual(effects, [`reconcile:${defaultThreadId}`, "snapshot"]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("reconciles before serving an HTTP thread snapshot", () => + Effect.gen(function* () { + const thread = makeDefaultOrchestrationReadModel().threads[0]!; + const effects: string[] = []; + yield* buildAppUnderTest({ + layers: { + providerThreadContinuity: { + reconcileThread: (threadId) => + Effect.sync(() => { + effects.push(`reconcile:${threadId}`); + return true; + }), + }, + projectionSnapshotQuery: { + getThreadDetailSnapshot: () => + Effect.sync(() => { + effects.push("snapshot"); + return Option.some({ snapshotSequence: 1, thread }); + }), + }, + }, + }); + + const baseUrl = yield* getHttpServerUrl(); + const cookie = yield* getAuthenticatedSessionCookieHeader(); + const response = yield* measureHttpGet({ + url: `${baseUrl}/api/orchestration/threads/${defaultThreadId}`, + headers: { cookie }, + }); + + assert.equal(response.status, 200); + assert.deepEqual(effects, [`reconcile:${defaultThreadId}`, "snapshot"]); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -6456,6 +6611,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("subscribeThread bounds catch-up replay to the captured head", () => Effect.gen(function* () { let replayLimit: number | undefined; + let reconcileCalls = 0; const now = "2026-01-01T00:00:00.000Z"; const messageEvent = { sequence: 3, @@ -6482,6 +6638,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { + providerThreadContinuity: { + reconcileThread: () => + Effect.sync(() => { + reconcileCalls += 1; + return true; + }), + }, orchestrationEngine: { latestSequence: Effect.succeed(50), readEvents: (_afterSequence, limit) => { @@ -6510,6 +6673,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { // The replay is bounded to the head captured before the read, not // Number.MAX_SAFE_INTEGER. assert.equal(replayLimit, 50); + assert.equal(reconcileCalls, 0); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 0c299bfc5c34..ff42501753df 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -35,6 +35,7 @@ import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRe import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts"; import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; +import { ProviderThreadReconcilerLive } from "./provider/Layers/ProviderThreadReconciler.ts"; import * as OpenCodeRuntime from "./provider/opencodeRuntime.ts"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; @@ -245,6 +246,7 @@ const PlatformServicesLive = Layer.unwrap( const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(OrchestrationReactorLive), + Layer.provideMerge(ProviderThreadReconcilerLive), Layer.provideMerge(ProviderRuntimeIngestionLive), Layer.provideMerge(ProviderCommandReactorLive), Layer.provideMerge(CheckpointReactorLive), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index a9f8f5f0bb50..d159035343cd 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -86,6 +86,7 @@ import { } from "./observability/RpcInstrumentation.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; import * as ProviderService from "./provider/Services/ProviderService.ts"; +import * as ProviderThreadContinuity from "./provider/Services/ProviderThreadContinuity.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; @@ -462,6 +463,7 @@ const makeWsRpcLayer = ( const portDiscovery = yield* PortScanner.PortDiscovery; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; const providerService = yield* ProviderService.ProviderService; + const providerThreadContinuity = yield* ProviderThreadContinuity.ProviderThreadContinuity; const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; const config = yield* ServerConfig.ServerConfig; @@ -1083,6 +1085,18 @@ const makeWsRpcLayer = ( const dispatchNormalizedCommand = ( normalizedCommand: OrchestrationCommand, ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => { + const continuityPreflight = + normalizedCommand.type === "thread.turn.start" + ? providerThreadContinuity.reconcileThread(normalizedCommand.threadId).pipe( + Effect.mapError((cause) => + toDispatchCommandError( + cause, + "Failed to reconcile provider thread before starting the turn", + ), + ), + Effect.asVoid, + ) + : Effect.void; const dispatchEffect = normalizedCommand.type === "thread.turn.start" && normalizedCommand.bootstrap ? dispatchBootstrapTurnStart(normalizedCommand) @@ -1093,7 +1107,7 @@ const makeWsRpcLayer = ( ); return startup - .enqueueCommand(dispatchEffect) + .enqueueCommand(continuityPreflight.pipe(Effect.andThen(dispatchEffect))) .pipe( Effect.mapError((cause) => toDispatchCommandError(cause, "Failed to dispatch orchestration command"), @@ -1504,6 +1518,18 @@ const makeWsRpcLayer = ( // fresh thread detail instead of an unbounded replay. } + // HTTP-loaded snapshots already reconcile before their sequence is + // passed into this subscription. Reconcile here only on the + // fallback snapshot path, avoiding a duplicate Codex process and + // exact read for the normal HTTP-then-WebSocket flow. + yield* providerThreadContinuity.reconcileThread(input.threadId).pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to reconcile provider thread before subscribing", { + threadId: input.threadId, + detail: cause.message, + }), + ), + ); const snapshot = yield* projectionSnapshotQuery .getThreadDetailSnapshot( input.threadId, diff --git a/docs/internals/overview.md b/docs/internals/overview.md index b9454f7b58d0..66325b34c9af 100644 --- a/docs/internals/overview.md +++ b/docs/internals/overview.md @@ -82,7 +82,7 @@ reconciles. Command and event names live in [`orchestration.ts`][contracts]. Some commands are client dispatchable (`thread.create`, `thread.turn.start`, `thread.approval.respond`); others are internal and produced only by server-side reactors (`thread.message.assistant.delta`, -`thread.turn.diff.complete`). +`thread.message.import`, `thread.turn.diff.complete`). A turn is complete when its session leaves `running` status, projected by `settledTurnStateForSessionStatus` in [`projector.ts`][projector]. Checkpoint work settling later @@ -100,6 +100,13 @@ Follow-up work runs asynchronously in queue-backed workers built on [`DrainableW count reaches zero, so a test can await "queue empty and current item finished" instead of sleeping. Each of the three services exposes `drain` for exactly this. +[`ProviderThreadReconciler`][reconciler] is a separate activation-gated background service rather +than a drainable worker. It discovers durable Codex conversations, imports missing historical +messages through the event-sourced command path, and reconciles an exact provider transcript before +thread reads and new local turns. Native T3-bound Codex identities remain attached to their original +thread; unmatched external conversations use a deterministic imported thread under the matching +project or **Unassigned Codex threads**. + Runtime receipts are a test-only mechanism. `RuntimeReceiptBusLive` in [`RuntimeReceiptBus.ts`][receipts] publishes nothing; only the test layer is PubSub-backed. Do not build production behavior on receipts. @@ -147,6 +154,7 @@ already dispatch. [worker]: ../../packages/shared/src/DrainableWorker.ts [ingest]: ../../apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts [cmd]: ../../apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +[reconciler]: ../../apps/server/src/provider/Layers/ProviderThreadReconciler.ts [checkpoint]: ../../apps/server/src/orchestration/Layers/CheckpointReactor.ts [receipts]: ../../apps/server/src/orchestration/Layers/RuntimeReceiptBus.ts [drivers]: ../../apps/server/src/provider/builtInDrivers.ts diff --git a/docs/internals/providers.md b/docs/internals/providers.md index a309d70f03de..1f8456ef8147 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -36,6 +36,12 @@ Two registries separate configuration from live processes: [`ProviderService`][service] sits on top. It combines the adapter registry with the provider session directory to route session and turn operations for a thread, so callers name a thread, not an agent. +Adapters can optionally expose `discoverPersistedThreads` and `readPersistedThread`. Codex +implements both through [`CodexThreadDiscovery.ts`][codex-discovery] using app-server +`thread/list` and `thread/read`; it never parses `CODEX_HOME` session files directly. Discovery does +one complete scan after adapter creation, then later passes stop after the first fully known page. +Exact reads keep a thread current even when an update shares a coarse discovery timestamp. + Adding a driver means writing the driver plus adapter and adding it to `BUILT_IN_DRIVERS`. No orchestration, contract, or client change is required for the common case. @@ -66,6 +72,25 @@ synchronization. 3. [`CheckpointReactor`][checkpoint] captures workspace checkpoints on turn start and completion, and performs reverts. +Persisted conversation convergence is separate from those workers. +[`ProviderThreadReconciler`][reconciler] groups compatible Codex instances by continuation identity +and serializes background discovery with exact per-thread reconciliation. The durable identity is +the continuation key plus the provider thread ID. An external conversation is assigned to the +project matching its working directory, or to **Unassigned Codex threads** when no project matches. +A Codex identity already bound to a native T3 thread always reconciles into that original thread and +preserves its provider instance and model ownership. + +Missing messages enter through the internal `thread.message.import` command with deterministic +command and message IDs. Imported message events are marked historical, so projections create +completed turns without pending provider work or retroactive checkpoints. The discovery cursor is +advanced only after every import command lands, making a crash retry safe. + +HTTP and WebSocket thread reads attempt exact reconciliation before loading the projection. Those +reads remain available if Codex is temporarily unavailable. `thread.turn.start` is stricter: exact +reconciliation must finish before the local user command is persisted, preventing a stale local +prompt from overtaking externally appended turns. Background loops start only after server +activation, and all clients observe the resulting ordinary orchestration projections. + ### Buffered assistant delivery A thread in `buffered` assistant delivery mode accumulates assistant text instead of streaming each @@ -85,6 +110,8 @@ when a request opens (approval) or user input is requested, via [instances]: ../../apps/server/src/provider/Services/ProviderInstanceRegistry.ts [registry]: ../../apps/server/src/provider/Services/ProviderAdapterRegistry.ts [service]: ../../apps/server/src/provider/Layers/ProviderService.ts +[codex-discovery]: ../../apps/server/src/provider/Layers/CodexThreadDiscovery.ts +[reconciler]: ../../apps/server/src/provider/Layers/ProviderThreadReconciler.ts [contracts]: ../../packages/contracts/src/orchestration.ts [worker]: ../../packages/shared/src/DrainableWorker.ts [ingest]: ../../apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts diff --git a/docs/user/providers-codex.md b/docs/user/providers-codex.md index f696e8877b2e..c9fbe4af793d 100644 --- a/docs/user/providers-codex.md +++ b/docs/user/providers-codex.md @@ -28,6 +28,19 @@ Log in with Codex normally: codex login ``` +## Continue Codex Work In T3 Code + +T3 Code automatically discovers durable conversations created by Codex CLI and other Codex clients +that use the same `CODEX_HOME`. Conversations whose working directory matches a T3 Code project +appear in that project. Other conversations appear under **Unassigned Codex threads**. A new +external conversation can take up to two minutes to appear. + +You can also start a thread in T3 Code, continue it in Codex CLI or the Codex app, and return to the +same T3 Code thread. T3 Code refreshes the durable Codex history when the thread opens and again +before it sends your next message. If that refresh fails, T3 Code does not send the new message, so +it cannot silently fork the conversation from stale history. Retry after the Codex thread is +available again. + ## Send feedback to OpenAI In an existing Codex thread, send `/feedback` or `/feedback` followed by a description of the diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 52b893f39c3b..437f70058046 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -59,6 +59,26 @@ const decodeOrchestrationEvent = Schema.decodeUnknownEffect(OrchestrationEvent); const decodeThreadMetaUpdatedPayload = Schema.decodeUnknownEffect(ThreadMetaUpdatedPayload); const decodeDispatchCommandError = Schema.decodeUnknownEffect(OrchestrationDispatchCommandError); +it.effect("decodes provider history message imports as internal commands", () => + Effect.gen(function* () { + const command = yield* decodeOrchestrationCommand({ + type: "thread.message.import", + commandId: "provider-import:message-1", + threadId: "thread-1", + messageId: "message-1", + role: "assistant", + text: "Imported response", + turnId: "turn-1", + createdAt: "2026-08-21T03:22:43.000Z", + }); + + assert.strictEqual(command.type, "thread.message.import"); + if (command.type === "thread.message.import") { + assert.strictEqual(command.turnId, "turn-1"); + } + }), +); + it.effect("decodes a dispatch error after its bootstrap thread was deleted", () => Effect.gen(function* () { const error = yield* decodeDispatchCommandError({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index e0634cea1152..d2d5027bc620 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1014,6 +1014,17 @@ const ThreadMessageAssistantCompleteCommand = Schema.Struct({ createdAt: IsoDateTime, }); +const ThreadMessageImportCommand = Schema.Struct({ + type: Schema.Literal("thread.message.import"), + commandId: CommandId, + threadId: ThreadId, + messageId: MessageId, + role: OrchestrationMessageRole, + text: Schema.String, + turnId: TurnId, + createdAt: IsoDateTime, +}); + const ThreadProposedPlanUpsertCommand = Schema.Struct({ type: Schema.Literal("thread.proposed-plan.upsert"), commandId: CommandId, @@ -1062,6 +1073,7 @@ const ThreadTitleRegenerationCompleteCommand = Schema.Struct({ const InternalOrchestrationCommand = Schema.Union([ ThreadSessionSetCommand, + ThreadMessageImportCommand, ThreadMessageAssistantDeltaCommand, ThreadMessageAssistantCompleteCommand, ThreadProposedPlanUpsertCommand, @@ -1264,6 +1276,10 @@ export const ThreadMessageSentPayload = Schema.Struct({ attachments: Schema.optional(Schema.Array(ChatAttachment)), turnId: Schema.NullOr(TurnId), streaming: Schema.Boolean, + // Historical provider messages are projected into the transcript without + // requesting work from the provider. Optional keeps persisted events and + // older clients wire-compatible. + historical: Schema.optional(Schema.Literal(true)), createdAt: IsoDateTime, updatedAt: IsoDateTime, }); From a4fd6d06ffeae798059feae067e99da647b71a2a Mon Sep 17 00:00:00 2001 From: Raul Date: Wed, 26 Aug 2026 15:58:49 +0200 Subject: [PATCH 02/10] fix(codex): batch persisted thread discovery --- .../src/provider/Layers/CodexAdapter.ts | 30 ++++++++--- .../Layers/CodexThreadDiscovery.test.ts | 49 +++++++++++++++++- .../provider/Layers/CodexThreadDiscovery.ts | 51 ++++++++++++++----- 3 files changed, 108 insertions(+), 22 deletions(-) diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 4b33c3918cda..e42703a3125a 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -1886,20 +1886,36 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ); let completedInitialPersistedDiscovery = false; + let initialPersistedDiscoveryCursor: string | undefined; const discoverPersistedThreads: NonNullable = ( input, ) => Effect.scoped( - discoverCodexThreads(codexConfig, options?.environment, { - excludeProviderThreadIds: input?.excludeProviderThreadIds ?? new Set(), - cursorByProviderThreadId: input?.cursorByProviderThreadId ?? new Map(), - stopAfterKnownPage: completedInitialPersistedDiscovery, - }).pipe( - Effect.tap(() => + discoverCodexThreads( + codexConfig, + options?.environment, + { + excludeProviderThreadIds: input?.excludeProviderThreadIds ?? new Set(), + cursorByProviderThreadId: input?.cursorByProviderThreadId ?? new Map(), + stopAfterKnownPage: completedInitialPersistedDiscovery, + }, + completedInitialPersistedDiscovery + ? undefined + : { startCursor: initialPersistedDiscoveryCursor, maxPages: 1 }, + ).pipe( + Effect.tap((result) => Effect.sync(() => { - completedInitialPersistedDiscovery = true; + if (!completedInitialPersistedDiscovery) { + if (result.nextCursor === null) { + completedInitialPersistedDiscovery = true; + initialPersistedDiscoveryCursor = undefined; + } else { + initialPersistedDiscoveryCursor = result.nextCursor; + } + } }), ), + Effect.map((result) => result.threads), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), Effect.timeout("2 minutes"), Effect.mapError( diff --git a/apps/server/src/provider/Layers/CodexThreadDiscovery.test.ts b/apps/server/src/provider/Layers/CodexThreadDiscovery.test.ts index 0c59da7b83d8..97e2966e84d0 100644 --- a/apps/server/src/provider/Layers/CodexThreadDiscovery.test.ts +++ b/apps/server/src/provider/Layers/CodexThreadDiscovery.test.ts @@ -225,7 +225,7 @@ it.effect("stops incremental paging after the first fully known page", () => ], ]); - const selected = yield* listCodexThreadsForRead( + const result = yield* listCodexThreadsForRead( { excludeProviderThreadIds: new Set(), cursorByProviderThreadId: new Map([["known", "2026-05-03T03:09:20.000Z:idle"]]), @@ -238,7 +238,52 @@ it.effect("stops incremental paging after the first fully known page", () => }), ); - expect(selected.map((thread) => thread.id)).toEqual(["new"]); + expect(result.threads.map((thread) => thread.id)).toEqual(["new"]); expect(requestedCursors).toEqual([undefined, "known-page"]); }), ); + +it.effect("returns the next cursor when an initial scan pauses after one page", () => + Effect.gen(function* () { + const requestedCursors: Array = []; + const result = yield* listCodexThreadsForRead( + { + excludeProviderThreadIds: new Set(), + cursorByProviderThreadId: new Map(), + }, + (cursor) => + Effect.sync(() => { + requestedCursors.push(cursor); + return { + data: [makeListedThread(cursor === undefined ? "recent" : "older")], + nextCursor: cursor === undefined ? "page-2" : null, + } as EffectCodexSchema.V2ThreadListResponse; + }), + { maxPages: 1 }, + ); + + expect(result.threads.map((thread) => thread.id)).toEqual(["recent"]); + expect(result.nextCursor).toBe("page-2"); + expect(requestedCursors).toEqual([undefined]); + + const resumed = yield* listCodexThreadsForRead( + { + excludeProviderThreadIds: new Set(), + cursorByProviderThreadId: new Map(), + }, + (cursor) => + Effect.sync(() => { + requestedCursors.push(cursor); + return { + data: [makeListedThread(cursor === undefined ? "recent" : "older")], + nextCursor: cursor === undefined ? "page-2" : null, + } as EffectCodexSchema.V2ThreadListResponse; + }), + { startCursor: result.nextCursor ?? undefined, maxPages: 1 }, + ); + + expect(resumed.threads.map((thread) => thread.id)).toEqual(["older"]); + expect(resumed.nextCursor).toBeNull(); + expect(requestedCursors).toEqual([undefined, "page-2"]); + }), +); diff --git a/apps/server/src/provider/Layers/CodexThreadDiscovery.ts b/apps/server/src/provider/Layers/CodexThreadDiscovery.ts index cf93eb0ab5ef..18168bca0d39 100644 --- a/apps/server/src/provider/Layers/CodexThreadDiscovery.ts +++ b/apps/server/src/provider/Layers/CodexThreadDiscovery.ts @@ -19,7 +19,9 @@ import { expandHomePath } from "../../pathExpansion.ts"; import { buildCodexInitializeParams } from "./CodexProvider.ts"; import { codexSessionAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts"; -const PAGE_SIZE = 100; +// Keep discovery passes comfortably below the adapter timeout even when a Codex home +// contains long-running threads whose full turn history is expensive to read. +const PAGE_SIZE = 5; const THREAD_READ_CONCURRENCY = 4; const FORCE_KILL_AFTER = "2 seconds" as const; @@ -223,38 +225,61 @@ export const listCodexThreadsForRead = ( requestPage: ( cursor: string | undefined, ) => Effect.Effect, -): Effect.Effect, E> => + pagination?: { + readonly startCursor?: string | undefined; + readonly maxPages?: number | undefined; + }, +): Effect.Effect< + { + readonly threads: ReadonlyArray; + readonly nextCursor: string | null; + }, + E +> => Effect.gen(function* () { const selected: Array = []; - let cursor: string | null | undefined; + let cursor: string | null | undefined = pagination?.startCursor; + let pagesRead = 0; do { const page = yield* requestPage(cursor ?? undefined); + pagesRead += 1; const changed = selectCodexThreadsForRead(page.data, discoveryInput); selected.push(...changed); + cursor = page.nextCursor; if (discoveryInput?.stopAfterKnownPage === true && changed.length === 0) { break; } - cursor = page.nextCursor; + if (pagination?.maxPages !== undefined && pagesRead >= pagination.maxPages) { + break; + } } while (cursor); - return selected; + return { threads: selected, nextCursor: cursor ?? null }; }).pipe(Effect.withSpan("listCodexThreadsForRead")); export const discoverCodexThreads = Effect.fn("discoverCodexThreads")(function* ( config: CodexSettings, environment?: NodeJS.ProcessEnv, discoveryInput?: ProviderPersistedThreadDiscoveryInput, + pagination?: { + readonly startCursor?: string | undefined; + readonly maxPages?: number | undefined; + }, ) { const client = yield* makeCodexDiscoveryClient(config, environment); - const listed = yield* listCodexThreadsForRead(discoveryInput, (cursor) => - client.request("thread/list", { - ...(cursor !== undefined ? { cursor } : {}), - limit: PAGE_SIZE, - sortKey: "updated_at", - sortDirection: "desc", - }), + const listed = yield* listCodexThreadsForRead( + discoveryInput, + (cursor) => + client.request("thread/list", { + ...(cursor !== undefined ? { cursor } : {}), + limit: PAGE_SIZE, + sortKey: "updated_at", + sortDirection: "desc", + }), + pagination, ); - return yield* readCodexThreadSnapshots(listed, (threadId) => + const threads = yield* readCodexThreadSnapshots(listed.threads, (threadId) => client.request("thread/read", { threadId, includeTurns: true }), ); + return { threads, nextCursor: listed.nextCursor }; }); From 8260dd227486f9c326da7d563bbe418a8992cb42 Mon Sep 17 00:00:00 2001 From: Raul Date: Wed, 26 Aug 2026 17:39:43 +0200 Subject: [PATCH 03/10] fix(codex): bound persisted thread reads --- apps/server/src/provider/Layers/CodexThreadDiscovery.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/CodexThreadDiscovery.ts b/apps/server/src/provider/Layers/CodexThreadDiscovery.ts index 18168bca0d39..297b5d75c8ed 100644 --- a/apps/server/src/provider/Layers/CodexThreadDiscovery.ts +++ b/apps/server/src/provider/Layers/CodexThreadDiscovery.ts @@ -23,6 +23,7 @@ import { codexSessionAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunch // contains long-running threads whose full turn history is expensive to read. const PAGE_SIZE = 5; const THREAD_READ_CONCURRENCY = 4; +const THREAD_READ_TIMEOUT = "30 seconds" as const; const FORCE_KILL_AFTER = "2 seconds" as const; function diagnosticErrorType(cause: unknown): string { @@ -142,6 +143,7 @@ export function readCodexThreadSnapshots( threads, (thread) => readThread(thread.id).pipe( + Effect.timeout(THREAD_READ_TIMEOUT), Effect.map((response) => Option.some(toPersistedThread(response.thread))), Effect.catch((cause) => Effect.logWarning("skipped unreadable persisted Codex thread", { @@ -241,7 +243,7 @@ export const listCodexThreadsForRead = ( let cursor: string | null | undefined = pagination?.startCursor; let pagesRead = 0; do { - const page = yield* requestPage(cursor ?? undefined); + const page: EffectCodexSchema.V2ThreadListResponse = yield* requestPage(cursor ?? undefined); pagesRead += 1; const changed = selectCodexThreadsForRead(page.data, discoveryInput); selected.push(...changed); From 2269fe8d72efff737948fe49b6221e9a4eb88a35 Mon Sep 17 00:00:00 2001 From: Raul Date: Wed, 26 Aug 2026 19:04:25 +0200 Subject: [PATCH 04/10] fix(dev): bind shared web server to IPv4 --- apps/web/vite.config.ts | 3 ++- scripts/dev-runner.test.ts | 3 +++ scripts/dev-runner.ts | 6 ++++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 6ee5de587b9e..4f1bc4b07198 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -28,7 +28,8 @@ const isSingleOriginDev = process.env.T3CODE_SINGLE_ORIGIN_DEV === "1"; const port = Number(process.env.PORT ?? 5733); const explicitHost = process.env.HOST?.trim(); -const host = explicitHost || "localhost"; +const sharedBindHost = process.env.T3CODE_WEB_BIND_HOST?.trim(); +const host = explicitHost || sharedBindHost || "localhost"; const configuredWsUrl = isSingleOriginDev ? undefined : process.env.VITE_WS_URL?.trim(); const configuredHttpUrl = isSingleOriginDev ? undefined : process.env.VITE_HTTP_URL?.trim(); const configuredRelayUrl = repoEnv.VITE_T3CODE_RELAY_URL?.trim() || ""; diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 9b4f44475d95..a8068351cfa1 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -1080,6 +1080,8 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { Effect.gen(function* () { const env = yield* shareSpawnedEnv({ ambientBundledDev: undefined }); assert.equal(env?.T3CODE_BUNDLED_DEV, "1"); + assert.equal(env?.T3CODE_WEB_BIND_HOST, "127.0.0.1"); + assert.equal(env?.HOST, undefined); }), ); @@ -1116,6 +1118,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { ); assert.equal(captured?.T3CODE_BUNDLED_DEV, undefined); + assert.equal(captured?.T3CODE_WEB_BIND_HOST, undefined); }), ); }); diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index d426cc7829b1..c81d352dd768 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -357,6 +357,7 @@ export function createDevRunnerEnv({ // apps/web/vite.config.ts. Over a shared origin that is invisible: the // page loads and only HMR quietly dials the wrong machine. delete output.HOST; + delete output.T3CODE_WEB_BIND_HOST; if (mode === "dev" || mode === "dev:web") { // Browser dev is single-origin: everything (including /ws) is proxied // through Vite, so the client must resolve its backend from @@ -796,6 +797,11 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { if (env.T3CODE_BUNDLED_DEV === undefined) { env.T3CODE_BUNDLED_DEV = "1"; } + // Tailscale proxies to IPv4 loopback. `localhost` may make Vite bind + // only ::1 on macOS, which leaves the shared origin returning 502. + // Keep HOST unset so HMR still derives the remote browser's origin. + env.T3CODE_WEB_BIND_HOST = DESKTOP_DEV_LOOPBACK_HOST; + yield* Effect.logInfo(`[dev-runner] shared on tailnet: ${shared.url}`); } } From b3e558eadc02c5c1bda7fd87cdd475af6537aadf Mon Sep 17 00:00:00 2001 From: Raul Date: Wed, 26 Aug 2026 19:48:14 +0200 Subject: [PATCH 05/10] fix(codex): sync external sessions into projects --- .../Layers/ProjectionPipeline.ts | 3 + apps/server/src/orchestration/decider.ts | 8 ++ apps/server/src/orchestration/projector.ts | 1 + .../Layers/ProviderThreadReconciler.test.ts | 59 +++++----- .../Layers/ProviderThreadReconciler.ts | 110 +++++++----------- apps/server/src/server.test.ts | 42 +++++++ apps/server/src/ws.ts | 14 +++ .../src/state/threadReducer.test.ts | 2 + .../client-runtime/src/state/threadReducer.ts | 1 + .../src/state/threads-sync.test.ts | 6 + packages/client-runtime/src/state/threads.ts | 7 ++ packages/contracts/src/orchestration.ts | 9 ++ 12 files changed, 160 insertions(+), 102 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 7d20cd127ae4..31d08df3f4b7 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -811,6 +811,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti } yield* projectionThreadRepository.upsert({ ...existingRow.value, + ...(event.payload.projectId !== undefined + ? { projectId: event.payload.projectId } + : {}), ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), ...(event.payload.titleRegeneration !== undefined ? { diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 101635d4ede8..fca95c83f09e 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -811,6 +811,13 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, threadId: command.threadId, }); + if (command.projectId !== undefined) { + yield* requireProject({ + readModel, + command, + projectId: command.projectId, + }); + } const branch = command.branch !== undefined && command.expectedBranch !== undefined && @@ -828,6 +835,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" type: "thread.meta-updated", payload: { threadId: command.threadId, + ...(command.projectId !== undefined ? { projectId: command.projectId } : {}), ...(command.title !== undefined ? { title: command.title } : {}), ...(command.regenerateTitle === true ? { diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index e59ca82810a0..c9f6d07b821b 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -447,6 +447,7 @@ export function projectEvent( Effect.map((payload) => ({ ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { + ...(payload.projectId !== undefined ? { projectId: payload.projectId } : {}), ...(payload.title !== undefined ? { title: payload.title } : {}), ...(payload.titleRegeneration !== undefined ? { titleRegeneration: payload.titleRegeneration } diff --git a/apps/server/src/provider/Layers/ProviderThreadReconciler.test.ts b/apps/server/src/provider/Layers/ProviderThreadReconciler.test.ts index 723674b2a8e6..aa481b67f130 100644 --- a/apps/server/src/provider/Layers/ProviderThreadReconciler.test.ts +++ b/apps/server/src/provider/Layers/ProviderThreadReconciler.test.ts @@ -134,7 +134,7 @@ it.effect("does not recover a reconciliation interruption", () => }), ); -it.effect("imports an unmatched Codex thread into the unassigned project", () => +it.effect("creates a project for an unmatched Codex workspace", () => Effect.gen(function* () { const commands: OrchestrationCommand[] = []; const bindings: Array[0]> = []; @@ -169,7 +169,6 @@ it.effect("imports an unmatched Codex thread into the unassigned project", () => directory, snapshots, engine, - unassignedWorkspaceRoot: "/t3/provider-imports/codex-work", }); expect(commands.map((command) => command.type)).toEqual([ @@ -180,8 +179,15 @@ it.effect("imports an unmatched Codex thread into the unassigned project", () => ]); expect(commands[1]).toMatchObject({ type: "thread.create", + projectId: "provider-workspace:b8e66955aa280f1e6b3db463", title: "External work", - worktreePath: "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/work/external", + worktreePath: null, + }); + expect(commands[0]).toMatchObject({ + type: "project.create", + projectId: "provider-workspace:b8e66955aa280f1e6b3db463", + title: "external", + workspaceRoot: "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/work/external", }); expect(commands[2]).toMatchObject({ type: "thread.message.import", @@ -211,7 +217,7 @@ it.effect("imports an unmatched Codex thread into the unassigned project", () => }), ); -it.effect("reuses the group project when discovery ownership changes", () => +it.effect("reuses the workspace project when discovery ownership changes", () => Effect.gen(function* () { const commands: OrchestrationCommand[] = []; const handoffInstance = { @@ -247,33 +253,27 @@ it.effect("reuses the group project when discovery ownership changes", () => return { sequence: commands.length }; }), } as unknown as OrchestrationEngineService["Service"], - unassignedWorkspaceRoot: "/t3/provider-imports/shared-home", }); expect(commands.map((command) => command.type)).toEqual([ - "project.meta.update", "thread.create", "thread.message.import", "thread.message.import", ]); expect(commands[0]).toMatchObject({ - type: "project.meta.update", - defaultModelSelection: { instanceId: "codex-next", model: "gpt-5.6-sol" }, + type: "thread.create", + projectId: "provider-workspace:b8e66955aa280f1e6b3db463", }); }), ); -it.effect("migrates an existing imported thread when discovery ownership changes", () => +it.effect("moves an existing imported thread from unassigned into its workspace project", () => Effect.gen(function* () { const commands: OrchestrationCommand[] = []; const bindings: Array[0]> = []; - const handoffInstance = { - ...instance, - instanceId: ProviderInstanceId.make("codex-next"), - } as ProviderInstance; yield* reconcilePersistedThread({ - instance: handoffInstance, + instance, thread: persistedThread, model: "gpt-5.6-sol", threadByProviderIdentity: new Map([[providerIdentity, importedThreadId]]), @@ -288,6 +288,7 @@ it.effect("migrates an existing imported thread when discovery ownership changes Effect.succeed( Option.some({ id: importedThreadId, + projectId: "provider-imports:legacy", modelSelection: { instanceId: ProviderInstanceId.make("codex-work"), model: "gpt-5.6-sol", @@ -298,10 +299,7 @@ it.effect("migrates an existing imported thread when discovery ownership changes getProjectShellById: () => Effect.succeed( Option.some({ - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex-work"), - model: "gpt-5.6-sol", - }, + id: "provider-workspace:b8e66955aa280f1e6b3db463", }), ), getThreadDetailById: () => @@ -321,17 +319,18 @@ it.effect("migrates an existing imported thread when discovery ownership changes return { sequence: commands.length }; }), } as unknown as OrchestrationEngineService["Service"], - unassignedWorkspaceRoot: "/t3/provider-imports/shared-home", }); - expect(commands.map((command) => command.type)).toEqual([ - "project.meta.update", - "thread.meta.update", - ]); + expect(commands.map((command) => command.type)).toEqual(["thread.meta.update"]); + expect(commands[0]).toMatchObject({ + type: "thread.meta.update", + projectId: "provider-workspace:b8e66955aa280f1e6b3db463", + worktreePath: null, + }); expect(bindings[0]).toMatchObject({ - providerInstanceId: "codex-next", + providerInstanceId: "codex-work", runtimePayload: { - modelSelection: { instanceId: "codex-next", model: "gpt-5.6-sol" }, + modelSelection: { instanceId: "codex-work", model: "gpt-5.6-sol" }, }, }); }), @@ -361,7 +360,6 @@ it.effect("imports a new thread directly into its matching project", () => getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.some({ id: "project-1" })), } as unknown as ProjectionSnapshotQuery["Service"], engine, - unassignedWorkspaceRoot: "/unused", }); expect(commands.map((command) => command.type)).toEqual([ @@ -402,7 +400,6 @@ it.effect("does not advance the discovery watermark after a partial message impo ? Effect.die("message import failed") : Effect.succeed({ sequence: 1 }), } as unknown as OrchestrationEngineService["Service"], - unassignedWorkspaceRoot: "/unused", }), ); @@ -486,7 +483,6 @@ it.effect("reconciles externally appended turns into the original native T3 thre return { sequence: commands.length }; }), } as unknown as OrchestrationEngineService["Service"], - unassignedWorkspaceRoot: "/unused", }); expect(commands.map((command) => command.type)).toEqual([ @@ -584,7 +580,6 @@ it.effect("does not duplicate native user messages with generated attachment pat return { sequence: commands.length }; }), } as unknown as OrchestrationEngineService["Service"], - unassignedWorkspaceRoot: "/unused", }); expect(commands).toEqual([]); @@ -619,7 +614,6 @@ it.effect("does not recreate an archived or deleted native T3 thread as an impor return { sequence: commands.length }; }), } as unknown as OrchestrationEngineService["Service"], - unassignedWorkspaceRoot: "/unused", }); expect(commands).toEqual([]); @@ -664,7 +658,6 @@ it.effect("keeps a deleted imported thread tombstoned by its provider binding", return { sequence: commands.length }; }), } as unknown as OrchestrationEngineService["Service"], - unassignedWorkspaceRoot: "/unused", }); expect(commands).toEqual([]); @@ -698,6 +691,7 @@ it.effect("does not re-import turns that T3 already projected under different me Effect.succeed( Option.some({ id: importedThreadId, + projectId: "project-1", modelSelection: { instanceId: instance.instanceId, model: "gpt-5.6-sol", @@ -722,7 +716,6 @@ it.effect("does not re-import turns that T3 already projected under different me return { sequence: commands.length }; }), } as unknown as OrchestrationEngineService["Service"], - unassignedWorkspaceRoot: "/unused", }); expect(commands).toEqual([]); @@ -746,6 +739,7 @@ it.effect("appends only messages missing after a deterministic imported id", () Effect.succeed( Option.some({ id: importedThreadId, + projectId: "project-1", modelSelection: { instanceId: instance.instanceId, model: "gpt-5.6-sol", @@ -773,7 +767,6 @@ it.effect("appends only messages missing after a deterministic imported id", () return { sequence: commands.length }; }), } as unknown as OrchestrationEngineService["Service"], - unassignedWorkspaceRoot: "/unused", }); expect(commands).toHaveLength(1); diff --git a/apps/server/src/provider/Layers/ProviderThreadReconciler.ts b/apps/server/src/provider/Layers/ProviderThreadReconciler.ts index 3006a99f2f01..5abac517133f 100644 --- a/apps/server/src/provider/Layers/ProviderThreadReconciler.ts +++ b/apps/server/src/provider/Layers/ProviderThreadReconciler.ts @@ -15,12 +15,10 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; -import { ServerConfig } from "../../config.ts"; import { forkParked } from "../../serverActivation.ts"; import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; @@ -102,10 +100,13 @@ function importedThreadId(instance: ProviderInstance, providerThreadId: string): return importedThreadIdFor(instance.continuationIdentity.continuationKey, providerThreadId); } -function unassignedProjectId(instance: ProviderInstance): ProjectId { - return ProjectId.make( - `provider-imports:${continuationIdentityDigest(instance.continuationIdentity.continuationKey)}`, - ); +function workspaceProjectId(workspaceRoot: string): ProjectId { + return ProjectId.make(`provider-workspace:${continuationIdentityDigest(workspaceRoot)}`); +} + +function workspaceProjectTitle(workspaceRoot: string): string { + const withoutTrailingSeparators = workspaceRoot.replace(/[\\/]+$/, ""); + return withoutTrailingSeparators.split(/[\\/]/).at(-1) || workspaceRoot; } function importCommandId(...parts: ReadonlyArray): CommandId { @@ -190,8 +191,6 @@ export const reconcilePersistedProviderThreads = Effect.fn("reconcilePersistedPr const directory = yield* ProviderSessionDirectory; const snapshots = yield* ProjectionSnapshotQuery; const engine = yield* OrchestrationEngineService; - const serverConfig = yield* ServerConfig; - const path = yield* Path.Path; const instances = yield* registry.listInstances; const bindings = yield* directory.listBindings(); const discoveryCandidateGroups = groupPersistedThreadDiscoveryCandidates(instances); @@ -322,11 +321,6 @@ export const reconcilePersistedProviderThreads = Effect.fn("reconcilePersistedPr directory, snapshots, engine, - unassignedWorkspaceRoot: path.join( - serverConfig.stateDir, - "provider-imports", - continuationIdentityDigest(instance.continuationIdentity.continuationKey), - ), }).pipe( Effect.catchCause((cause) => recoverReconciliationCause( @@ -374,7 +368,6 @@ export const reconcilePersistedThread = Effect.fn("reconcilePersistedProviderThr readonly directory: ProviderSessionDirectory["Service"]; readonly snapshots: ProjectionSnapshotQuery["Service"]; readonly engine: OrchestrationEngineService["Service"]; - readonly unassignedWorkspaceRoot: string; }) { const continuationKey = input.instance.continuationIdentity.continuationKey; const continuationIdentity = continuationIdentityDigest(continuationKey); @@ -409,38 +402,21 @@ export const reconcilePersistedThread = Effect.fn("reconcilePersistedProviderThr : Option.none(); const projectId = Option.isSome(matchingProject) ? matchingProject.value.id - : unassignedProjectId(input.instance); + : managesImportedThread + ? workspaceProjectId(input.thread.cwd) + : Option.getOrThrow(existingThread).projectId; if (managesImportedThread && Option.isNone(matchingProject)) { - const existingUnassignedProject = yield* input.snapshots.getProjectShellById(projectId); - if (Option.isNone(existingUnassignedProject)) { - if (Option.isNone(existingThread)) { - yield* input.engine.dispatch({ - type: "project.create", - commandId: importCommandId(continuationIdentity, "unassigned-project"), - projectId, - title: "Unassigned Codex threads", - workspaceRoot: input.unassignedWorkspaceRoot, - defaultModelSelection: { instanceId: input.instance.instanceId, model: input.model }, - createdAt: input.thread.createdAt, - }); - } - } else if ( - existingUnassignedProject.value.defaultModelSelection?.instanceId !== - input.instance.instanceId || - existingUnassignedProject.value.defaultModelSelection.model !== input.model - ) { + const existingWorkspaceProject = yield* input.snapshots.getProjectShellById(projectId); + if (Option.isNone(existingWorkspaceProject)) { yield* input.engine.dispatch({ - type: "project.meta.update", - commandId: importCommandId( - continuationIdentity, - "unassigned-project", - "owner", - input.instance.instanceId, - input.model, - ), + type: "project.create", + commandId: importCommandId("workspace", continuationIdentityDigest(input.thread.cwd)), projectId, + title: workspaceProjectTitle(input.thread.cwd), + workspaceRoot: input.thread.cwd, defaultModelSelection: { instanceId: input.instance.instanceId, model: input.model }, + createdAt: input.thread.createdAt, }); } } @@ -456,27 +432,32 @@ export const reconcilePersistedThread = Effect.fn("reconcilePersistedProviderThr runtimeMode: "full-access", interactionMode: "default", branch: null, - worktreePath: Option.isSome(matchingProject) ? null : input.thread.cwd, + worktreePath: null, createdAt: input.thread.createdAt, }); - } else if ( - targetThreadId === expectedThreadId && - Option.isSome(existingThread) && - (existingThread.value.modelSelection.instanceId !== input.instance.instanceId || - existingThread.value.modelSelection.model !== input.model) - ) { - yield* input.engine.dispatch({ - type: "thread.meta.update", - commandId: importCommandId( - continuationIdentity, - input.thread.providerThreadId, - "owner", - input.instance.instanceId, - input.model, - ), - threadId: targetThreadId, - modelSelection: { instanceId: input.instance.instanceId, model: input.model }, - }); + } else if (targetThreadId === expectedThreadId && Option.isSome(existingThread)) { + const ownerChanged = + existingThread.value.modelSelection.instanceId !== input.instance.instanceId || + existingThread.value.modelSelection.model !== input.model; + const projectChanged = existingThread.value.projectId !== projectId; + if (ownerChanged || projectChanged) { + yield* input.engine.dispatch({ + type: "thread.meta.update", + commandId: importCommandId( + continuationIdentity, + input.thread.providerThreadId, + "metadata", + projectId, + input.instance.instanceId, + input.model, + ), + threadId: targetThreadId, + ...(projectChanged ? { projectId, worktreePath: null } : {}), + ...(ownerChanged + ? { modelSelection: { instanceId: input.instance.instanceId, model: input.model } } + : {}), + }); + } } const existingDetail = Option.isSome(existingThread) @@ -611,8 +592,6 @@ export const reconcilePersistedProviderThreadById = Effect.fn( const directory = yield* ProviderSessionDirectory; const snapshots = yield* ProjectionSnapshotQuery; const engine = yield* OrchestrationEngineService; - const serverConfig = yield* ServerConfig; - const path = yield* Path.Path; const bindingOption = yield* directory.getBinding(threadId); if (Option.isNone(bindingOption)) return false; const binding = bindingOption.value; @@ -677,11 +656,6 @@ export const reconcilePersistedProviderThreadById = Effect.fn( directory, snapshots, engine, - unassignedWorkspaceRoot: path.join( - serverConfig.stateDir, - "provider-imports", - continuationIdentityDigest(continuationKey), - ), }); return true; }); @@ -691,11 +665,9 @@ export const ProviderThreadReconcilerLive = Layer.effect( Effect.gen(function* () { const continuityContext = yield* Effect.context< | OrchestrationEngineService - | Path.Path | ProjectionSnapshotQuery | ProviderInstanceRegistry | ProviderSessionDirectory - | ServerConfig >(); const registry = yield* ProviderInstanceRegistry; const changes = yield* registry.subscribeChanges; diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index a3a5c97d0286..85613ed691cb 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -6677,6 +6677,48 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("reconciles a warm-cache thread before capturing its replay head", () => + Effect.gen(function* () { + const effects: string[] = []; + yield* buildAppUnderTest({ + layers: { + providerThreadContinuity: { + reconcileThread: (threadId) => + Effect.sync(() => { + effects.push(`reconcile:${threadId}`); + return true; + }), + }, + orchestrationEngine: { + latestSequence: Effect.sync(() => { + effects.push("head"); + return 0; + }), + readEvents: () => { + effects.push("replay"); + return Stream.empty; + }, + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const firstItem = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeThread]({ + threadId: defaultThreadId, + afterSequence: 0, + reconcileBeforeReplay: true, + requestCompletionMarker: true, + }).pipe(Stream.runHead), + ), + ); + + assert.deepEqual(Option.getOrThrow(firstItem), { kind: "synchronized" }); + assert.deepEqual(effects, [`reconcile:${defaultThreadId}`, "head", "replay"]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("subscribeShell sends a fresh snapshot instead of replaying a large gap", () => Effect.gen(function* () { let readEventsCalls = 0; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index d159035343cd..f603a02b9ddd 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1483,6 +1483,20 @@ const makeWsRpcLayer = ( // thread snapshot instead, exactly like subscribeShell above. if (input.afterSequence !== undefined) { const afterSequence = input.afterSequence; + // A cursor restored from client cache has not passed through + // the HTTP snapshot endpoint on this connection. Reconcile + // before capturing the replay head so any externally appended + // Codex turns are persisted and included in the catch-up. + if (input.reconcileBeforeReplay === true) { + yield* providerThreadContinuity.reconcileThread(input.threadId).pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to reconcile provider thread before replay", { + threadId: input.threadId, + detail: cause.message, + }), + ), + ); + } const headSequence = yield* orchestrationEngine.latestSequence; const replayGap = headSequence - afterSequence; if (replayGap >= 0 && replayGap <= THREAD_RESUME_MAX_GAP) { diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 2042b2168c88..89bd6edcb8b4 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -291,6 +291,7 @@ describe("applyThreadDetailEvent", () => { type: "thread.meta-updated", payload: { threadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-2"), title: "Updated Title", branch: "feature/demo", updatedAt: "2026-04-01T05:00:00.000Z", @@ -299,6 +300,7 @@ describe("applyThreadDetailEvent", () => { expect(result.kind).toBe("updated"); if (result.kind === "updated") { + expect(result.thread.projectId).toBe("project-2"); expect(result.thread.title).toBe("Updated Title"); expect(result.thread.branch).toBe("feature/demo"); // Model selection should be unchanged since it wasn't in the payload diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index f3eb6f7ca9c4..5b7980590e10 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -207,6 +207,7 @@ export function applyThreadDetailEvent( kind: "updated", thread: { ...thread, + ...(event.payload.projectId !== undefined ? { projectId: event.payload.projectId } : {}), ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), ...(event.payload.titleRegeneration !== undefined ? { titleRegeneration: event.payload.titleRegeneration } diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index c2df434e8e77..efe8cb07b4f5 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -142,6 +142,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o const loaderCalls = yield* Ref.make(0); const lastSubscribeAfterSequence = yield* Ref.make(undefined); const lastRequestCompletionMarker = yield* Ref.make(undefined); + const lastReconcileBeforeReplay = yield* Ref.make(undefined); const savedThreads = yield* Ref.make>([]); const removedThreads = yield* Ref.make>([]); const wakeups = yield* Queue.unbounded(); @@ -158,11 +159,13 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o [ORCHESTRATION_WS_METHODS.subscribeThread]: (input: { readonly afterSequence?: number; readonly requestCompletionMarker?: boolean; + readonly reconcileBeforeReplay?: boolean; }) => Stream.unwrap( Ref.updateAndGet(subscriptionCount, (count) => count + 1).pipe( Effect.andThen(Ref.set(lastSubscribeAfterSequence, input.afterSequence)), Effect.andThen(Ref.set(lastRequestCompletionMarker, input.requestCompletionMarker)), + Effect.andThen(Ref.set(lastReconcileBeforeReplay, input.reconcileBeforeReplay)), Effect.as(streamFrom(inputs)), ), ), @@ -246,6 +249,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o loaderCalls, lastSubscribeAfterSequence, lastRequestCompletionMarker, + lastReconcileBeforeReplay, supervisorState, supervisorSession, savedThreads, @@ -344,6 +348,7 @@ describe("EnvironmentThreads", () => { // full snapshot over HTTP. expect(yield* Ref.get(harness.lastSubscribeAfterSequence)).toBe(CACHED_SNAPSHOT_SEQUENCE); expect(yield* Ref.get(harness.loaderCalls)).toBe(0); + expect(yield* Ref.get(harness.lastReconcileBeforeReplay)).toBe(true); }), ); @@ -417,6 +422,7 @@ describe("EnvironmentThreads", () => { // resumed from that snapshot's sequence. expect(yield* Ref.get(harness.loaderCalls)).toBeGreaterThanOrEqual(1); expect(yield* Ref.get(harness.lastSubscribeAfterSequence)).toBe(1); + expect(yield* Ref.get(harness.lastReconcileBeforeReplay)).toBeUndefined(); }), ); diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 39561afdb416..8e7a4c8476ad 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -590,6 +590,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make yield* SubscriptionRef.set(lastSequence, 0); current = yield* SubscriptionRef.get(state); } + let loadedHttpSnapshot = false; if (Option.isNone(current.data) && current.status !== "deleted") { const prepared = yield* SubscriptionRef.get(supervisor.prepared).pipe( Effect.flatMap( @@ -611,6 +612,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make supportsPagination ? { turnLimit: INITIAL_THREAD_USER_TURN_LIMIT } : undefined, ); if (Option.isSome(httpSnapshot)) { + loadedHttpSnapshot = true; yield* applyItem({ kind: "snapshot", snapshot: httpSnapshot.value }); current = yield* SubscriptionRef.get(state); } @@ -629,6 +631,11 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make return { threadId, ...(canResume ? { afterSequence: sequence } : {}), + // A warm cache did not pass through the HTTP snapshot endpoint, so + // the provider may contain externally appended turns that have not + // emitted T3 events yet. Reconcile before the server captures the + // replay head; a freshly loaded HTTP snapshot already did this. + ...(canResume && !loadedHttpSnapshot ? { reconcileBeforeReplay: true as const } : {}), ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}), // The WS fallback snapshot (sent when afterSequence is missing or // the gap is too large) should be windowed the same as the HTTP diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index d2d5027bc620..14f1095db1d0 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -591,6 +591,13 @@ export const OrchestrationSubscribeThreadInput = Schema.Struct({ * sequence on the client). */ afterSequence: Schema.optionalKey(NonNegativeInt), + /** + * Requests exact provider reconciliation before replaying from + * `afterSequence`. Warm-cache resumes set this because, unlike a cursor + * produced by a fresh HTTP snapshot, their provider history may have changed + * while T3 was disconnected. + */ + reconcileBeforeReplay: Schema.optionalKey(Schema.Boolean), /** * Requests an explicit marker after the subscription has emitted its initial * snapshot or catch-up replay and before it begins emitting live events. @@ -788,6 +795,7 @@ const ThreadMetaUpdateCommand = Schema.Struct({ type: Schema.Literal("thread.meta.update"), commandId: CommandId, threadId: ThreadId, + projectId: Schema.optional(ProjectId), title: Schema.optional(TrimmedNonEmptyString), regenerateTitle: Schema.optional(Schema.Literal(true)), modelSelection: Schema.optional(ModelSelection), @@ -1239,6 +1247,7 @@ export const ThreadPinReorderedPayload = Schema.Struct({ export const ThreadMetaUpdatedPayload = Schema.Struct({ threadId: ThreadId, + projectId: Schema.optional(ProjectId), title: Schema.optional(TrimmedNonEmptyString), /** Intent marker consumed by the title-generation reactor. Keeping this on the existing event lets older clients safely ignore the new field. */ From bfa4ae42f4a56e8293cba867ed4f0c4b0af69795 Mon Sep 17 00:00:00 2001 From: Raul Date: Wed, 26 Aug 2026 22:44:34 +0200 Subject: [PATCH 06/10] feat(codex): complete cross-client session continuity --- apps/server/src/auth/RpcAuthorization.ts | 2 + apps/server/src/bin.test.ts | 8 +- .../src/environment/ServerEnvironment.ts | 1 + .../Layers/ProviderCommandReactor.test.ts | 213 +++++++++++++++++- .../Layers/ProviderCommandReactor.ts | 57 ++++- apps/server/src/orchestration/decider.ts | 59 +++++ .../src/provider/Layers/CodexAdapter.ts | 13 +- .../Layers/CodexSessionRuntime.test.ts | 54 ++++- .../provider/Layers/CodexSessionRuntime.ts | 21 +- .../Layers/CodexThreadDiscovery.test.ts | 79 +++++++ .../provider/Layers/CodexThreadDiscovery.ts | 76 ++++++- .../Layers/ProviderThreadReconciler.test.ts | 147 +++++++++++- .../Layers/ProviderThreadReconciler.ts | 153 ++++++++++--- .../ProviderThreadSyncCoordinator.test.ts | 54 +++++ .../Layers/ProviderThreadSyncCoordinator.ts | 104 +++++++++ .../src/provider/Services/ProviderAdapter.ts | 6 + .../Services/ProviderThreadContinuity.ts | 6 +- apps/server/src/server.test.ts | 51 +++++ apps/server/src/ws.ts | 12 + apps/web/src/components/ChatView.tsx | 54 +++++ .../chat/ThreadErrorBanner.test.tsx | 43 ++++ .../src/components/chat/ThreadErrorBanner.tsx | 75 +++++- .../chat/ThreadSyncStatusPill.test.tsx | 31 ++- .../components/chat/ThreadSyncStatusPill.tsx | 6 +- ...ProviderSettingsPanel.environment.test.tsx | 61 ++++- .../settings/ProviderSettingsPanel.tsx | 67 ++++++ docs/internals/overview.md | 5 +- docs/internals/providers.md | 34 ++- docs/user/providers-codex.md | 22 +- .../src/operations/commands.test.ts | 28 +++ .../client-runtime/src/operations/commands.ts | 13 ++ packages/client-runtime/src/rpc/client.ts | 1 + packages/client-runtime/src/state/server.ts | 25 ++ .../src/state/threadCommands.ts | 9 + packages/contracts/src/environment.ts | 2 + packages/contracts/src/orchestration.ts | 27 +++ packages/contracts/src/provider.ts | 1 + packages/contracts/src/rpc.ts | 21 ++ packages/contracts/src/server.test.ts | 34 +++ packages/contracts/src/server.ts | 34 +++ 40 files changed, 1610 insertions(+), 99 deletions(-) create mode 100644 apps/server/src/provider/Layers/ProviderThreadSyncCoordinator.test.ts create mode 100644 apps/server/src/provider/Layers/ProviderThreadSyncCoordinator.ts diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 28ceac4cec99..4b87c77b767f 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -32,6 +32,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverProbe]: AuthOrchestrationReadScope, [WS_METHODS.serverGetConfig]: AuthOrchestrationReadScope, [WS_METHODS.serverRefreshProviders]: AuthOrchestrationOperateScope, + [WS_METHODS.serverStartProviderThreadSync]: AuthOrchestrationOperateScope, [WS_METHODS.serverUpdateProvider]: AuthOrchestrationOperateScope, [WS_METHODS.serverUpdateServer]: AuthOrchestrationOperateScope, [WS_METHODS.serverUpdateServerWithProgress]: AuthOrchestrationOperateScope, @@ -89,6 +90,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.providerUploadFeedback]: AuthOrchestrationOperateScope, [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope, [WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope, + [WS_METHODS.subscribeProviderThreadSync]: AuthOrchestrationReadScope, [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope, [WS_METHODS.vcsPull]: AuthOrchestrationOperateScope, [WS_METHODS.gitRunStackedAction]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 09d9c7c132ab..59fae33f1750 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -17,6 +17,7 @@ import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as DateTime from "effect/DateTime"; import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; import * as HttpRouter from "effect/unstable/http/HttpRouter"; import * as HttpServer from "effect/unstable/http/HttpServer"; import * as HttpApi from "effect/unstable/httpapi/HttpApi"; @@ -135,7 +136,12 @@ const withLiveProjectCliServer = (baseDir: string, run: () => Effect.Ef Layer.provide( Layer.succeed( ProviderThreadContinuity, - ProviderThreadContinuity.of({ reconcileThread: () => Effect.succeed(false) }), + ProviderThreadContinuity.of({ + reconcileThread: () => Effect.succeed(false), + startSyncAll: Effect.succeed({ status: "idle" }), + getSyncStatus: Effect.succeed({ status: "idle" }), + streamSyncStatus: Stream.make({ status: "idle" }), + }), ), ), Layer.provideMerge( diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 907a5d64bdfc..ed6d37007eb9 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -154,6 +154,7 @@ export const make = Effect.gen(function* () { threadPinReorder: true, threadTitleRegeneration: true, threadPullRequestLinking: true, + providerThreadSync: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}), }, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index a22a7acfb705..371afd0934a0 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -34,7 +34,11 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { deriveServerPaths, ServerConfig } from "../../config.ts"; import { TextGenerationError } from "@t3tools/contracts"; -import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + type ProviderServiceError, +} from "../../provider/Errors.ts"; import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; @@ -154,7 +158,7 @@ describe("ProviderCommandReactor", () => { readonly stopSessionEffect?: () => Effect.Effect; readonly startSessionEffect?: ( session: ProviderSession, - ) => Effect.Effect; + ) => Effect.Effect; }) { const now = "2026-01-01T00:00:00.000Z"; const baseDir = @@ -678,6 +682,211 @@ describe("ProviderCommandReactor", () => { }), ); + effectIt.effect("projects an actionable conflict without leaking Codex writer details", () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => + createHarness({ + startSessionEffect: () => + Effect.fail( + new ProviderAdapterProcessError({ + provider: "codex", + threadId: "thread-1", + detail: + "thread provider-secret already has an active writer at file:///Users/example/secret.jsonl", + }), + ), + }), + ); + const messageId = asMessageId("user-message-active-writer"); + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-active-writer"), + threadId: ThreadId.make("thread-1"), + message: { + messageId, + role: "user", + text: "continue this work", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }); + + yield* Effect.promise(() => + waitFor(async () => { + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + return thread?.session?.status === "error"; + }), + ); + const thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + + expect(thread?.session?.lastError).toBe( + "This Codex session is open in another client. Close it there and retry, or continue in a copy.", + ); + expect(thread?.session?.lastError).not.toContain("provider-secret"); + expect(thread?.session?.lastError).not.toContain("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/Users/example"); + expect(thread?.activities.at(-1)).toMatchObject({ + kind: "provider.thread.active-writer-conflict", + summary: "Codex session is open elsewhere", + payload: { + detail: + "This Codex session is open in another client. Close it there and retry, or continue in a copy.", + messageId, + canFork: true, + }, + }); + expect(thread?.messages.filter((message) => message.role === "user")).toHaveLength(1); + expect(harness.sendTurn).not.toHaveBeenCalled(); + }), + ); + + effectIt.effect("retries the conflicted Codex turn without duplicating its user message", () => + Effect.gen(function* () { + let writerIsActive = true; + const harness = yield* Effect.promise(() => + createHarness({ + startSessionEffect: (session) => + writerIsActive + ? Effect.fail( + new ProviderAdapterProcessError({ + provider: "codex", + threadId: "thread-1", + detail: "thread provider-thread already has an active writer", + }), + ) + : Effect.succeed(session), + }), + ); + const threadId = ThreadId.make("thread-1"); + const messageId = asMessageId("user-message-active-writer-retry"); + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-active-writer-retry"), + threadId, + message: { + messageId, + role: "user", + text: "continue exactly once", + attachments: [], + }, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-retry-selection", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }); + yield* Effect.promise(() => + waitFor(async () => { + const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); + return thread?.session?.status === "error"; + }), + ); + + writerIsActive = false; + yield* harness.engine.dispatch({ + type: "thread.turn.recover", + commandId: CommandId.make("cmd-turn-recover-active-writer-retry"), + threadId, + messageId, + strategy: "retry", + createdAt: "2026-01-01T00:00:01.000Z", + }); + + yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 1)); + const thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === threadId, + ); + expect(thread?.messages.filter((message) => message.role === "user")).toEqual([ + expect.objectContaining({ id: messageId, text: "continue exactly once" }), + ]); + expect(harness.startSession).toHaveBeenCalledTimes(2); + expect(harness.startSession.mock.calls[1]?.[1]).not.toHaveProperty( + "continuationMode", + "fork", + ); + expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ + modelSelection: { + instanceId: "codex", + model: "gpt-retry-selection", + }, + }); + }), + ); + + effectIt.effect("forks the conflicted Codex turn only when continuing in a copy", () => + Effect.gen(function* () { + let writerIsActive = true; + const harness = yield* Effect.promise(() => + createHarness({ + startSessionEffect: (session) => + writerIsActive + ? Effect.fail( + new ProviderAdapterProcessError({ + provider: "codex", + threadId: "thread-1", + detail: "thread provider-thread already has an active writer", + }), + ) + : Effect.succeed(session), + }), + ); + const threadId = ThreadId.make("thread-1"); + const messageId = asMessageId("user-message-active-writer-fork"); + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-active-writer-fork"), + threadId, + message: { + messageId, + role: "user", + text: "continue in one copy", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }); + yield* Effect.promise(() => + waitFor(async () => { + const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); + return thread?.session?.status === "error"; + }), + ); + + writerIsActive = false; + yield* harness.engine.dispatch({ + type: "thread.turn.recover", + commandId: CommandId.make("cmd-turn-recover-active-writer-fork"), + threadId, + messageId, + strategy: "fork", + createdAt: "2026-01-01T00:00:01.000Z", + }); + + yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 1)); + const thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === threadId, + ); + expect(thread?.messages.filter((message) => message.role === "user")).toEqual([ + expect.objectContaining({ id: messageId, text: "continue in one copy" }), + ]); + expect(harness.startSession).toHaveBeenCalledTimes(2); + expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ + continuationMode: "fork", + }); + }), + ); + it("generates a thread title on the first turn", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 1c0091028add..4013cd7db273 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -1,5 +1,7 @@ import { type ChatAttachment, + CODEX_ACTIVE_WRITER_CONFLICT_MESSAGE, + type CodexActiveWriterConflictActivityPayload, CommandId, EventId, type ModelSelection, @@ -29,7 +31,7 @@ import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts"; import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; -import type { ProviderServiceError } from "../../provider/Errors.ts"; +import { ProviderAdapterProcessError, type ProviderServiceError } from "../../provider/Errors.ts"; import { TextGeneration } from "../../textGeneration/TextGeneration.ts"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { ProviderRegistry } from "../../provider/Services/ProviderRegistry.ts"; @@ -48,6 +50,7 @@ import { import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); +const isProviderAdapterProcessError = Schema.is(ProviderAdapterProcessError); const isProviderDriverKind = Schema.is(ProviderDriverKind); type ProviderIntentEvent = Extract< @@ -332,6 +335,7 @@ const make = Effect.gen(function* () { readonly threadId: ThreadId; readonly kind: | "provider.turn.start.failed" + | "provider.thread.active-writer-conflict" | "provider.turn.interrupt.failed" | "provider.approval.respond.failed" | "provider.user-input.respond.failed" @@ -341,6 +345,7 @@ const make = Effect.gen(function* () { readonly turnId: TurnId | null; readonly createdAt: string; readonly requestId?: string; + readonly payload?: Readonly>; }) => Effect.all({ commandId: serverCommandId("provider-failure-activity"), @@ -359,6 +364,7 @@ const make = Effect.gen(function* () { payload: { detail: input.detail, ...(input.requestId ? { requestId: input.requestId } : {}), + ...input.payload, }, turnId: input.turnId, createdAt: input.createdAt, @@ -379,6 +385,16 @@ const make = Effect.gen(function* () { return Cause.pretty(cause); }; + const isCodexActiveWriterConflict = (cause: Cause.Cause): boolean => { + const failReason = cause.reasons.find(Cause.isFailReason); + const error = failReason?.error; + if (!isProviderAdapterProcessError(error) || error.provider !== "codex") { + return false; + } + const detail = error.detail.toLowerCase(); + return detail.includes("thread") && detail.includes("already has an active writer"); + }; + const setThreadSession = (input: { readonly threadId: ThreadId; readonly session: OrchestrationSession; @@ -520,6 +536,7 @@ const make = Effect.gen(function* () { options?: { readonly modelSelection?: ModelSelection; readonly pendingTurnStart?: boolean; + readonly continuationMode?: "fork"; }, ) { const thread = yield* resolveThread(threadId); @@ -665,6 +682,7 @@ const make = Effect.gen(function* () { ...(thread.title ? { title: thread.title } : {}), modelSelection: desiredModelSelection, ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), + ...(options?.continuationMode === "fork" ? { continuationMode: "fork" as const } : {}), runtimeMode: desiredRuntimeMode, }); @@ -775,6 +793,7 @@ const make = Effect.gen(function* () { readonly attachments?: ReadonlyArray; readonly modelSelection?: ModelSelection; readonly interactionMode?: "default" | "plan"; + readonly continuationMode?: "fork"; readonly createdAt: string; }) { const thread = yield* resolveThread(input.threadId); @@ -786,6 +805,7 @@ const make = Effect.gen(function* () { yield* ensureSessionForThread(input.threadId, input.createdAt, { ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}), pendingTurnStart: true, + ...(input.continuationMode === "fork" ? { continuationMode: "fork" as const } : {}), }); if (input.modelSelection !== undefined) { threadModelSelections.set(input.threadId, input.modelSelection); @@ -1168,7 +1188,10 @@ const make = Effect.gen(function* () { if (Cause.hasInterruptsOnly(cause)) { return Effect.void; } - const detail = formatFailureDetail(cause); + const activeWriterConflict = isCodexActiveWriterConflict(cause); + const detail = activeWriterConflict + ? CODEX_ACTIVE_WRITER_CONFLICT_MESSAGE + : formatFailureDetail(cause); return setThreadSessionErrorOnTurnStartFailure({ threadId: event.payload.threadId, detail, @@ -1177,11 +1200,34 @@ const make = Effect.gen(function* () { Effect.flatMap(() => appendProviderFailureActivity({ threadId: event.payload.threadId, - kind: "provider.turn.start.failed", - summary: "Provider turn start failed", + kind: activeWriterConflict + ? "provider.thread.active-writer-conflict" + : "provider.turn.start.failed", + summary: activeWriterConflict + ? "Codex session is open elsewhere" + : "Provider turn start failed", detail, turnId: null, createdAt: event.payload.createdAt, + ...(activeWriterConflict + ? { + payload: { + messageId: event.payload.messageId, + canFork: true, + ...(event.payload.modelSelection !== undefined + ? { modelSelection: event.payload.modelSelection } + : {}), + ...(event.payload.titleSeed !== undefined + ? { titleSeed: event.payload.titleSeed } + : {}), + runtimeMode: event.payload.runtimeMode, + interactionMode: event.payload.interactionMode, + ...(event.payload.sourceProposedPlan !== undefined + ? { sourceProposedPlan: event.payload.sourceProposedPlan } + : {}), + } satisfies CodexActiveWriterConflictActivityPayload, + } + : {}), }), ), Effect.asVoid, @@ -1208,6 +1254,9 @@ const make = Effect.gen(function* () { ? { modelSelection: event.payload.modelSelection } : {}), interactionMode: event.payload.interactionMode, + ...(event.payload.continuationMode !== undefined + ? { continuationMode: event.payload.continuationMode } + : {}), createdAt: event.payload.createdAt, }).pipe( Effect.map(Option.some), diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index fca95c83f09e..9fdfd3dc4888 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -1,4 +1,5 @@ import { + CodexActiveWriterConflictActivityPayload, EventId, type OrchestrationCommand, type OrchestrationEvent, @@ -8,6 +9,7 @@ import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import type * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; import { OrchestrationCommandInvariantError } from "./Errors.ts"; import { @@ -23,6 +25,9 @@ import { import { projectEvent } from "./projector.ts"; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); +const isCodexActiveWriterConflictActivityPayload = Schema.is( + CodexActiveWriterConflictActivityPayload, +); // Session adoption takes seconds; a user message still unadopted after this // window is a failed/stale start, not pending work. Mirrors the client's @@ -1047,6 +1052,60 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" return [...lifecycleResetEvents, userMessageEvent, turnStartRequestedEvent]; } + case "thread.turn.recover": { + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + const message = thread.messages.find( + (entry) => entry.id === command.messageId && entry.role === "user", + ); + const conflict = thread.activities.findLast( + (activity) => activity.kind === "provider.thread.active-writer-conflict", + ); + const conflictPayload = + conflict !== undefined && isCodexActiveWriterConflictActivityPayload(conflict.payload) + ? conflict.payload + : null; + if ( + message === undefined || + thread.session?.status !== "error" || + conflictPayload?.messageId !== command.messageId + ) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Thread '${command.threadId}' has no recoverable active-writer conflict for message '${command.messageId}'.`, + }); + } + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.turn-start-requested", + payload: { + threadId: command.threadId, + messageId: command.messageId, + ...(conflictPayload.modelSelection !== undefined + ? { modelSelection: conflictPayload.modelSelection } + : {}), + ...(conflictPayload.titleSeed !== undefined + ? { titleSeed: conflictPayload.titleSeed } + : {}), + runtimeMode: conflictPayload.runtimeMode, + interactionMode: conflictPayload.interactionMode, + ...(conflictPayload.sourceProposedPlan !== undefined + ? { sourceProposedPlan: conflictPayload.sourceProposedPlan } + : {}), + ...(command.strategy === "fork" ? { continuationMode: "fork" as const } : {}), + createdAt: command.createdAt, + }, + }; + } + case "thread.turn.interrupt": { yield* requireThread({ readModel, diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index e42703a3125a..ee781a91ee79 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -1690,6 +1690,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ...(isCodexResumeCursorSchema(input.resumeCursor) ? { resumeCursor: input.resumeCursor } : {}), + ...(input.continuationMode === "fork" ? { continuationMode: "fork" as const } : {}), runtimeMode: input.runtimeMode, ...(input.modelSelection?.instanceId === boundInstanceId ? { model: input.modelSelection.model } @@ -1897,11 +1898,17 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( { excludeProviderThreadIds: input?.excludeProviderThreadIds ?? new Set(), cursorByProviderThreadId: input?.cursorByProviderThreadId ?? new Map(), - stopAfterKnownPage: completedInitialPersistedDiscovery, + stopAfterKnownPage: + input?.includeUnchangedMetadata === true ? false : completedInitialPersistedDiscovery, + ...(input?.includeUnchangedMetadata !== undefined + ? { includeUnchangedMetadata: input.includeUnchangedMetadata } + : {}), }, - completedInitialPersistedDiscovery + input?.includeUnchangedMetadata === true ? undefined - : { startCursor: initialPersistedDiscoveryCursor, maxPages: 1 }, + : completedInitialPersistedDiscovery + ? undefined + : { startCursor: initialPersistedDiscoveryCursor, maxPages: 1 }, ).pipe( Effect.tap((result) => Effect.sync(() => { diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index fa06d6a27ed2..c1571a85bff5 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -775,12 +775,60 @@ describe("isRecoverableThreadResumeError", () => { }); describe("openCodexThread", () => { + it.effect("forks only when the caller explicitly chooses to continue in a copy", () => + Effect.gen(function* () { + const calls: Array<{ + method: "thread/start" | "thread/resume" | "thread/fork"; + payload: unknown; + }> = []; + const forked = makeThreadOpenResponse("forked-thread"); + const client = { + request: ( + method: M, + payload: CodexRpc.ClientRequestParamsByMethod[M], + ) => { + calls.push({ method, payload }); + return Effect.succeed(forked as CodexRpc.ClientRequestResponsesByMethod[M]); + }, + }; + + const opened = yield* openCodexThread({ + client, + threadId: ThreadId.make("thread-1"), + runtimeMode: "full-access", + cwd: "/tmp/project", + requestedModel: "gpt-5.3-codex", + serviceTier: undefined, + resumeThreadId: "provider-thread", + continuationMode: "fork", + }); + + NodeAssert.equal(opened.thread.id, "forked-thread"); + NodeAssert.deepStrictEqual(calls, [ + { + method: "thread/fork", + payload: { + threadId: "provider-thread", + cwd: "/tmp/project", + approvalPolicy: "never", + sandbox: "danger-full-access", + approvalsReviewer: "user", + model: "gpt-5.3-codex", + }, + }, + ]); + }), + ); + it.effect("falls back to thread/start when resume fails recoverably", () => Effect.gen(function* () { - const calls: Array<{ method: "thread/start" | "thread/resume"; payload: unknown }> = []; + const calls: Array<{ + method: "thread/start" | "thread/resume" | "thread/fork"; + payload: unknown; + }> = []; const started = makeThreadOpenResponse("fresh-thread"); const client = { - request: ( + request: ( method: M, payload: CodexRpc.ClientRequestParamsByMethod[M], ) => { @@ -818,7 +866,7 @@ describe("openCodexThread", () => { it.effect("does not fork a fresh thread when the durable thread is owned elsewhere", () => Effect.gen(function* () { const client = { - request: ( + request: ( method: M, _payload: CodexRpc.ClientRequestParamsByMethod[M], ) => { diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index b34067b7fb90..9d2a5d25df87 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -159,6 +159,7 @@ export interface CodexSessionRuntimeOptions { readonly model?: string; readonly serviceTier?: CodexServiceTier | undefined; readonly resumeCursor?: CodexResumeCursor; + readonly continuationMode?: "fork"; readonly appServerArgs?: ReadonlyArray; } @@ -671,9 +672,10 @@ export function isRecoverableThreadResumeError(error: unknown): boolean { type CodexThreadOpenResponse = | CodexRpc.ClientRequestResponsesByMethod["thread/start"] - | CodexRpc.ClientRequestResponsesByMethod["thread/resume"]; + | CodexRpc.ClientRequestResponsesByMethod["thread/resume"] + | CodexRpc.ClientRequestResponsesByMethod["thread/fork"]; -type CodexThreadOpenMethod = "thread/start" | "thread/resume"; +type CodexThreadOpenMethod = "thread/start" | "thread/resume" | "thread/fork"; interface CodexThreadOpenClient { readonly request: ( @@ -690,6 +692,7 @@ export const openCodexThread = (input: { readonly requestedModel: string | undefined; readonly serviceTier: CodexServiceTier | undefined; readonly resumeThreadId: string | undefined; + readonly continuationMode?: "resume" | "fork"; }): Effect.Effect => { const resumeThreadId = input.resumeThreadId; const startParams = buildThreadStartParams({ @@ -703,6 +706,19 @@ export const openCodexThread = (input: { return input.client.request("thread/start", startParams); } + if (input.continuationMode === "fork") { + const config = runtimeModeToThreadConfig(input.runtimeMode); + return input.client.request("thread/fork", { + threadId: resumeThreadId, + cwd: input.cwd, + approvalPolicy: config.approvalPolicy, + approvalsReviewer: config.approvalsReviewer, + sandbox: config.sandbox, + ...(input.requestedModel ? { model: input.requestedModel } : {}), + ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), + }); + } + return input.client .request("thread/resume", { threadId: resumeThreadId, @@ -2040,6 +2056,7 @@ export const makeCodexSessionRuntime = ( requestedModel, serviceTier: options.serviceTier, resumeThreadId: readResumeCursorThreadId(options.resumeCursor), + ...(options.continuationMode === "fork" ? { continuationMode: "fork" as const } : {}), }); const providerThreadId = opened.thread.id; diff --git a/apps/server/src/provider/Layers/CodexThreadDiscovery.test.ts b/apps/server/src/provider/Layers/CodexThreadDiscovery.test.ts index 97e2966e84d0..c97b35eab97f 100644 --- a/apps/server/src/provider/Layers/CodexThreadDiscovery.test.ts +++ b/apps/server/src/provider/Layers/CodexThreadDiscovery.test.ts @@ -4,6 +4,7 @@ import type * as EffectCodexSchema from "effect-codex-app-server/schema"; import { listCodexThreadsForRead, + materializeCodexPersistedThreads, readCodexThreadSnapshots, selectCodexThreadsForRead, toPersistedThread, @@ -243,6 +244,84 @@ it.effect("stops incremental paging after the first fully known page", () => }), ); +it.effect("returns unchanged visible threads as metadata during an explicit full scan", () => + Effect.gen(function* () { + const requestedCursors: Array = []; + const known = makeListedThread("known"); + const pages = new Map([ + [ + undefined, + { + data: [known, makeListedThread("new")], + nextCursor: "older-page", + } as EffectCodexSchema.V2ThreadListResponse, + ], + [ + "older-page", + { + data: [ + makeListedThread("older-known"), + makeListedThread("ephemeral", { ephemeral: true }), + ], + nextCursor: null, + } as EffectCodexSchema.V2ThreadListResponse, + ], + ]); + + const result = yield* listCodexThreadsForRead( + { + excludeProviderThreadIds: new Set(), + cursorByProviderThreadId: new Map([ + ["known", "2026-05-03T03:09:20.000Z:idle"], + ["older-known", "2026-05-03T03:09:20.000Z:idle"], + ]), + stopAfterKnownPage: true, + includeUnchangedMetadata: true, + }, + (cursor) => + Effect.sync(() => { + requestedCursors.push(cursor); + return pages.get(cursor)!; + }), + ); + + expect(result.threads.map((thread) => thread.id)).toEqual(["new"]); + expect(result.metadataThreads.map((thread) => thread.id)).toEqual(["known", "older-known"]); + expect(requestedCursors).toEqual([undefined, "older-page"]); + }), +); + +it.effect("materializes unchanged metadata without re-reading its transcript", () => + Effect.gen(function* () { + const readThreadIds: string[] = []; + const persisted = yield* materializeCodexPersistedThreads( + { + threads: [makeListedThread("changed")], + metadataThreads: [makeListedThread("unchanged", { preview: "Existing work" })], + }, + (threadId) => + Effect.sync(() => { + readThreadIds.push(threadId); + return { + thread: makeReadThread({ id: threadId, preview: "Changed work" }), + } as EffectCodexSchema.V2ThreadReadResponse; + }), + ); + + expect(readThreadIds).toEqual(["changed"]); + expect( + persisted.map((thread) => ({ + id: thread.providerThreadId, + title: thread.title, + messages: thread.messages, + })), + ).toEqual([ + { id: "changed", title: "Changed work", messages: [] }, + { id: "unchanged", title: "Existing work", messages: [] }, + ]); + }), +); + it.effect("returns the next cursor when an initial scan pauses after one page", () => Effect.gen(function* () { const requestedCursors: Array = []; diff --git a/apps/server/src/provider/Layers/CodexThreadDiscovery.ts b/apps/server/src/provider/Layers/CodexThreadDiscovery.ts index 297b5d75c8ed..4bfc13c217b7 100644 --- a/apps/server/src/provider/Layers/CodexThreadDiscovery.ts +++ b/apps/server/src/provider/Layers/CodexThreadDiscovery.ts @@ -44,17 +44,24 @@ function discoveryCursorForThread( return `${unixSecondsToIso(thread.updatedAt)}:${thread.status.type}`; } +function isVisibleCodexThread( + thread: EffectCodexSchema.V2ThreadListResponse["data"][number], + discoveryInput?: ProviderPersistedThreadDiscoveryInput, +): boolean { + return !( + thread.ephemeral || + (typeof thread.source === "object" && "subAgent" in thread.source) || + thread.threadSource === "memory_consolidation" || + discoveryInput?.excludeProviderThreadIds.has(thread.id) === true + ); +} + export function selectCodexThreadsForRead( threads: ReadonlyArray, discoveryInput?: ProviderPersistedThreadDiscoveryInput, ): ReadonlyArray { return threads.filter((thread) => { - if ( - thread.ephemeral || - (typeof thread.source === "object" && "subAgent" in thread.source) || - thread.threadSource === "memory_consolidation" || - discoveryInput?.excludeProviderThreadIds.has(thread.id) === true - ) { + if (!isVisibleCodexThread(thread, discoveryInput)) { return false; } const knownCursor = discoveryInput?.cursorByProviderThreadId.get(thread.id); @@ -156,6 +163,43 @@ export function readCodexThreadSnapshots( ).pipe(Effect.map((threads) => threads.filter(Option.isSome).map((thread) => thread.value))); } +function toPersistedThreadMetadata( + thread: EffectCodexSchema.V2ThreadListResponse["data"][number], +): ProviderPersistedThread { + return { + providerThreadId: thread.id, + cwd: thread.cwd, + title: titleForThread(thread as EffectCodexSchema.V2ThreadReadResponse["thread"]), + createdAt: unixSecondsToIso(thread.createdAt), + updatedAt: unixSecondsToIso(thread.updatedAt), + discoveryCursor: discoveryCursorForThread(thread), + sourceMetadata: { + source: thread.source, + threadSource: thread.threadSource ?? null, + status: thread.status, + sessionId: thread.sessionId, + forkedFromId: thread.forkedFromId ?? null, + parentThreadId: thread.parentThreadId ?? null, + cliVersion: thread.cliVersion, + modelProvider: thread.modelProvider, + gitInfo: thread.gitInfo ?? null, + }, + messages: [], + }; +} + +export function materializeCodexPersistedThreads( + listed: { + readonly threads: ReadonlyArray; + readonly metadataThreads: ReadonlyArray; + }, + readThread: (threadId: string) => Effect.Effect, +): Effect.Effect> { + return readCodexThreadSnapshots(listed.threads, readThread).pipe( + Effect.map((threads) => [...threads, ...listed.metadataThreads.map(toPersistedThreadMetadata)]), + ); +} + const makeCodexDiscoveryClient = Effect.fn("makeCodexDiscoveryClient")(function* ( config: CodexSettings, environment?: NodeJS.ProcessEnv, @@ -234,12 +278,14 @@ export const listCodexThreadsForRead = ( ): Effect.Effect< { readonly threads: ReadonlyArray; + readonly metadataThreads: ReadonlyArray; readonly nextCursor: string | null; }, E > => Effect.gen(function* () { const selected: Array = []; + const metadataThreads: Array = []; let cursor: string | null | undefined = pagination?.startCursor; let pagesRead = 0; do { @@ -247,15 +293,27 @@ export const listCodexThreadsForRead = ( pagesRead += 1; const changed = selectCodexThreadsForRead(page.data, discoveryInput); selected.push(...changed); + if (discoveryInput?.includeUnchangedMetadata === true) { + const changedIds = new Set(changed.map((thread) => thread.id)); + metadataThreads.push( + ...page.data.filter( + (thread) => isVisibleCodexThread(thread, discoveryInput) && !changedIds.has(thread.id), + ), + ); + } cursor = page.nextCursor; - if (discoveryInput?.stopAfterKnownPage === true && changed.length === 0) { + if ( + discoveryInput?.stopAfterKnownPage === true && + discoveryInput.includeUnchangedMetadata !== true && + changed.length === 0 + ) { break; } if (pagination?.maxPages !== undefined && pagesRead >= pagination.maxPages) { break; } } while (cursor); - return { threads: selected, nextCursor: cursor ?? null }; + return { threads: selected, metadataThreads, nextCursor: cursor ?? null }; }).pipe(Effect.withSpan("listCodexThreadsForRead")); export const discoverCodexThreads = Effect.fn("discoverCodexThreads")(function* ( @@ -280,7 +338,7 @@ export const discoverCodexThreads = Effect.fn("discoverCodexThreads")(function* pagination, ); - const threads = yield* readCodexThreadSnapshots(listed.threads, (threadId) => + const threads = yield* materializeCodexPersistedThreads(listed, (threadId) => client.request("thread/read", { threadId, includeTurns: true }), ); return { threads, nextCursor: listed.nextCursor }; diff --git a/apps/server/src/provider/Layers/ProviderThreadReconciler.test.ts b/apps/server/src/provider/Layers/ProviderThreadReconciler.test.ts index aa481b67f130..89c7c5ce7e33 100644 --- a/apps/server/src/provider/Layers/ProviderThreadReconciler.test.ts +++ b/apps/server/src/provider/Layers/ProviderThreadReconciler.test.ts @@ -11,16 +11,19 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; -import type { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts"; -import type { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; import type { ProviderInstance } from "../ProviderDriver.ts"; +import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; import type { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; +import { ProviderSessionDirectory as ProviderSessionDirectoryService } from "../Services/ProviderSessionDirectory.ts"; import type { ProviderPersistedThread } from "../Services/ProviderAdapter.ts"; import { groupPersistedThreadDiscoveryCandidates, providerThreadDiscoveryExclusions, continuationIdentityDigest, recoverReconciliationCause, + reconcilePersistedProviderThreads, reconcilePersistedThread, resolvePersistedContinuationKey, } from "./ProviderThreadReconciler.ts"; @@ -134,6 +137,104 @@ it.effect("does not recover a reconciliation interruption", () => }), ); +it.effect("runs an explicit metadata backfill and reports organized progress", () => + Effect.gen(function* () { + const discoveryInputs: unknown[] = []; + const progress: unknown[] = []; + const syncInstance = { + ...instance, + enabled: true, + adapter: { + discoverPersistedThreads: (input: unknown) => + Effect.sync(() => { + discoveryInputs.push(input); + return [persistedThread]; + }), + }, + snapshot: { + getSnapshot: Effect.succeed({ + models: [{ slug: "gpt-5.6-sol", isDefault: true }], + }), + }, + } as unknown as ProviderInstance; + const commands: OrchestrationCommand[] = []; + + const summary = yield* reconcilePersistedProviderThreads({ + includeUnchangedMetadata: true, + onProgress: (next) => + Effect.sync(() => { + progress.push(next); + }), + }).pipe( + Effect.provideService(ProviderInstanceRegistry, { + listInstances: Effect.succeed([syncInstance]), + } as unknown as ProviderInstanceRegistry["Service"]), + Effect.provideService(ProviderSessionDirectoryService, { + listBindings: () => + Effect.succeed([ + { + threadId: importedThreadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: instance.instanceId, + resumeCursor: { threadId: PROVIDER_THREAD_ID }, + runtimePayload: { + imported: true, + continuationKey: CONTINUATION_KEY, + providerDiscoveryCursor: persistedThread.discoveryCursor, + }, + lastSeenAt: persistedThread.updatedAt, + }, + ]), + upsert: () => Effect.void, + } as unknown as ProviderSessionDirectory["Service"]), + Effect.provideService(ProjectionSnapshotQuery, { + getThreadShellById: () => + Effect.succeed( + Option.some({ + id: importedThreadId, + projectId: "provider-imports:legacy", + modelSelection: { instanceId: instance.instanceId, model: "gpt-5.6-sol" }, + }), + ), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.succeed(Option.none()), + getThreadDetailById: () => + Effect.succeed( + Option.some({ + messages: persistedThread.messages.map((message) => ({ + id: message.id, + role: message.role, + text: message.text, + })), + }), + ), + } as unknown as ProjectionSnapshotQuery["Service"]), + Effect.provideService(OrchestrationEngineService, { + dispatch: (command: OrchestrationCommand) => + Effect.sync(() => { + commands.push(command); + return { sequence: commands.length }; + }), + } as unknown as OrchestrationEngineService["Service"]), + ); + + expect(discoveryInputs).toEqual([expect.objectContaining({ includeUnchangedMetadata: true })]); + expect(summary).toEqual({ + total: 1, + completed: 1, + organized: 1, + updated: 0, + unchanged: 0, + failed: 0, + }); + expect(progress).toEqual([summary]); + expect(commands.map((command) => command.type)).toEqual([ + "project.create", + "thread.meta.update", + ]); + }), +); + it.effect("creates a project for an unmatched Codex workspace", () => Effect.gen(function* () { const commands: OrchestrationCommand[] = []; @@ -161,7 +262,7 @@ it.effect("creates a project for an unmatched Codex workspace", () => } as unknown as ProjectionSnapshotQuery["Service"]; const identities = new Map(); - yield* reconcilePersistedThread({ + const outcome = yield* reconcilePersistedThread({ instance, thread: persistedThread, model: "gpt-5.6-sol", @@ -214,6 +315,46 @@ it.effect("creates a project for an unmatched Codex workspace", () => providerDiscoveryCursor: "2026-08-21T03:24:43.000Z:idle", }, }); + expect(outcome).toBe("organized"); + }), +); + +it.effect("groups a Codex subdirectory session under its resolved Git root", () => + Effect.gen(function* () { + const commands: OrchestrationCommand[] = []; + + yield* reconcilePersistedThread({ + instance, + thread: { ...persistedThread, cwd: "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/work/repository/packages/client" }, + workspaceRoot: "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/work/repository", + model: "gpt-5.6-sol", + threadByProviderIdentity: new Map(), + directory: { + upsert: () => Effect.void, + } as unknown as ProviderSessionDirectory["Service"], + snapshots: { + getThreadShellById: () => Effect.succeed(Option.none()), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.succeed(Option.none()), + } as unknown as ProjectionSnapshotQuery["Service"], + engine: { + dispatch: (command: OrchestrationCommand) => + Effect.sync(() => { + commands.push(command); + return { sequence: commands.length }; + }), + } as unknown as OrchestrationEngineService["Service"], + }); + + expect(commands[0]).toMatchObject({ + type: "project.create", + title: "repository", + workspaceRoot: "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/work/repository", + }); + expect(commands[1]).toMatchObject({ + type: "thread.create", + projectId: `provider-workspace:${continuationIdentityDigest("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/work/repository")}`, + }); }), ); diff --git a/apps/server/src/provider/Layers/ProviderThreadReconciler.ts b/apps/server/src/provider/Layers/ProviderThreadReconciler.ts index 5abac517133f..d3caa27be029 100644 --- a/apps/server/src/provider/Layers/ProviderThreadReconciler.ts +++ b/apps/server/src/provider/Layers/ProviderThreadReconciler.ts @@ -6,6 +6,7 @@ import { DEFAULT_MODEL_BY_PROVIDER, MessageId, ProjectId, + type ProviderThreadSyncProgress, ProviderDriverKind, ThreadId, TurnId, @@ -16,12 +17,14 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import { forkParked } from "../../serverActivation.ts"; import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; import type { ProviderInstance } from "../ProviderDriver.ts"; import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; @@ -30,6 +33,7 @@ import { ProviderThreadContinuityError, } from "../Services/ProviderThreadContinuity.ts"; import type { ProviderPersistedThread } from "../Services/ProviderAdapter.ts"; +import { makeProviderThreadSyncCoordinator } from "./ProviderThreadSyncCoordinator.ts"; const CODEX = ProviderDriverKind.make("codex"); const ACTIVE_RECONCILE_INTERVAL = Duration.seconds(30); @@ -186,7 +190,13 @@ export function groupPersistedThreadDiscoveryCandidates( } export const reconcilePersistedProviderThreads = Effect.fn("reconcilePersistedProviderThreads")( - function* () { + function* ( + options: { + readonly includeUnchangedMetadata?: boolean; + readonly resolveWorkspaceRoot?: (cwd: string) => Effect.Effect; + readonly onProgress?: (progress: ProviderThreadSyncProgress) => Effect.Effect; + } = {}, + ) { const registry = yield* ProviderInstanceRegistry; const directory = yield* ProviderSessionDirectory; const snapshots = yield* ProjectionSnapshotQuery; @@ -206,6 +216,14 @@ export const reconcilePersistedProviderThreads = Effect.fn("reconcilePersistedPr const cursorByThreadIdByContinuation = new Map>(); const importedOwnerInstanceIdsByContinuation = new Map>(); const unresolvedNativeProviderThreadIds = new Set(); + const progress = yield* Ref.make({ + total: 0, + completed: 0, + organized: 0, + updated: 0, + unchanged: 0, + failed: 0, + }); for (const binding of bindings) { if ( @@ -253,7 +271,7 @@ export const reconcilePersistedProviderThreads = Effect.fn("reconcilePersistedPr } } - const discoveredCounts = yield* Effect.forEach( + yield* Effect.forEach( discoveryCandidateGroups, (candidates) => Effect.gen(function* () { @@ -288,6 +306,9 @@ export const reconcilePersistedProviderThreads = Effect.fn("reconcilePersistedPr cursorByProviderThreadId: ownerChanged ? new Map() : (cursorByThreadIdByContinuation.get(continuationKey) ?? new Map()), + ...(options.includeUnchangedMetadata !== undefined + ? { includeUnchangedMetadata: options.includeUnchangedMetadata } + : {}), }); return { instance, model, discovered } as const; }).pipe( @@ -310,31 +331,53 @@ export const reconcilePersistedProviderThreads = Effect.fn("reconcilePersistedPr if (!selected) return 0; const { instance, model, discovered } = selected; + yield* Ref.update(progress, (current) => ({ + ...current, + total: current.total + discovered.length, + })); + yield* Effect.forEach( discovered, (thread) => - reconcilePersistedThread({ - instance, - thread, - model, - threadByProviderIdentity, - directory, - snapshots, - engine, - }).pipe( - Effect.catchCause((cause) => - recoverReconciliationCause( - cause, - "skipped persisted provider thread during reconciliation", - { - provider: instance.driverKind, - providerInstanceId: instance.instanceId, - providerThreadId: thread.providerThreadId, - }, - undefined, + Effect.gen(function* () { + const workspaceRoot = options.resolveWorkspaceRoot + ? yield* options.resolveWorkspaceRoot(thread.cwd) + : thread.cwd; + const outcome = yield* reconcilePersistedThread({ + instance, + thread, + workspaceRoot, + model, + threadByProviderIdentity, + directory, + snapshots, + engine, + }).pipe( + Effect.map(Option.some), + Effect.catchCause((cause) => + recoverReconciliationCause( + cause, + "skipped persisted provider thread during reconciliation", + { + provider: instance.driverKind, + providerInstanceId: instance.instanceId, + providerThreadId: thread.providerThreadId, + }, + Option.none(), + ), ), - ), - ), + ); + const next = yield* Ref.modify(progress, (current) => { + const key = Option.isSome(outcome) ? outcome.value : "failed"; + const updated = { + ...current, + completed: current.completed + 1, + [key]: current[key] + 1, + }; + return [updated, updated] as const; + }); + if (options.onProgress) yield* options.onProgress(next); + }), { concurrency: 1, discard: true }, ); return discovered.length; @@ -355,7 +398,9 @@ export const reconcilePersistedProviderThreads = Effect.fn("reconcilePersistedPr ), { concurrency: "unbounded" }, ); - return discoveredCounts.reduce((total, count) => total + count, 0); + const result = yield* Ref.get(progress); + if (result.completed === 0 && options.onProgress) yield* options.onProgress(result); + return result; }, ); @@ -363,12 +408,16 @@ export const reconcilePersistedThread = Effect.fn("reconcilePersistedProviderThr function* (input: { readonly instance: ProviderInstance; readonly thread: ProviderPersistedThread; + readonly workspaceRoot?: string; readonly model: string; readonly threadByProviderIdentity: Map; readonly directory: ProviderSessionDirectory["Service"]; readonly snapshots: ProjectionSnapshotQuery["Service"]; readonly engine: OrchestrationEngineService["Service"]; }) { + const workspaceRoot = input.workspaceRoot ?? input.thread.cwd; + let organized = false; + let updated = false; const continuationKey = input.instance.continuationIdentity.continuationKey; const continuationIdentity = continuationIdentityDigest(continuationKey); const identity = providerIdentityKey(continuationKey, input.thread.providerThreadId); @@ -394,27 +443,28 @@ export const reconcilePersistedThread = Effect.fn("reconcilePersistedProviderThr sourceMetadata: input.thread.sourceMetadata, }, }); - return; + return "unchanged" as const; } const matchingProject = managesImportedThread - ? yield* input.snapshots.getActiveProjectByWorkspaceRoot(input.thread.cwd) + ? yield* input.snapshots.getActiveProjectByWorkspaceRoot(workspaceRoot) : Option.none(); const projectId = Option.isSome(matchingProject) ? matchingProject.value.id : managesImportedThread - ? workspaceProjectId(input.thread.cwd) + ? workspaceProjectId(workspaceRoot) : Option.getOrThrow(existingThread).projectId; if (managesImportedThread && Option.isNone(matchingProject)) { const existingWorkspaceProject = yield* input.snapshots.getProjectShellById(projectId); if (Option.isNone(existingWorkspaceProject)) { + organized = true; yield* input.engine.dispatch({ type: "project.create", - commandId: importCommandId("workspace", continuationIdentityDigest(input.thread.cwd)), + commandId: importCommandId("workspace", continuationIdentityDigest(workspaceRoot)), projectId, - title: workspaceProjectTitle(input.thread.cwd), - workspaceRoot: input.thread.cwd, + title: workspaceProjectTitle(workspaceRoot), + workspaceRoot, defaultModelSelection: { instanceId: input.instance.instanceId, model: input.model }, createdAt: input.thread.createdAt, }); @@ -422,6 +472,7 @@ export const reconcilePersistedThread = Effect.fn("reconcilePersistedProviderThr } if (importsNewThread && Option.isNone(existingThread)) { + organized = true; yield* input.engine.dispatch({ type: "thread.create", commandId: importCommandId(continuationIdentity, input.thread.providerThreadId, "create"), @@ -441,6 +492,8 @@ export const reconcilePersistedThread = Effect.fn("reconcilePersistedProviderThr existingThread.value.modelSelection.model !== input.model; const projectChanged = existingThread.value.projectId !== projectId; if (ownerChanged || projectChanged) { + organized ||= projectChanged; + updated ||= ownerChanged; yield* input.engine.dispatch({ type: "thread.meta.update", commandId: importCommandId( @@ -530,6 +583,7 @@ export const reconcilePersistedThread = Effect.fn("reconcilePersistedProviderThr } return true; }); + updated ||= missingMessages.length > 0; yield* Effect.forEach( missingMessages, @@ -582,6 +636,11 @@ export const reconcilePersistedThread = Effect.fn("reconcilePersistedProviderThr }, }); input.threadByProviderIdentity.set(identity, targetThreadId); + return organized + ? ("organized" as const) + : updated + ? ("updated" as const) + : ("unchanged" as const); }, ); @@ -670,6 +729,7 @@ export const ProviderThreadReconcilerLive = Layer.effect( | ProviderSessionDirectory >(); const registry = yield* ProviderInstanceRegistry; + const repositoryIdentityResolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const changes = yield* registry.subscribeChanges; const reconcileSemaphore = yield* Semaphore.make(1); const reconcileAll = reconcileSemaphore.withPermits(1)( @@ -680,7 +740,14 @@ export const ProviderThreadReconcilerLive = Layer.effect( cause, "persisted provider thread reconciliation failed", {}, - 0, + { + total: 0, + completed: 0, + organized: 0, + updated: 0, + unchanged: 0, + failed: 0, + }, ), ), ), @@ -706,18 +773,36 @@ export const ProviderThreadReconcilerLive = Layer.effect( ), ); }); + const resolveWorkspaceRoot = (cwd: string) => + repositoryIdentityResolver + .resolve(cwd) + .pipe(Effect.map((identity) => identity?.rootPath ?? cwd)); + const syncCoordinator = yield* makeProviderThreadSyncCoordinator((onProgress) => + reconcileSemaphore.withPermits(1)( + reconcilePersistedProviderThreads({ + includeUnchangedMetadata: true, + resolveWorkspaceRoot, + onProgress, + }).pipe(Effect.provideContext(continuityContext)), + ), + ); yield* forkParked( Effect.forever( reconcileAll.pipe( - Effect.flatMap((discoveredCount) => - Effect.sleep(discoveredCount > 0 ? ACTIVE_RECONCILE_INTERVAL : IDLE_RECONCILE_INTERVAL), + Effect.flatMap((progress) => + Effect.sleep(progress.total > 0 ? ACTIVE_RECONCILE_INTERVAL : IDLE_RECONCILE_INTERVAL), ), ), ), ); yield* forkParked(Effect.forever(PubSub.take(changes).pipe(Effect.andThen(reconcileAll)))); - return ProviderThreadContinuity.of({ reconcileThread }); + return ProviderThreadContinuity.of({ + reconcileThread, + startSyncAll: syncCoordinator.start, + getSyncStatus: syncCoordinator.getStatus, + streamSyncStatus: syncCoordinator.changes, + }); }), ); diff --git a/apps/server/src/provider/Layers/ProviderThreadSyncCoordinator.test.ts b/apps/server/src/provider/Layers/ProviderThreadSyncCoordinator.test.ts new file mode 100644 index 000000000000..c643444eb6c1 --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderThreadSyncCoordinator.test.ts @@ -0,0 +1,54 @@ +import { expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; + +import { makeProviderThreadSyncCoordinator } from "./ProviderThreadSyncCoordinator.ts"; + +it.effect("keeps a sync running independently and replays its terminal progress", () => + Effect.gen(function* () { + const finish = yield* Deferred.make(); + const coordinator = yield* makeProviderThreadSyncCoordinator((reportProgress) => + reportProgress({ + total: 2, + completed: 1, + organized: 1, + updated: 0, + unchanged: 0, + failed: 0, + }).pipe( + Effect.andThen(Deferred.await(finish)), + Effect.as({ + total: 2, + completed: 2, + organized: 1, + updated: 0, + unchanged: 1, + failed: 0, + }), + ), + ); + const observedFiber = yield* coordinator.changes.pipe( + Stream.takeUntil((status) => status.status === "completed"), + Stream.runCollect, + Effect.forkChild, + ); + + const started = yield* coordinator.start; + expect(started.status).toBe("running"); + expect(started.status === "running" ? started.phase : null).toBe("discovering"); + + yield* Deferred.succeed(finish, undefined); + const observed = Array.from(yield* Fiber.join(observedFiber)); + expect( + observed.map((status) => + status.status === "running" ? `${status.status}:${status.phase}` : status.status, + ), + ).toEqual(["running:discovering", "running:reconciling", "completed"]); + + const replayed = Option.getOrThrow(yield* coordinator.changes.pipe(Stream.runHead)); + expect(replayed).toEqual(observed.at(-1)); + }), +); diff --git a/apps/server/src/provider/Layers/ProviderThreadSyncCoordinator.ts b/apps/server/src/provider/Layers/ProviderThreadSyncCoordinator.ts new file mode 100644 index 000000000000..10def4d9e3e7 --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderThreadSyncCoordinator.ts @@ -0,0 +1,104 @@ +import type { ProviderThreadSyncProgress, ProviderThreadSyncStatus } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; + +import { subscribeBeforeSnapshot } from "../../utils/subscribeBeforeSnapshot.ts"; + +const EMPTY_PROGRESS: ProviderThreadSyncProgress = { + total: 0, + completed: 0, + organized: 0, + updated: 0, + unchanged: 0, + failed: 0, +}; + +export interface ProviderThreadSyncCoordinator { + readonly start: Effect.Effect; + readonly getStatus: Effect.Effect; + readonly changes: Stream.Stream; +} + +export const makeProviderThreadSyncCoordinator = Effect.fn("makeProviderThreadSyncCoordinator")( + function* ( + run: ( + reportProgress: (progress: ProviderThreadSyncProgress) => Effect.Effect, + ) => Effect.Effect, + ) { + const lifetime = yield* Scope.Scope; + const state = yield* Ref.make({ status: "idle" }); + const changes = yield* PubSub.unbounded(); + const mutex = yield* Semaphore.make(1); + + const publish = (status: ProviderThreadSyncStatus) => + mutex.withPermits(1)( + Ref.set(state, status).pipe(Effect.andThen(PubSub.publish(changes, status)), Effect.asVoid), + ); + + const start = mutex.withPermits(1)( + Effect.gen(function* () { + const current = yield* Ref.get(state); + if (current.status === "running") return current; + + const startedAt = DateTime.formatIso(yield* DateTime.now); + const started: ProviderThreadSyncStatus = { + status: "running", + phase: "discovering", + startedAt, + progress: EMPTY_PROGRESS, + }; + yield* Ref.set(state, started); + yield* PubSub.publish(changes, started); + + const operation = run((progress) => + publish({ status: "running", phase: "reconciling", startedAt, progress }), + ).pipe( + Effect.exit, + Effect.flatMap((exit) => + DateTime.now.pipe( + Effect.map(DateTime.formatIso), + Effect.flatMap((finishedAt) => + Exit.match(exit, { + onSuccess: (progress) => + publish({ status: "completed", startedAt, finishedAt, progress }), + onFailure: () => + Ref.get(state).pipe( + Effect.flatMap((latest) => + publish({ + status: "failed", + startedAt, + finishedAt, + progress: latest.status === "idle" ? EMPTY_PROGRESS : latest.progress, + message: "Could not synchronize Codex sessions.", + }), + ), + ), + }), + ), + ), + ), + ); + yield* Effect.forkIn(operation, lifetime); + return started; + }), + ); + + const stream = Stream.unwrap( + subscribeBeforeSnapshot(changes, Ref.get(state), mutex).pipe( + Effect.map(({ latest, changes }) => Stream.concat(Stream.succeed(latest), changes)), + ), + ); + + return { + start, + getStatus: Ref.get(state), + changes: stream, + } satisfies ProviderThreadSyncCoordinator; + }, +); diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index fcf5e88ac8a2..8b3120c94b2e 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -69,6 +69,12 @@ export interface ProviderPersistedThreadDiscoveryInput { readonly cursorByProviderThreadId: ReadonlyMap; /** Stop paging once a full page contains no new or changed eligible threads. */ readonly stopAfterKnownPage?: boolean; + /** + * Return unchanged visible threads as metadata-only snapshots while paging the + * complete provider history. This lets an explicit maintenance pass organize + * old sessions without re-reading every transcript. + */ + readonly includeUnchangedMetadata?: boolean; } export interface ProviderAdapterShape { diff --git a/apps/server/src/provider/Services/ProviderThreadContinuity.ts b/apps/server/src/provider/Services/ProviderThreadContinuity.ts index 55f1e6041edb..dd1d1264c8bf 100644 --- a/apps/server/src/provider/Services/ProviderThreadContinuity.ts +++ b/apps/server/src/provider/Services/ProviderThreadContinuity.ts @@ -1,7 +1,8 @@ -import { ThreadId } from "@t3tools/contracts"; +import { type ProviderThreadSyncStatus, ThreadId } from "@t3tools/contracts"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +import type * as Stream from "effect/Stream"; export class ProviderThreadContinuityError extends Schema.TaggedErrorClass()( "ProviderThreadContinuityError", @@ -21,6 +22,9 @@ export interface ProviderThreadContinuityShape { readonly reconcileThread: ( threadId: ThreadId, ) => Effect.Effect; + readonly startSyncAll: Effect.Effect; + readonly getSyncStatus: Effect.Effect; + readonly streamSyncStatus: Stream.Stream; } export class ProviderThreadContinuity extends Context.Service< diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 85613ed691cb..1ecd43d7a36b 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -655,6 +655,9 @@ const buildAppUnderTest = (options?: { }), Layer.mock(ProviderThreadContinuity.ProviderThreadContinuity)({ reconcileThread: () => Effect.succeed(false), + startSyncAll: Effect.succeed({ status: "idle" }), + getSyncStatus: Effect.succeed({ status: "idle" }), + streamSyncStatus: Stream.make({ status: "idle" }), ...options?.layers?.providerThreadContinuity, }), ), @@ -4494,6 +4497,54 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("starts Codex history synchronization remotely and streams server-owned progress", () => + Effect.gen(function* () { + const running = { + status: "running" as const, + phase: "reconciling" as const, + startedAt: "2026-08-26T20:00:00.000Z", + progress: { + total: 91, + completed: 18, + organized: 12, + updated: 4, + unchanged: 2, + failed: 0, + }, + }; + const completed = { + status: "completed" as const, + startedAt: running.startedAt, + finishedAt: "2026-08-26T20:01:00.000Z", + progress: { ...running.progress, completed: 91, organized: 80, unchanged: 7 }, + }; + yield* buildAppUnderTest({ + layers: { + providerThreadContinuity: { + startSyncAll: Effect.succeed(running), + getSyncStatus: Effect.succeed(running), + streamSyncStatus: Stream.make(running, completed), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const started = yield* client[WS_METHODS.serverStartProviderThreadSync]({}); + const progress = yield* client[WS_METHODS.subscribeProviderThreadSync]({}).pipe( + Stream.runCollect, + ); + return { started, progress: Array.from(progress) }; + }), + ), + ); + + assert.deepStrictEqual(result, { started: running, progress: [running, completed] }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("uploads image bytes through a signed URL issued by websocket rpc", () => Effect.gen(function* () { const config = yield* buildAppUnderTest(); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index f603a02b9ddd..7c4572aee630 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1606,6 +1606,12 @@ const makeWsRpcLayer = ( ).pipe(Effect.map((providers) => ({ providers }))), { "rpc.aggregate": "server" }, ), + [WS_METHODS.serverStartProviderThreadSync]: (_input) => + observeRpcEffect( + WS_METHODS.serverStartProviderThreadSync, + providerThreadContinuity.startSyncAll, + { "rpc.aggregate": "server" }, + ), [WS_METHODS.providerUploadFeedback]: (input) => observeRpcEffect( WS_METHODS.providerUploadFeedback, @@ -1659,6 +1665,12 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "server" }, ), + [WS_METHODS.subscribeProviderThreadSync]: (_input) => + observeRpcStream( + WS_METHODS.subscribeProviderThreadSync, + providerThreadContinuity.streamSyncStatus, + { "rpc.aggregate": "server" }, + ), [WS_METHODS.serverUpsertKeybinding]: (rule) => observeRpcEffect( WS_METHODS.serverUpsertKeybinding, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 858a02add8d7..18a8a328f7cd 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -301,6 +301,7 @@ import { } from "./chat/ProviderStatusBanner"; import { dismissThreadErrorBannerForSession, + getActiveWriterRecoveryMessageId, getThreadErrorBannerKey, isThreadErrorBannerDismissedForSession, shouldShowThreadErrorBanner, @@ -1298,6 +1299,9 @@ function ChatViewContent(props: ChatViewProps) { reportFailure: false, }); const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); + const recoverThreadTurn = useAtomCommand(threadEnvironment.recoverTurn, { + reportFailure: false, + }); const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, { reportFailure: false, }); @@ -1612,6 +1616,47 @@ function ChatViewContent(props: ChatViewProps) { const threadError = isServerThread ? (localServerError ?? activeServerThread?.session?.lastError ?? null) : localDraftError; + const activeWriterRecoveryMessageIdRaw = isServerThread + ? getActiveWriterRecoveryMessageId({ + sessionStatus: activeServerThread?.session?.status, + error: threadError, + activities: activeServerThread?.activities ?? [], + }) + : null; + const activeWriterRecoveryMessageId = + activeThread?.messages.find( + (message) => message.role === "user" && message.id === activeWriterRecoveryMessageIdRaw, + )?.id ?? null; + const [activeWriterRecoveryPending, setActiveWriterRecoveryPending] = useState(false); + useEffect(() => { + if (activeWriterRecoveryMessageId === null) { + setActiveWriterRecoveryPending(false); + } + }, [activeWriterRecoveryMessageId]); + const handleActiveWriterRecovery = useCallback( + async (strategy: "retry" | "fork") => { + if (!activeThread || activeWriterRecoveryMessageId === null) return; + setActiveWriterRecoveryPending(true); + const result = await recoverThreadTurn({ + environmentId, + input: { + threadId: activeThread.id, + messageId: activeWriterRecoveryMessageId, + strategy, + }, + }); + if (result._tag === "Failure") { + setActiveWriterRecoveryPending(false); + if (!isAtomCommandInterrupted(result)) { + toastManager.add({ + type: "error", + title: "Could not continue the Codex session", + }); + } + } + }, + [activeThread, activeWriterRecoveryMessageId, environmentId, recoverThreadTurn], + ); // Dismissals can only mask the shown error, never clear it: a server thread // keeps its error in session.lastError, so clearing the local shadow would // just fall through to the persisted one. Mask the current error until a @@ -6869,6 +6914,15 @@ function ChatViewContent(props: ChatViewProps) { void handleActiveWriterRecovery("retry"), + onFork: () => void handleActiveWriterRecovery("fork"), + }, + } + : {})} onDismiss={() => { setThreadError(activeThread.id, null); dismissThreadErrorBannerForSession(threadErrorBannerKey); diff --git a/apps/web/src/components/chat/ThreadErrorBanner.test.tsx b/apps/web/src/components/chat/ThreadErrorBanner.test.tsx index a3dbb27515ae..ff40a4ee61c8 100644 --- a/apps/web/src/components/chat/ThreadErrorBanner.test.tsx +++ b/apps/web/src/components/chat/ThreadErrorBanner.test.tsx @@ -4,12 +4,38 @@ import { describe, expect, it } from "vite-plus/test"; import { dismissThreadErrorBannerForSession, getThreadErrorBannerKey, + getActiveWriterRecoveryMessageId, isThreadErrorBannerDismissedForSession, shouldShowThreadErrorBanner, ThreadErrorBanner, } from "./ThreadErrorBanner"; describe("ThreadErrorBanner", () => { + it("derives recovery only from the current active-writer error", () => { + const activities = [ + { + kind: "provider.thread.active-writer-conflict", + payload: { messageId: "message-1", canFork: true }, + }, + ]; + + expect( + getActiveWriterRecoveryMessageId({ + sessionStatus: "error", + error: + "This Codex session is open in another client. Close it there and retry, or continue in a copy.", + activities, + }), + ).toBe("message-1"); + expect( + getActiveWriterRecoveryMessageId({ + sessionStatus: "starting", + error: null, + activities, + }), + ).toBeNull(); + }); + it("stays hidden after its current error is dismissed", () => { const bannerKey = getThreadErrorBannerKey("env:thread-a", "Aborted"); dismissThreadErrorBannerForSession(bannerKey); @@ -85,4 +111,21 @@ describe("ThreadErrorBanner", () => { expect(markup).toContain("h-lh w-4"); expect(markup).toContain("h-lh self-start"); }); + + it("explains how to release a Codex writer and offers retry or copy actions", () => { + const markup = renderToStaticMarkup( + {}, + onFork: () => {}, + }} + />, + ); + + expect(markup).toContain("Close the session in the Codex app or type /exit in its terminal"); + expect(markup).toContain("I've closed it — retry"); + expect(markup).toContain("Continue in a copy"); + }); }); diff --git a/apps/web/src/components/chat/ThreadErrorBanner.tsx b/apps/web/src/components/chat/ThreadErrorBanner.tsx index 62b211c3f856..15b9cb392c14 100644 --- a/apps/web/src/components/chat/ThreadErrorBanner.tsx +++ b/apps/web/src/components/chat/ThreadErrorBanner.tsx @@ -1,9 +1,30 @@ import { memo } from "react"; +import { CODEX_ACTIVE_WRITER_CONFLICT_MESSAGE } from "@t3tools/contracts"; import { Alert, AlertAction, AlertDescription } from "../ui/alert"; import { Button } from "../ui/button"; -import { CircleAlertIcon, XIcon } from "lucide-react"; +import { CircleAlertIcon, LoaderCircleIcon, XIcon } from "lucide-react"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +export function getActiveWriterRecoveryMessageId(input: { + sessionStatus: string | null | undefined; + error: string | null | undefined; + activities: ReadonlyArray<{ readonly kind: string; readonly payload: unknown }>; +}): string | null { + if (input.sessionStatus !== "error" || input.error !== CODEX_ACTIVE_WRITER_CONFLICT_MESSAGE) { + return null; + } + const conflict = input.activities.findLast( + (activity) => activity.kind === "provider.thread.active-writer-conflict", + ); + if (typeof conflict?.payload !== "object" || conflict.payload === null) { + return null; + } + const payload = conflict.payload as Record; + return payload.canFork === true && typeof payload.messageId === "string" + ? payload.messageId + : null; +} + export function getThreadErrorBannerKey(threadKey: string, error: string | null): string | null { return error === null ? null : `${threadKey}\u0000${error}`; } @@ -36,9 +57,15 @@ export function isThreadErrorBannerDismissedForSession(bannerKey: string | null) export const ThreadErrorBanner = memo(function ThreadErrorBanner({ error, onDismiss, + activeWriterRecovery, }: { error: string | null; onDismiss?: () => void; + activeWriterRecovery?: { + pending: boolean; + onRetry: () => void; + onFork: () => void; + }; }) { if (!error) return null; return ( @@ -46,12 +73,46 @@ export const ThreadErrorBanner = memo(function ThreadErrorBanner({ - - }>{error} - - {error} - - + {activeWriterRecovery ? ( +
+
+
{error}
+
+ Close the session in the Codex app or type /exit in its terminal, then retry. + Continuing in a copy keeps the original session open and creates a separate Codex + thread. +
+
+
+ + +
+
+ ) : ( + + }>{error} + + {error} + + + )}
{onDismiss && ( diff --git a/apps/web/src/components/chat/ThreadSyncStatusPill.test.tsx b/apps/web/src/components/chat/ThreadSyncStatusPill.test.tsx index 2a6a28b2becd..33a632eb69ef 100644 --- a/apps/web/src/components/chat/ThreadSyncStatusPill.test.tsx +++ b/apps/web/src/components/chat/ThreadSyncStatusPill.test.tsx @@ -1,22 +1,19 @@ -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it } from "vite-plus/test"; +import { expect, it } from "vite-plus/test"; +import { visitElements } from "../../test/reactElementTree"; import { ThreadSyncStatusPill } from "./ThreadSyncStatusPill"; -describe("ThreadSyncStatusPill", () => { - it.each([ - ["loading", "Loading messages..."], - ["syncing", "Syncing messages..."], - ] as const)("renders the %s message sync phase", (phase, label) => { - const markup = renderToStaticMarkup(); +it("shows an accessible motion-safe activity indicator while messages synchronize", () => { + const pill = ThreadSyncStatusPill({ phase: "syncing" }); + expect(pill.props.role).toBe("status"); + expect(pill.props["aria-live"]).toBe("polite"); + expect(pill.props["aria-label"]).toBe("Syncing messages..."); - expect(markup).toContain('role="status"'); - expect(markup).toContain('data-thread-sync-drawer="true"'); - expect(markup).toContain("chat-composer-drawer-surface"); - expect(markup).toContain("chat-composer-drawer-attached"); - expect(markup).toContain("chat-composer-drawer-slot"); - expect(markup).toContain("pb-[calc(var(--chat-composer-attachment-overlap)_+_0.375rem)]"); - expect(markup).toContain(label); - expect(markup).not.toContain("animate-"); - }); + const animatedIndicator = visitElements( + pill, + (element) => + typeof element.props.className === "string" && + element.props.className.includes("motion-safe:animate-spin"), + ); + expect(animatedIndicator).not.toBeNull(); }); diff --git a/apps/web/src/components/chat/ThreadSyncStatusPill.tsx b/apps/web/src/components/chat/ThreadSyncStatusPill.tsx index 31b5dc184191..a238fa99e67c 100644 --- a/apps/web/src/components/chat/ThreadSyncStatusPill.tsx +++ b/apps/web/src/components/chat/ThreadSyncStatusPill.tsx @@ -8,11 +8,15 @@ export function ThreadSyncStatusPill({ phase }: { readonly phase: ThreadSyncPhas return (
- + {label}
); diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx index 2b304378ae91..ee4265833a0f 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx @@ -4,6 +4,7 @@ import { EnvironmentId, ProviderDriverKind, ProviderInstanceId, + type ProviderThreadSyncStatus, type ServerProvider, type UnifiedSettings, } from "@t3tools/contracts"; @@ -17,11 +18,15 @@ const atoms = vi.hoisted(() => ({ providersAtom: Symbol("providers"), refreshProviders: Symbol("refreshProviders"), updateProvider: Symbol("updateProvider"), + syncStatus: { status: "idle" } as ProviderThreadSyncStatus, + syncStatusAtom: Symbol("syncStatus"), + startProviderThreadSync: Symbol("startProviderThreadSync"), })); const commands = vi.hoisted(() => ({ refresh: vi.fn(), updateProvider: vi.fn(), + startProviderThreadSync: vi.fn(), })); const settingsState = vi.hoisted(() => ({ @@ -49,7 +54,8 @@ vi.mock("react/compiler-runtime", async () => { }); vi.mock("@effect/atom-react", () => ({ - useAtomValue: () => atoms.providers, + useAtomValue: (atom: symbol) => + atom === atoms.providersAtom ? atoms.providers : atoms.syncStatus, })); vi.mock("../../state/server", () => ({ @@ -58,12 +64,18 @@ vi.mock("../../state/server", () => ({ providersValueAtom: () => atoms.providersAtom, refreshProviders: atoms.refreshProviders, updateProvider: atoms.updateProvider, + providerThreadSyncStatusAtom: () => atoms.syncStatusAtom, + startProviderThreadSync: atoms.startProviderThreadSync, }, })); vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: (atom: symbol) => - atom === atoms.refreshProviders ? commands.refresh : commands.updateProvider, + atom === atoms.refreshProviders + ? commands.refresh + : atom === atoms.startProviderThreadSync + ? commands.startProviderThreadSync + : commands.updateProvider, })); vi.mock("../../hooks/useSettings", () => ({ @@ -136,12 +148,14 @@ describe("EnvironmentProviderSettings routing", () => { beforeEach(() => { hooks.reset(); atoms.providers = null; + atoms.syncStatus = { status: "idle" }; settingsState.value = DEFAULT_UNIFIED_SETTINGS; settingsState.readEnvironmentIds = []; settingsState.updateEnvironmentIds = []; settingsState.updateSettings.mockReset(); commands.refresh.mockReset().mockResolvedValue({ _tag: "Success" }); commands.updateProvider.mockReset().mockResolvedValue({ _tag: "Success" }); + commands.startProviderThreadSync.mockReset().mockResolvedValue({ _tag: "Success" }); }); it("coalesces a nullable provider snapshot before rendering array-backed UI", () => { @@ -178,6 +192,49 @@ describe("EnvironmentProviderSettings routing", () => { }); }); + it("starts a server-owned Codex history sync and renders its durable progress", async () => { + atoms.providers = [provider()]; + atoms.syncStatus = { + status: "running", + phase: "reconciling", + startedAt: "2026-08-26T20:00:00.000Z", + progress: { + total: 91, + completed: 18, + organized: 12, + updated: 4, + unchanged: 2, + failed: 0, + }, + }; + const panel = renderPanel(); + const button = visitElements( + panel, + (element) => element.props["aria-label"] === "Synchronize Codex sessions", + ); + expect(button).not.toBeNull(); + expect(button?.props.disabled).toBe(true); + const progressRow = visitElements( + panel, + (element) => element.props.description === "18 of 91 sessions processed", + ); + expect(progressRow).not.toBeNull(); + + atoms.syncStatus = { status: "idle" }; + const idlePanel = renderPanel(); + const idleButton = visitElements( + idlePanel, + (element) => element.props["aria-label"] === "Synchronize Codex sessions", + ); + (idleButton?.props.onClick as (() => void) | undefined)?.(); + await flushPromises(); + + expect(commands.startProviderThreadSync).toHaveBeenCalledWith({ + environmentId, + input: {}, + }); + }); + it("renders the provider layout inert with a limited-permissions notice when read only", () => { atoms.providers = [provider()]; const panel = renderPanel({ readOnly: true }); diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index 3a38a91e2265..00e83f48e52f 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -12,6 +12,7 @@ import { ProviderDriverKind, type ProviderInstanceConfig, type ProviderInstanceId, + type ProviderThreadSyncStatus, resolveProviderInstanceEnabled, } from "@t3tools/contracts"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; @@ -349,14 +350,32 @@ function AccessGatedProviderSettings({ environmentId={environment.environmentId} environmentLabel={environment.label} readOnly={access.kind === "read-only"} + supportsProviderThreadSync={ + environment.serverConfig?.environment.capabilities.providerThreadSync === true + } /> ); } +function providerThreadSyncDescription(status: ProviderThreadSyncStatus): string { + if (status.status === "running") { + return status.phase === "discovering" + ? "Discovering persisted Codex sessions" + : `${status.progress.completed} of ${status.progress.total} sessions processed`; + } + if (status.status === "completed") { + const { organized, updated, unchanged, failed } = status.progress; + return `${organized} organized, ${updated} updated, ${unchanged} unchanged${failed > 0 ? `, ${failed} failed` : ""}`; + } + if (status.status === "failed") return status.message; + return "Organize every persisted Codex session and import external history changes."; +} + export function EnvironmentProviderSettings({ environmentId, environmentLabel, readOnly = false, + supportsProviderThreadSync = true, }: { readonly environmentId: EnvironmentId; readonly environmentLabel: string; @@ -367,6 +386,7 @@ export function EnvironmentProviderSettings({ * every one of its writes from being offered and then rejected. */ readonly readOnly?: boolean; + readonly supportsProviderThreadSync?: boolean; }) { const settings = useEnvironmentSettings(environmentId); const updateSettings = useUpdateEnvironmentSettings(environmentId); @@ -378,6 +398,12 @@ export function EnvironmentProviderSettings({ const updateProvider = useAtomCommand(serverEnvironment.updateProvider, { reportFailure: false, }); + const providerThreadSyncStatus = useAtomValue( + serverEnvironment.providerThreadSyncStatusAtom(environmentId), + ); + const startProviderThreadSync = useAtomCommand(serverEnvironment.startProviderThreadSync, { + reportFailure: false, + }); const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); const [isAddInstanceDialogOpen, setIsAddInstanceDialogOpen] = useState(false); const [updatingProviderDrivers, setUpdatingProviderDrivers] = useState< @@ -386,6 +412,9 @@ export function EnvironmentProviderSettings({ const [openInstanceDetails, setOpenInstanceDetails] = useState>({}); const refreshingRef = useRef(false); const updatingDriversRef = useRef>(new Set()); + const hasCodexProvider = serverProviders.some( + (provider) => provider.driver === ProviderDriverKind.make("codex") && provider.installed, + ); const providerUpdateCandidates = useMemo( () => collectProviderUpdateCandidates(serverProviders), @@ -485,6 +514,21 @@ export function EnvironmentProviderSettings({ [environmentId, updateProvider], ); + const synchronizeCodexSessions = useCallback(() => { + void (async () => { + const result = await startProviderThreadSync({ environmentId, input: {} }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not synchronize Codex sessions", + description: "The server could not start the Codex history synchronization.", + }), + ); + } + })(); + }, [environmentId, startProviderThreadSync]); + interface InstanceRow { readonly instanceId: ProviderInstanceId; readonly instance: ProviderInstanceConfig; @@ -793,6 +837,29 @@ export function EnvironmentProviderSettings({ } /> + {supportsProviderThreadSync && hasCodexProvider && !readOnly ? ( + + {providerThreadSyncStatus.status === "running" ? ( + + ) : ( + + )} + {providerThreadSyncStatus.status === "running" ? "Synchronizing" : "Synchronize"} + + } + /> + ) : null} + {rows.map((row) => { const driverOption = getDriverOption(row.driver); const liveProvider = serverProviders.find( diff --git a/docs/internals/overview.md b/docs/internals/overview.md index 66325b34c9af..2291ead0b014 100644 --- a/docs/internals/overview.md +++ b/docs/internals/overview.md @@ -104,8 +104,9 @@ Each of the three services exposes `drain` for exactly this. than a drainable worker. It discovers durable Codex conversations, imports missing historical messages through the event-sourced command path, and reconciles an exact provider transcript before thread reads and new local turns. Native T3-bound Codex identities remain attached to their original -thread; unmatched external conversations use a deterministic imported thread under the matching -project or **Unassigned Codex threads**. +thread; unmatched external conversations use a deterministic imported thread and project derived +from their Git root or working directory. Its explicit maintenance operation is server-owned and +streams replayable progress, while periodic and per-thread reconciliation remain incremental. Runtime receipts are a test-only mechanism. `RuntimeReceiptBusLive` in [`RuntimeReceiptBus.ts`][receipts] publishes nothing; only the test layer is PubSub-backed. Do not diff --git a/docs/internals/providers.md b/docs/internals/providers.md index 1f8456ef8147..7a3d6286ae32 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -51,8 +51,10 @@ Clients never call a provider directly. They dispatch orchestration commands ove `orchestration.dispatchCommand`, defined with the rest of the orchestration surface in [`orchestration.ts`][contracts]. The client-dispatchable provider-facing commands are `thread.turn.start`, `thread.turn.interrupt`, `thread.approval.respond`, -`thread.user-input.respond`, `thread.checkpoint.revert`, and `thread.session.stop`, plus the mode -setters `thread.runtime-mode.set` and `thread.interaction-mode.set`. +`thread.user-input.respond`, `thread.turn.recover`, `thread.checkpoint.revert`, and +`thread.session.stop`, plus the mode setters `thread.runtime-mode.set` and +`thread.interaction-mode.set`. `thread.turn.recover` references an already-persisted user message; +it never creates another message event. The engine persists an event for the command, and a server-side reactor performs the provider call. Provider output comes back as internal commands such as `thread.message.assistant.delta` and @@ -76,9 +78,16 @@ Persisted conversation convergence is separate from those workers. [`ProviderThreadReconciler`][reconciler] groups compatible Codex instances by continuation identity and serializes background discovery with exact per-thread reconciliation. The durable identity is the continuation key plus the provider thread ID. An external conversation is assigned to the -project matching its working directory, or to **Unassigned Codex threads** when no project matches. -A Codex identity already bound to a native T3 thread always reconciles into that original thread and -preserves its provider instance and model ownership. +project matching its Git repository root, with its working directory as fallback; the reconciler +creates the deterministic project when it does not exist. A Codex identity already bound to a native +T3 thread always reconciles into that original thread and preserves its provider instance and model +ownership. + +Periodic reconciliation and exact thread reads stay incremental. The explicit Settings operation +requests a complete `thread/list` scan, including unchanged visible sessions as metadata-only +records; only new or changed conversations require `thread/read`. A server-owned single-flight +coordinator publishes replayable `discovering`, `reconciling`, `completed`, and `failed` status over +`subscribeProviderThreadSync`, so the operation survives navigation and client reconnects. Missing messages enter through the internal `thread.message.import` command with deterministic command and message IDs. Imported message events are marked historical, so projections create @@ -91,6 +100,21 @@ reconciliation must finish before the local user command is persisted, preventin prompt from overtaking externally appended turns. Background loops start only after server activation, and all clients observe the resulting ordinary orchestration projections. +### Codex active-writer recovery + +Codex rejects `thread/resume` while another Codex process owns the conversation writer. The adapter +does not delete the lock and does not auto-fork. `ProviderCommandReactor` replaces the provider's raw +error with a stable user-safe message and appends a +`provider.thread.active-writer-conflict` activity containing the pending message ID. + +The web client and its desktop wrapper expose **Retry** and **Continue in a copy**. Retry re-emits +`thread.turn-start-requested` for the same message and resumes the same provider thread. Copy passes +an explicit fork continuation mode to the Codex adapter, which calls `thread/fork`, persists the new +resume cursor, and then sends that same message. The command is authorized only while the thread is +in the matching recoverable error state. Mobile receives the sanitized projected error and shares +the typed recovery command through `packages/client-runtime`; this change deliberately does not add +a new native mobile chat banner because mobile currently has no thread-detail error action surface. + ### Buffered assistant delivery A thread in `buffered` assistant delivery mode accumulates assistant text instead of streaming each diff --git a/docs/user/providers-codex.md b/docs/user/providers-codex.md index c9fbe4af793d..8da762f316c5 100644 --- a/docs/user/providers-codex.md +++ b/docs/user/providers-codex.md @@ -31,9 +31,14 @@ codex login ## Continue Codex Work In T3 Code T3 Code automatically discovers durable conversations created by Codex CLI and other Codex clients -that use the same `CODEX_HOME`. Conversations whose working directory matches a T3 Code project -appear in that project. Other conversations appear under **Unassigned Codex threads**. A new -external conversation can take up to two minutes to appear. +that use the same `CODEX_HOME`. It groups them by Git repository root when available, with the +conversation's working directory as a fallback, and creates the missing T3 Code project +automatically. A new external conversation can take up to two minutes to appear. + +To organize the complete existing history immediately, open **Settings → Providers** and choose +**Synchronize** under **Codex session history**. The operation continues on the server if you leave +Settings or reconnect from another device. Its progress shows how many sessions were processed and +how many were organized, updated, unchanged, or failed. You can also start a thread in T3 Code, continue it in Codex CLI or the Codex app, and return to the same T3 Code thread. T3 Code refreshes the durable Codex history when the thread opens and again @@ -41,6 +46,17 @@ before it sends your next message. If that refresh fails, T3 Code does not send it cannot silently fork the conversation from stale history. Retry after the Codex thread is available again. +Codex allows only one client to write to a conversation at a time. If the conversation is still +open in Codex CLI or the Codex app, T3 Code keeps your message pending and explains the two safe +choices: + +- Close the owning Codex client cleanly — use `/exit` in Codex CLI when applicable — then choose + **I've closed it — retry** to continue the same conversation. +- Choose **Continue in a copy** to create a separate Codex conversation without closing the + original. + +Retry and copy both reuse the pending message; neither adds a duplicate to the T3 Code transcript. + ## Send feedback to OpenAI In an existing Codex thread, send `/feedback` or `/feedback` followed by a description of the diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index 0cb1650066c4..971a860a11ee 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -1,6 +1,7 @@ import { CommandId, EnvironmentId, + MessageId, ORCHESTRATION_WS_METHODS, ProjectId, ThreadId, @@ -24,6 +25,7 @@ import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import { archiveThread, createProject, + recoverThreadTurn, settleThread, stopThreadSession, unsettleThread, @@ -121,6 +123,32 @@ describe("environment commands", () => { }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), ); + it.effect("dispatches active-writer recovery for the existing user message", () => + Effect.gen(function* () { + const dispatched: ClientOrchestrationCommand[] = []; + const supervisor = yield* makeSupervisor(dispatched); + + yield* recoverThreadTurn({ + commandId: CommandId.make("recover-command"), + threadId: ThreadId.make("thread-1"), + messageId: MessageId.make("message-1"), + strategy: "fork", + createdAt: "2026-06-06T00:02:00.000Z", + }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); + + expect(dispatched).toEqual([ + { + type: "thread.turn.recover", + commandId: "recover-command", + threadId: "thread-1", + messageId: "message-1", + strategy: "fork", + createdAt: "2026-06-06T00:02:00.000Z", + }, + ]); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); + it.effect("does not add timestamps to commands without createdAt", () => Effect.gen(function* () { const dispatched: ClientOrchestrationCommand[] = []; diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index cb74f117b772..22033242ff93 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -46,6 +46,7 @@ export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">; export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">; export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">; export type StartThreadTurnInput = CommandInput<"thread.turn.start">; +export type RecoverThreadTurnInput = CommandInput<"thread.turn.recover">; export type InterruptThreadTurnInput = CommandInput<"thread.turn.interrupt">; export type RespondToThreadApprovalInput = CommandInput<"thread.approval.respond">; export type RespondToThreadUserInputInput = CommandInput<"thread.user-input.respond">; @@ -275,6 +276,18 @@ export const startThreadTurn: (input: StartThreadTurnInput) => CommandEffect = E }); }); +export const recoverThreadTurn: (input: RecoverThreadTurnInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.recoverThreadTurn", +)(function* (input) { + const metadata = yield* timestampedCommandMetadata(input); + return yield* dispatch({ + ...input, + type: "thread.turn.recover", + commandId: metadata.commandId, + createdAt: metadata.createdAt, + }); +}); + export const interruptThreadTurn: (input: InterruptThreadTurnInput) => CommandEffect = Effect.fn( "EnvironmentCommands.interruptThreadTurn", )(function* (input) { diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index bfe57a6c0dd5..5317ee8afbe1 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -45,6 +45,7 @@ export type EnvironmentSubscriptionRpcTag = | typeof WS_METHODS.subscribeAuthAccess | typeof WS_METHODS.subscribeServerConfig | typeof WS_METHODS.subscribeServerLifecycle + | typeof WS_METHODS.subscribeProviderThreadSync | typeof WS_METHODS.subscribeTerminalEvents | typeof WS_METHODS.subscribeTerminalMetadata | typeof WS_METHODS.subscribePreviewEvents diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 2fef689a9bbb..a983b065dcce 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -4,6 +4,7 @@ import { type ServerConfigStreamEvent, type ServerLifecycleWelcomePayload, type ServerLifecycleStreamReadyEvent, + type ProviderThreadSyncStatus, type ServerSelfUpdateProgressEvent, type ServerSelfUpdateResult, WS_METHODS, @@ -69,6 +70,7 @@ const IDLE_SERVER_UPDATE_STATE: ServerUpdateState = { status: "idle" }; const EMPTY_SERVER_UPDATE_STATE_ATOM = Atom.make(IDLE_SERVER_UPDATE_STATE).pipe( Atom.withLabel("environment-data:server:update-state:empty"), ); +const IDLE_PROVIDER_THREAD_SYNC_STATUS: ProviderThreadSyncStatus = { status: "idle" }; const serverUpdateStateAtom = Atom.family((environmentId: EnvironmentId) => Atom.make(IDLE_SERVER_UPDATE_STATE).pipe( Atom.withLabel(`environment-data:server:update-state:${environmentId}`), @@ -693,12 +695,27 @@ export function createServerEnvironmentAtoms( Atom.withLabel(`environment-data:server:providers:${environmentId}`), ), ); + const providerThreadSyncProjection = createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:server:provider-thread-sync-projection", + tag: WS_METHODS.subscribeProviderThreadSync, + idleTtlMs: 0, + }); + const providerThreadSyncStatusAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make( + (get): ProviderThreadSyncStatus => + Option.getOrElse( + AsyncResult.value(get(providerThreadSyncProjection({ environmentId, input: {} }))), + () => IDLE_PROVIDER_THREAD_SYNC_STATUS, + ), + ).pipe(Atom.withLabel(`environment-data:server:provider-thread-sync:${environmentId}`)), + ); return { configValueAtom, updateStateAtom, settingsValueAtom, providersValueAtom, + providerThreadSyncStatusAtom, traceDiagnostics: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:server:trace-diagnostics", tag: WS_METHODS.serverGetTraceDiagnostics, @@ -745,6 +762,14 @@ export function createServerEnvironmentAtoms( key: ({ environmentId }) => environmentId, }, }), + startProviderThreadSync: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:start-provider-thread-sync", + tag: WS_METHODS.serverStartProviderThreadSync, + concurrency: { + mode: "singleFlight", + key: ({ environmentId }) => environmentId, + }, + }), updateProvider: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:update-provider", tag: WS_METHODS.serverUpdateProvider, diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index c540644289df..568843913a8f 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -13,6 +13,7 @@ import { type DeleteThreadInput, type InterruptThreadTurnInput, type RespondToThreadApprovalInput, + type RecoverThreadTurnInput, type RespondToThreadUserInputInput, type RevertThreadCheckpointInput, type SetThreadInteractionModeInput, @@ -33,6 +34,7 @@ import { deleteThread, interruptThreadTurn, respondToThreadApproval, + recoverThreadTurn, respondToThreadUserInput, revertThreadCheckpoint, setThreadInteractionMode, @@ -57,6 +59,7 @@ export type { DeleteThreadInput, InterruptThreadTurnInput, RespondToThreadApprovalInput, + RecoverThreadTurnInput, RespondToThreadUserInputInput, RevertThreadCheckpointInput, SetThreadInteractionModeInput, @@ -174,6 +177,12 @@ export function createThreadEnvironmentAtoms( scheduler, concurrency, }), + recoverTurn: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:recover-turn", + execute: (input: RecoverThreadTurnInput) => recoverThreadTurn(input), + scheduler, + concurrency, + }), interruptTurn: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:interrupt-turn", execute: (input: InterruptThreadTurnInput) => interruptThreadTurn(input), diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 939313e8c83e..78d9de4bd6e1 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -78,6 +78,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server can stream self-update progress before acknowledging the restart. Clients fall back to server.updateServer when absent. */ serverSelfUpdateProgress: Schema.optionalKey(Schema.Boolean), + /** Server owns a replayable persisted-provider history reconciliation operation. */ + providerThreadSync: Schema.optionalKey(Schema.Boolean), /** Agent-activity publishes (push notifications and Live Activities) currently leave this environment: the publish opt-in is enabled and the relay link credentials exist. Clients skip seeding a Live Activity when diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 14f1095db1d0..d64d5bf6acce 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -362,6 +362,21 @@ export const OrchestrationThreadActivity = Schema.Struct({ }); export type OrchestrationThreadActivity = typeof OrchestrationThreadActivity.Type; +export const CODEX_ACTIVE_WRITER_CONFLICT_MESSAGE = + "This Codex session is open in another client. Close it there and retry, or continue in a copy."; + +export const CodexActiveWriterConflictActivityPayload = Schema.Struct({ + messageId: MessageId, + canFork: Schema.Literal(true), + modelSelection: Schema.optional(ModelSelection), + titleSeed: Schema.optional(TrimmedNonEmptyString), + runtimeMode: RuntimeMode, + interactionMode: ProviderInteractionMode, + sourceProposedPlan: Schema.optional(SourceProposedPlanReference), +}); +export type CodexActiveWriterConflictActivityPayload = + typeof CodexActiveWriterConflictActivityPayload.Type; + const OrchestrationLatestTurnState = Schema.Literals([ "running", "interrupted", @@ -874,6 +889,15 @@ export const ThreadTurnStartCommand = Schema.Struct({ createdAt: IsoDateTime, }); +const ThreadTurnRecoverCommand = Schema.Struct({ + type: Schema.Literal("thread.turn.recover"), + commandId: CommandId, + threadId: ThreadId, + messageId: MessageId, + strategy: Schema.Literals(["retry", "fork"]), + createdAt: IsoDateTime, +}); + const ClientThreadTurnStartCommand = Schema.Struct({ type: Schema.Literal("thread.turn.start"), commandId: CommandId, @@ -959,6 +983,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadRuntimeModeSetCommand, ThreadInteractionModeSetCommand, ThreadTurnStartCommand, + ThreadTurnRecoverCommand, ThreadTurnInterruptCommand, ThreadApprovalRespondCommand, ThreadUserInputRespondCommand, @@ -987,6 +1012,7 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadRuntimeModeSetCommand, ThreadInteractionModeSetCommand, ClientThreadTurnStartCommand, + ThreadTurnRecoverCommand, ThreadTurnInterruptCommand, ThreadApprovalRespondCommand, ThreadUserInputRespondCommand, @@ -1303,6 +1329,7 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed(DEFAULT_PROVIDER_INTERACTION_MODE)), ), sourceProposedPlan: Schema.optional(SourceProposedPlanReference), + continuationMode: Schema.optional(Schema.Literal("fork")), createdAt: IsoDateTime, }); diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 42a943923037..f76b89125865 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -59,6 +59,7 @@ export const ProviderSessionStartInput = Schema.Struct({ title: Schema.optional(TrimmedNonEmptyString), modelSelection: Schema.optional(ModelSelection), resumeCursor: Schema.optional(Schema.Unknown), + continuationMode: Schema.optional(Schema.Literal("fork")), approvalPolicy: Schema.optional(ProviderApprovalPolicy), sandboxMode: Schema.optional(ProviderSandboxMode), runtimeMode: RuntimeMode, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 14363cfedff9..855cd7871864 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -173,6 +173,7 @@ import { ServerRemoveKeybindingInput, ServerRemoveKeybindingResult, ServerProviderUpdatedPayload, + ProviderThreadSyncStatus, ServerSelfUpdateError, ServerSelfUpdateInput, ServerSelfUpdateProgressEvent, @@ -273,6 +274,7 @@ export const WS_METHODS = { serverProbe: "server.probe", serverGetConfig: "server.getConfig", serverRefreshProviders: "server.refreshProviders", + serverStartProviderThreadSync: "server.startProviderThreadSync", serverUpdateProvider: "server.updateProvider", serverUpdateServer: "server.updateServer", serverUpdateServerWithProgress: "server.updateServerWithProgress", @@ -331,6 +333,7 @@ export const WS_METHODS = { subscribeAuthAccess: "subscribeAuthAccess", subscribeBackgroundPolicy: "subscribeBackgroundPolicy", subscribeResourceTelemetry: "subscribeResourceTelemetry", + subscribeProviderThreadSync: "subscribeProviderThreadSync", } as const; export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, { @@ -371,6 +374,15 @@ export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProv error: EnvironmentAuthorizationError, }); +export const WsServerStartProviderThreadSyncRpc = Rpc.make( + WS_METHODS.serverStartProviderThreadSync, + { + payload: Schema.Struct({}), + success: ProviderThreadSyncStatus, + error: EnvironmentAuthorizationError, + }, +); + export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvider, { payload: ServerProviderUpdateInput, success: ServerProviderUpdatedPayload, @@ -1017,10 +1029,18 @@ export const WsSubscribeResourceTelemetryRpc = Rpc.make(WS_METHODS.subscribeReso stream: true, }); +export const WsSubscribeProviderThreadSyncRpc = Rpc.make(WS_METHODS.subscribeProviderThreadSync, { + payload: Schema.Struct({}), + success: ProviderThreadSyncStatus, + error: EnvironmentAuthorizationError, + stream: true, +}); + export const WsRpcGroup = RpcGroup.make( WsServerProbeRpc, WsServerGetConfigRpc, WsServerRefreshProvidersRpc, + WsServerStartProviderThreadSyncRpc, WsServerUpdateProviderRpc, WsServerUpdateServerRpc, WsServerUpdateServerWithProgressRpc, @@ -1039,6 +1059,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerReportClientActivityRpc, WsServerReportHostPowerStateRpc, WsServerGetBackgroundPolicyRpc, + WsSubscribeProviderThreadSyncRpc, WsCloudGetRelayClientStatusRpc, WsCloudInstallRelayClientRpc, WsPullRequestsListRpc, diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index 23e4a43bf5c4..cf59a3b1afdb 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -2,6 +2,7 @@ import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; import { + ProviderThreadSyncStatus, ServerConfig, ServerProvider, ServerProviders, @@ -12,6 +13,7 @@ const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider); const decodeServerProviders = Schema.decodeUnknownSync(ServerProviders); const decodeUpsertKeybindingResult = Schema.decodeUnknownSync(ServerUpsertKeybindingResult); const decodeAvailableEditors = Schema.decodeUnknownSync(ServerConfig.fields.availableEditors); +const decodeProviderThreadSyncStatus = Schema.decodeUnknownSync(ProviderThreadSyncStatus); const baseProviderSnapshot = { instanceId: "codex", @@ -117,6 +119,38 @@ describe("ServerProvider", () => { }); }); +describe("ProviderThreadSyncStatus", () => { + it("decodes reconnectable reconciliation progress", () => { + expect( + decodeProviderThreadSyncStatus({ + status: "running", + phase: "reconciling", + startedAt: "2026-08-26T20:00:00.000Z", + progress: { + total: 91, + completed: 18, + organized: 12, + updated: 4, + unchanged: 2, + failed: 0, + }, + }), + ).toEqual({ + status: "running", + phase: "reconciling", + startedAt: "2026-08-26T20:00:00.000Z", + progress: { + total: 91, + completed: 18, + organized: 12, + updated: 4, + unchanged: 2, + failed: 0, + }, + }); + }); +}); + describe("server config forward compatibility", () => { it("drops config issues with kinds this build does not know", () => { const parsed = decodeUpsertKeybindingResult({ diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 9791a4f62185..c083723d2d55 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -121,6 +121,40 @@ export const ServerProviderContinuation = Schema.Struct({ }); export type ServerProviderContinuation = typeof ServerProviderContinuation.Type; +export const ProviderThreadSyncProgress = Schema.Struct({ + total: NonNegativeInt, + completed: NonNegativeInt, + organized: NonNegativeInt, + updated: NonNegativeInt, + unchanged: NonNegativeInt, + failed: NonNegativeInt, +}); +export type ProviderThreadSyncProgress = typeof ProviderThreadSyncProgress.Type; + +export const ProviderThreadSyncStatus = Schema.Union([ + Schema.Struct({ status: Schema.Literal("idle") }), + Schema.Struct({ + status: Schema.Literal("running"), + phase: Schema.Literals(["discovering", "reconciling"]), + startedAt: IsoDateTime, + progress: ProviderThreadSyncProgress, + }), + Schema.Struct({ + status: Schema.Literal("completed"), + startedAt: IsoDateTime, + finishedAt: IsoDateTime, + progress: ProviderThreadSyncProgress, + }), + Schema.Struct({ + status: Schema.Literal("failed"), + startedAt: IsoDateTime, + finishedAt: IsoDateTime, + progress: ProviderThreadSyncProgress, + message: TrimmedNonEmptyString, + }), +]); +export type ProviderThreadSyncStatus = typeof ProviderThreadSyncStatus.Type; + export const ServerProviderVersionAdvisoryStatus = Schema.Literals([ "unknown", "current", From 64d655562a90e58b548b247cf9da96a5a760b186 Mon Sep 17 00:00:00 2001 From: Raul Date: Wed, 26 Aug 2026 23:29:09 +0200 Subject: [PATCH 07/10] fix(codex): keep recovery actions reusable --- .../src/orchestration/decider.snoozed.test.ts | 63 ++++++++++++++++++- apps/server/src/orchestration/decider.ts | 36 ++++++++++- apps/web/src/components/ChatView.tsx | 37 +++++------ .../chat/ThreadErrorBanner.test.tsx | 37 +++++++++++ .../src/components/chat/ThreadErrorBanner.tsx | 25 +++++--- 5 files changed, 171 insertions(+), 27 deletions(-) diff --git a/apps/server/src/orchestration/decider.snoozed.test.ts b/apps/server/src/orchestration/decider.snoozed.test.ts index 1012240b18a0..a5c6261659c1 100644 --- a/apps/server/src/orchestration/decider.snoozed.test.ts +++ b/apps/server/src/orchestration/decider.snoozed.test.ts @@ -27,6 +27,7 @@ function makeReadModel(input: { readonly archivedAt?: string | null; readonly activities?: OrchestrationThread["activities"]; readonly messages?: OrchestrationThread["messages"]; + readonly session?: OrchestrationThread["session"]; }): OrchestrationReadModel { return { snapshotSequence: 0, @@ -54,7 +55,7 @@ function makeReadModel(input: { proposedPlans: [], activities: input.activities ?? [], checkpoints: [], - session: null, + session: input.session ?? null, }, ], updatedAt: NOW, @@ -283,4 +284,64 @@ it.layer(NodeServices.layer)("snoozed thread decider", (it) => { } }), ); + + it.effect("recovering an active-writer conflict spends the snooze return ticket", () => + Effect.gen(function* () { + const messageId = MessageId.make("message-recover"); + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.turn.recover", + commandId: CommandId.make("cmd-turn-recover"), + threadId: ThreadId.make("thread-1"), + messageId, + strategy: "retry", + createdAt: NOW, + }, + readModel: makeReadModel({ + snoozedUntil: FUTURE_WAKE, + session: { + threadId: ThreadId.make("thread-1"), + status: "error", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: "Codex session is open elsewhere", + updatedAt: NOW, + }, + messages: [ + { + id: messageId, + role: "user", + text: "Continue", + turnId: null, + streaming: false, + createdAt: NOW, + updatedAt: NOW, + }, + ], + activities: [ + { + id: EventId.make("active-writer-conflict"), + tone: "error", + kind: "provider.thread.active-writer-conflict", + summary: "Codex session is open elsewhere", + payload: { + messageId, + canFork: true, + runtimeMode: "full-access", + interactionMode: "default", + }, + turnId: null, + createdAt: NOW, + }, + ], + }), + }); + const events = Array.isArray(result) ? result : [result]; + expect(events.map((event) => event.type)).toEqual([ + "thread.unsnoozed", + "thread.turn-start-requested", + ]); + }), + ); }); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 9fdfd3dc4888..3f03b5fb5b41 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -1078,7 +1078,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" detail: `Thread '${command.threadId}' has no recoverable active-writer conflict for message '${command.messageId}'.`, }); } - return { + const turnStartRequestedEvent: Omit = { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, @@ -1104,6 +1104,40 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" createdAt: command.createdAt, }, }; + const lifecycleResetEvents: Array> = []; + if (thread.settledOverride !== null) { + lifecycleResetEvents.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.unsettled", + payload: { + threadId: command.threadId, + reason: "activity", + updatedAt: command.createdAt, + }, + }); + } + if (thread.snoozedUntil !== null) { + lifecycleResetEvents.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.unsnoozed", + payload: { + threadId: command.threadId, + reason: "activity", + updatedAt: command.createdAt, + }, + }); + } + return [...lifecycleResetEvents, turnStartRequestedEvent]; } case "thread.turn.interrupt": { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 18a8a328f7cd..e9d277e808ae 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -301,7 +301,7 @@ import { } from "./chat/ProviderStatusBanner"; import { dismissThreadErrorBannerForSession, - getActiveWriterRecoveryMessageId, + getActiveWriterRecovery, getThreadErrorBannerKey, isThreadErrorBannerDismissedForSession, shouldShowThreadErrorBanner, @@ -1616,37 +1616,38 @@ function ChatViewContent(props: ChatViewProps) { const threadError = isServerThread ? (localServerError ?? activeServerThread?.session?.lastError ?? null) : localDraftError; - const activeWriterRecoveryMessageIdRaw = isServerThread - ? getActiveWriterRecoveryMessageId({ + const activeWriterRecoveryRaw = isServerThread + ? getActiveWriterRecovery({ sessionStatus: activeServerThread?.session?.status, error: threadError, activities: activeServerThread?.activities ?? [], }) : null; - const activeWriterRecoveryMessageId = + const activeWriterRecovery = activeThread?.messages.find( - (message) => message.role === "user" && message.id === activeWriterRecoveryMessageIdRaw, - )?.id ?? null; - const [activeWriterRecoveryPending, setActiveWriterRecoveryPending] = useState(false); - useEffect(() => { - if (activeWriterRecoveryMessageId === null) { - setActiveWriterRecoveryPending(false); - } - }, [activeWriterRecoveryMessageId]); + (message) => message.role === "user" && message.id === activeWriterRecoveryRaw?.messageId, + ) !== undefined + ? activeWriterRecoveryRaw + : null; + const [pendingActiveWriterConflictId, setPendingActiveWriterConflictId] = useState( + null, + ); + const activeWriterRecoveryPending = + activeWriterRecovery?.conflictId === pendingActiveWriterConflictId; const handleActiveWriterRecovery = useCallback( async (strategy: "retry" | "fork") => { - if (!activeThread || activeWriterRecoveryMessageId === null) return; - setActiveWriterRecoveryPending(true); + if (!activeThread || activeWriterRecovery === null) return; + setPendingActiveWriterConflictId(activeWriterRecovery.conflictId); const result = await recoverThreadTurn({ environmentId, input: { threadId: activeThread.id, - messageId: activeWriterRecoveryMessageId, + messageId: activeWriterRecovery.messageId, strategy, }, }); if (result._tag === "Failure") { - setActiveWriterRecoveryPending(false); + setPendingActiveWriterConflictId(null); if (!isAtomCommandInterrupted(result)) { toastManager.add({ type: "error", @@ -1655,7 +1656,7 @@ function ChatViewContent(props: ChatViewProps) { } } }, - [activeThread, activeWriterRecoveryMessageId, environmentId, recoverThreadTurn], + [activeThread, activeWriterRecovery, environmentId, recoverThreadTurn], ); // Dismissals can only mask the shown error, never clear it: a server thread // keeps its error in session.lastError, so clearing the local shadow would @@ -6914,7 +6915,7 @@ function ChatViewContent(props: ChatViewProps) { { ).toBeNull(); }); + it("changes the recovery key when the same message conflicts again", () => { + const input = { + sessionStatus: "error", + error: + "This Codex session is open in another client. Close it there and retry, or continue in a copy.", + }; + const first = getActiveWriterRecovery({ + ...input, + activities: [ + { + id: "conflict-1", + kind: "provider.thread.active-writer-conflict", + payload: { messageId: "message-1", canFork: true }, + }, + ], + }); + const repeated = getActiveWriterRecovery({ + ...input, + activities: [ + { + id: "conflict-1", + kind: "provider.thread.active-writer-conflict", + payload: { messageId: "message-1", canFork: true }, + }, + { + id: "conflict-2", + kind: "provider.thread.active-writer-conflict", + payload: { messageId: "message-1", canFork: true }, + }, + ], + }); + + expect(first).toEqual({ messageId: "message-1", conflictId: "conflict-1" }); + expect(repeated).toEqual({ messageId: "message-1", conflictId: "conflict-2" }); + }); + it("stays hidden after its current error is dismissed", () => { const bannerKey = getThreadErrorBannerKey("env:thread-a", "Aborted"); dismissThreadErrorBannerForSession(bannerKey); diff --git a/apps/web/src/components/chat/ThreadErrorBanner.tsx b/apps/web/src/components/chat/ThreadErrorBanner.tsx index 15b9cb392c14..943d10b0119a 100644 --- a/apps/web/src/components/chat/ThreadErrorBanner.tsx +++ b/apps/web/src/components/chat/ThreadErrorBanner.tsx @@ -1,15 +1,19 @@ import { memo } from "react"; -import { CODEX_ACTIVE_WRITER_CONFLICT_MESSAGE } from "@t3tools/contracts"; +import { CODEX_ACTIVE_WRITER_CONFLICT_MESSAGE, MessageId } from "@t3tools/contracts"; import { Alert, AlertAction, AlertDescription } from "../ui/alert"; import { Button } from "../ui/button"; import { CircleAlertIcon, LoaderCircleIcon, XIcon } from "lucide-react"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -export function getActiveWriterRecoveryMessageId(input: { +export function getActiveWriterRecovery(input: { sessionStatus: string | null | undefined; error: string | null | undefined; - activities: ReadonlyArray<{ readonly kind: string; readonly payload: unknown }>; -}): string | null { + activities: ReadonlyArray<{ + readonly id?: unknown; + readonly kind: string; + readonly payload: unknown; + }>; +}): { readonly messageId: MessageId; readonly conflictId: string } | null { if (input.sessionStatus !== "error" || input.error !== CODEX_ACTIVE_WRITER_CONFLICT_MESSAGE) { return null; } @@ -20,11 +24,18 @@ export function getActiveWriterRecoveryMessageId(input: { return null; } const payload = conflict.payload as Record; - return payload.canFork === true && typeof payload.messageId === "string" - ? payload.messageId - : null; + if (payload.canFork !== true || typeof payload.messageId !== "string") return null; + const conflictId = + typeof conflict.id === "string" + ? conflict.id + : `${payload.messageId}:${input.activities.lastIndexOf(conflict)}`; + return { messageId: MessageId.make(payload.messageId), conflictId }; } +export const getActiveWriterRecoveryMessageId = ( + input: Parameters[0], +): string | null => getActiveWriterRecovery(input)?.messageId ?? null; + export function getThreadErrorBannerKey(threadKey: string, error: string | null): string | null { return error === null ? null : `${threadKey}\u0000${error}`; } From c18f43869e8d66d597333315bd84b2f914b8a565 Mon Sep 17 00:00:00 2001 From: Raul Date: Wed, 26 Aug 2026 23:51:06 +0200 Subject: [PATCH 08/10] fix(codex): allow slow provider cold starts --- .../src/provider/Layers/CodexProvider.ts | 9 +++------ .../provider/Layers/ProviderRegistry.test.ts | 18 +++++++++++++++++- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 06cc05d2dbf1..114086e5d0f8 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -27,16 +27,13 @@ import { PREFERRED_DEFAULT_CODEX_MODELS, ServerSettingsError } from "@t3tools/co import { createModelCapabilities } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts"; -import { - AUTH_PROBE_TIMEOUT_MS, - buildServerProvider, - type ServerProviderDraft, -} from "../providerSnapshot.ts"; +import { buildServerProvider, type ServerProviderDraft } from "../providerSnapshot.ts"; import { expandHomePath } from "../../pathExpansion.ts"; import packageJson from "../../../package.json" with { type: "json" }; const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError); const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const; +const CODEX_PROVIDER_PROBE_TIMEOUT_MS = 30_000; const CODEX_PRESENTATION = { displayName: "Codex", @@ -556,7 +553,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu environment: resolvedEnvironment, }).pipe( Effect.scoped, - Effect.timeoutOption(Duration.millis(AUTH_PROBE_TIMEOUT_MS)), + Effect.timeoutOption(Duration.millis(CODEX_PROVIDER_PROBE_TIMEOUT_MS)), Effect.result, ); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 6c644e195381..add3181185b3 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -505,6 +505,22 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }), ); + it.effect("allows a valid Codex cold-start probe to take longer than ten seconds", () => + Effect.gen(function* () { + const statusFiber = yield* checkCodexProviderStatus(defaultCodexSettings, () => + Effect.sleep("11 seconds").pipe(Effect.as(makeCodexProbeSnapshot())), + ).pipe(Effect.forkChild); + + yield* Effect.yieldNow; + yield* TestClock.adjust("11 seconds"); + yield* Effect.yieldNow; + + const status = yield* Fiber.join(statusFiber); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.auth.status, "authenticated"); + }), + ); + it.effect("closes the app-server probe scope when provider status times out", () => Effect.gen(function* () { const killCalls = yield* Ref.make(0); @@ -514,7 +530,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ); yield* Effect.yieldNow; - yield* TestClock.adjust("11 seconds"); + yield* TestClock.adjust("31 seconds"); yield* Effect.yieldNow; const status = yield* Fiber.join(statusFiber); From a92031ab10f18274b61975c762fbb8de1002eb2f Mon Sep 17 00:00:00 2001 From: Raul Date: Thu, 27 Aug 2026 00:04:42 +0200 Subject: [PATCH 09/10] fix(codex): avoid unsnoozing awake recovery --- .../src/orchestration/decider.snoozed.test.ts | 111 ++++++++++++------ apps/server/src/orchestration/decider.ts | 2 +- 2 files changed, 74 insertions(+), 39 deletions(-) diff --git a/apps/server/src/orchestration/decider.snoozed.test.ts b/apps/server/src/orchestration/decider.snoozed.test.ts index a5c6261659c1..a464c97f6883 100644 --- a/apps/server/src/orchestration/decider.snoozed.test.ts +++ b/apps/server/src/orchestration/decider.snoozed.test.ts @@ -23,6 +23,7 @@ const SNOOZED_AT = "1969-12-30T00:00:00.000Z"; function makeReadModel(input: { readonly snoozedUntil?: string | null; + readonly omitSnoozedUntil?: boolean; readonly snoozedAt?: string | null; readonly archivedAt?: string | null; readonly activities?: OrchestrationThread["activities"]; @@ -48,7 +49,7 @@ function makeReadModel(input: { archivedAt: input.archivedAt ?? null, settledOverride: null, settledAt: null, - snoozedUntil: input.snoozedUntil ?? null, + ...(input.omitSnoozedUntil ? {} : { snoozedUntil: input.snoozedUntil ?? null }), snoozedAt: input.snoozedAt ?? (input.snoozedUntil != null ? SNOOZED_AT : null), deletedAt: null, messages: input.messages ?? [], @@ -62,6 +63,53 @@ function makeReadModel(input: { }; } +function makeRecoverableReadModel(input: { + readonly messageId: MessageId; + readonly snoozedUntil?: string | null; + readonly omitSnoozedUntil?: boolean; +}): OrchestrationReadModel { + return makeReadModel({ + ...(input.snoozedUntil !== undefined ? { snoozedUntil: input.snoozedUntil } : {}), + ...(input.omitSnoozedUntil ? { omitSnoozedUntil: true } : {}), + session: { + threadId: ThreadId.make("thread-1"), + status: "error", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: "Codex session is open elsewhere", + updatedAt: NOW, + }, + messages: [ + { + id: input.messageId, + role: "user", + text: "Continue", + turnId: null, + streaming: false, + createdAt: NOW, + updatedAt: NOW, + }, + ], + activities: [ + { + id: EventId.make(`active-writer-conflict-${input.messageId}`), + tone: "error", + kind: "provider.thread.active-writer-conflict", + summary: "Codex session is open elsewhere", + payload: { + messageId: input.messageId, + canFork: true, + runtimeMode: "full-access", + interactionMode: "default", + }, + turnId: null, + createdAt: NOW, + }, + ], + }); +} + it.layer(NodeServices.layer)("snoozed thread decider", (it) => { it.effect("snoozes a thread to a future wake time", () => Effect.gen(function* () { @@ -297,44 +345,9 @@ it.layer(NodeServices.layer)("snoozed thread decider", (it) => { strategy: "retry", createdAt: NOW, }, - readModel: makeReadModel({ + readModel: makeRecoverableReadModel({ + messageId, snoozedUntil: FUTURE_WAKE, - session: { - threadId: ThreadId.make("thread-1"), - status: "error", - providerName: "codex", - runtimeMode: "full-access", - activeTurnId: null, - lastError: "Codex session is open elsewhere", - updatedAt: NOW, - }, - messages: [ - { - id: messageId, - role: "user", - text: "Continue", - turnId: null, - streaming: false, - createdAt: NOW, - updatedAt: NOW, - }, - ], - activities: [ - { - id: EventId.make("active-writer-conflict"), - tone: "error", - kind: "provider.thread.active-writer-conflict", - summary: "Codex session is open elsewhere", - payload: { - messageId, - canFork: true, - runtimeMode: "full-access", - interactionMode: "default", - }, - turnId: null, - createdAt: NOW, - }, - ], }), }); const events = Array.isArray(result) ? result : [result]; @@ -344,4 +357,26 @@ it.layer(NodeServices.layer)("snoozed thread decider", (it) => { ]); }), ); + + it.effect("recovering an awake legacy thread does not emit an unsnoozed event", () => + Effect.gen(function* () { + const messageId = MessageId.make("message-recover-awake"); + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.turn.recover", + commandId: CommandId.make("cmd-turn-recover-awake"), + threadId: ThreadId.make("thread-1"), + messageId, + strategy: "retry", + createdAt: NOW, + }, + readModel: makeRecoverableReadModel({ + messageId, + omitSnoozedUntil: true, + }), + }); + const events = Array.isArray(result) ? result : [result]; + expect(events.map((event) => event.type)).toEqual(["thread.turn-start-requested"]); + }), + ); }); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 3f03b5fb5b41..6c5a96392973 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -1121,7 +1121,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }, }); } - if (thread.snoozedUntil !== null) { + if (thread.snoozedUntil != null) { lifecycleResetEvents.push({ ...(yield* withEventBase({ aggregateKind: "thread", From 74f5fb30c9d6a3736d389ae2540d5e9b9b4c0704 Mon Sep 17 00:00:00 2001 From: Raul Date: Thu, 27 Aug 2026 10:15:47 +0200 Subject: [PATCH 10/10] fix(codex): keep thread reads responsive --- apps/server/src/bin.test.ts | 1 + apps/server/src/orchestration/http.ts | 9 +- .../Layers/ProviderThreadReconciler.test.ts | 248 ++++++++++++++++++ .../Layers/ProviderThreadReconciler.ts | 67 +++++ .../Services/ProviderThreadContinuity.ts | 5 + apps/server/src/server.test.ts | 88 +++---- apps/server/src/ws.ts | 33 +-- docs/internals/overview.md | 12 +- docs/internals/providers.md | 18 +- docs/user/providers-codex.md | 14 +- 10 files changed, 406 insertions(+), 89 deletions(-) diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 59fae33f1750..56ed5149c1bc 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -138,6 +138,7 @@ const withLiveProjectCliServer = (baseDir: string, run: () => Effect.Ef ProviderThreadContinuity, ProviderThreadContinuity.of({ reconcileThread: () => Effect.succeed(false), + requestReconcileThread: () => Effect.void, startSyncAll: Effect.succeed({ status: "idle" }), getSyncStatus: Effect.succeed({ status: "idle" }), streamSyncStatus: Stream.make({ status: "idle" }), diff --git a/apps/server/src/orchestration/http.ts b/apps/server/src/orchestration/http.ts index 4552d64f4706..964af36aa1b0 100644 --- a/apps/server/src/orchestration/http.ts +++ b/apps/server/src/orchestration/http.ts @@ -67,14 +67,7 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( Effect.fn("environment.orchestration.threadSnapshot")(function* (args) { yield* annotateEnvironmentRequest(args.endpoint.name); yield* requireEnvironmentScope(AuthOrchestrationReadScope); - yield* providerThreadContinuity.reconcileThread(args.params.threadId).pipe( - Effect.catch((cause) => - Effect.logWarning("Failed to reconcile provider thread before HTTP snapshot", { - threadId: args.params.threadId, - detail: cause.message, - }), - ), - ); + yield* providerThreadContinuity.requestReconcileThread(args.params.threadId); const snapshot = yield* projectionSnapshotQuery .getThreadDetailSnapshot( args.params.threadId, diff --git a/apps/server/src/provider/Layers/ProviderThreadReconciler.test.ts b/apps/server/src/provider/Layers/ProviderThreadReconciler.test.ts index 89c7c5ce7e33..249b87dba958 100644 --- a/apps/server/src/provider/Layers/ProviderThreadReconciler.test.ts +++ b/apps/server/src/provider/Layers/ProviderThreadReconciler.test.ts @@ -661,6 +661,254 @@ it.effect("reconciles externally appended turns into the original native T3 thre }), ); +it.effect("heals a stale generic session error after importing newer provider history", () => + Effect.gen(function* () { + const commands: OrchestrationCommand[] = []; + const externallyContinued = { + ...persistedThread, + updatedAt: "2026-08-21T03:30:43.000Z", + discoveryCursor: "2026-08-21T03:30:43.000Z:idle", + messages: [ + ...persistedThread.messages, + { + id: "external-assistant-item", + sourceOrdinal: 2, + role: "assistant" as const, + text: "External work completed", + turnId: TurnId.make("turn-2"), + createdAt: "2026-08-21T03:30:43.000Z", + }, + ], + }; + + yield* reconcilePersistedThread({ + instance, + thread: externallyContinued, + model: "gpt-5.6-sol", + threadByProviderIdentity: new Map([[providerIdentity, importedThreadId]]), + directory: { + upsert: () => Effect.void, + } as unknown as ProviderSessionDirectory["Service"], + snapshots: { + getThreadShellById: () => + Effect.succeed( + Option.some({ + id: importedThreadId, + projectId: "project-1", + modelSelection: { instanceId: instance.instanceId, model: "gpt-5.6-sol" }, + }), + ), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.some({ id: "project-1" })), + getThreadDetailById: () => + Effect.succeed( + Option.some({ + messages: persistedThread.messages.map((message) => ({ + id: message.id, + role: message.role, + text: message.text, + })), + activities: [ + { + kind: "provider.turn.start.failed", + createdAt: "2026-08-21T03:25:43.000Z", + }, + ], + session: { + threadId: importedThreadId, + status: "error", + providerName: "codex", + providerInstanceId: instance.instanceId, + runtimeMode: "full-access", + activeTurnId: null, + lastError: "Provider turn start failed", + updatedAt: "2026-08-21T03:25:43.000Z", + }, + }), + ), + } as unknown as ProjectionSnapshotQuery["Service"], + engine: { + dispatch: (command: OrchestrationCommand) => + Effect.sync(() => { + commands.push(command); + return { sequence: commands.length }; + }), + } as unknown as OrchestrationEngineService["Service"], + }); + + expect(commands.map((command) => command.type)).toEqual([ + "thread.message.import", + "thread.session.set", + ]); + expect(commands[1]).toMatchObject({ + type: "thread.session.set", + threadId: importedThreadId, + session: { + status: "stopped", + activeTurnId: null, + lastError: null, + updatedAt: "2026-08-21T03:30:43.000Z", + }, + createdAt: "2026-08-21T03:30:43.000Z", + }); + }), +); + +it.effect("heals a stale generic session error when newer history was already imported", () => + Effect.gen(function* () { + const commands: OrchestrationCommand[] = []; + const externallyContinued = { + ...persistedThread, + updatedAt: "2026-08-21T03:30:43.000Z", + discoveryCursor: "2026-08-21T03:30:43.000Z:idle", + messages: [ + ...persistedThread.messages, + { + id: "external-assistant-item", + sourceOrdinal: 2, + role: "assistant" as const, + text: "External work completed", + turnId: TurnId.make("turn-2"), + createdAt: "2026-08-21T03:30:43.000Z", + }, + ], + }; + + yield* reconcilePersistedThread({ + instance, + thread: externallyContinued, + model: "gpt-5.6-sol", + threadByProviderIdentity: new Map([[providerIdentity, importedThreadId]]), + directory: { + upsert: () => Effect.void, + } as unknown as ProviderSessionDirectory["Service"], + snapshots: { + getThreadShellById: () => + Effect.succeed( + Option.some({ + id: importedThreadId, + projectId: "project-1", + modelSelection: { instanceId: instance.instanceId, model: "gpt-5.6-sol" }, + }), + ), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.some({ id: "project-1" })), + getThreadDetailById: () => + Effect.succeed( + Option.some({ + messages: externallyContinued.messages.map((message) => ({ + id: message.id, + role: message.role, + text: message.text, + })), + activities: [ + { + kind: "provider.turn.start.failed", + createdAt: "2026-08-21T03:25:43.000Z", + }, + ], + session: { + threadId: importedThreadId, + status: "error", + providerName: "codex", + providerInstanceId: instance.instanceId, + runtimeMode: "full-access", + activeTurnId: null, + lastError: "Provider turn start failed", + updatedAt: "2026-08-21T03:25:43.000Z", + }, + }), + ), + } as unknown as ProjectionSnapshotQuery["Service"], + engine: { + dispatch: (command: OrchestrationCommand) => + Effect.sync(() => { + commands.push(command); + return { sequence: commands.length }; + }), + } as unknown as OrchestrationEngineService["Service"], + }); + + expect(commands.map((command) => command.type)).toEqual(["thread.session.set"]); + }), +); + +it.effect("keeps an active-writer conflict actionable when newer provider history arrives", () => + Effect.gen(function* () { + const commands: OrchestrationCommand[] = []; + const externallyContinued = { + ...persistedThread, + updatedAt: "2026-08-21T03:30:43.000Z", + discoveryCursor: "2026-08-21T03:30:43.000Z:idle", + messages: [ + ...persistedThread.messages, + { + id: "external-assistant-item", + sourceOrdinal: 2, + role: "assistant" as const, + text: "External work completed", + turnId: TurnId.make("turn-2"), + createdAt: "2026-08-21T03:30:43.000Z", + }, + ], + }; + + yield* reconcilePersistedThread({ + instance, + thread: externallyContinued, + model: "gpt-5.6-sol", + threadByProviderIdentity: new Map([[providerIdentity, importedThreadId]]), + directory: { + upsert: () => Effect.void, + } as unknown as ProviderSessionDirectory["Service"], + snapshots: { + getThreadShellById: () => + Effect.succeed( + Option.some({ + id: importedThreadId, + projectId: "project-1", + modelSelection: { instanceId: instance.instanceId, model: "gpt-5.6-sol" }, + }), + ), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.some({ id: "project-1" })), + getThreadDetailById: () => + Effect.succeed( + Option.some({ + messages: persistedThread.messages.map((message) => ({ + id: message.id, + role: message.role, + text: message.text, + })), + activities: [ + { + kind: "provider.thread.active-writer-conflict", + createdAt: "2026-08-21T03:25:43.000Z", + }, + ], + session: { + threadId: importedThreadId, + status: "error", + providerName: "codex", + providerInstanceId: instance.instanceId, + runtimeMode: "full-access", + activeTurnId: null, + lastError: "This Codex session is open in another client", + updatedAt: "2026-08-21T03:25:43.000Z", + }, + }), + ), + } as unknown as ProjectionSnapshotQuery["Service"], + engine: { + dispatch: (command: OrchestrationCommand) => + Effect.sync(() => { + commands.push(command); + return { sequence: commands.length }; + }), + } as unknown as OrchestrationEngineService["Service"], + }); + + expect(commands.map((command) => command.type)).toEqual(["thread.message.import"]); + }), +); + it.effect("does not duplicate native user messages with generated attachment path text", () => Effect.gen(function* () { const nativeThreadId = ThreadId.make("native-t3-thread-with-attachment"); diff --git a/apps/server/src/provider/Layers/ProviderThreadReconciler.ts b/apps/server/src/provider/Layers/ProviderThreadReconciler.ts index d3caa27be029..fe8e2043547c 100644 --- a/apps/server/src/provider/Layers/ProviderThreadReconciler.ts +++ b/apps/server/src/provider/Layers/ProviderThreadReconciler.ts @@ -584,6 +584,16 @@ export const reconcilePersistedThread = Effect.fn("reconcilePersistedProviderThr return true; }); updated ||= missingMessages.length > 0; + const existingSession = Option.isSome(existingDetail) ? existingDetail.value.session : null; + const hasActiveWriterConflict = + Option.isSome(existingDetail) && + existingDetail.value.activities?.some( + (activity) => activity.kind === "provider.thread.active-writer-conflict", + ); + const staleSessionErrorWasSuperseded = + existingSession?.status === "error" && + !hasActiveWriterConflict && + input.thread.messages.some((message) => message.createdAt > existingSession.updatedAt); yield* Effect.forEach( missingMessages, @@ -611,6 +621,27 @@ export const reconcilePersistedThread = Effect.fn("reconcilePersistedProviderThr { concurrency: 1, discard: true }, ); + if (staleSessionErrorWasSuperseded) { + yield* input.engine.dispatch({ + type: "thread.session.set", + commandId: importCommandId( + continuationIdentity, + input.thread.providerThreadId, + "session-healed", + input.thread.updatedAt, + ), + threadId: targetThreadId, + session: { + ...existingSession, + status: "stopped", + activeTurnId: null, + lastError: null, + updatedAt: input.thread.updatedAt, + }, + createdAt: input.thread.updatedAt, + }); + } + // Advance the discovery watermark only after every deterministic message // command has landed. A crash mid-import then retries safely on the next // pass instead of permanently hiding the remaining history. @@ -722,6 +753,7 @@ export const reconcilePersistedProviderThreadById = Effect.fn( export const ProviderThreadReconcilerLive = Layer.effect( ProviderThreadContinuity, Effect.gen(function* () { + const scope = yield* Effect.scope; const continuityContext = yield* Effect.context< | OrchestrationEngineService | ProjectionSnapshotQuery @@ -773,6 +805,40 @@ export const ProviderThreadReconcilerLive = Layer.effect( ), ); }); + const requestedThreadIds = yield* Ref.make(new Set()); + const requestReconcileThread = Effect.fn("ProviderThreadContinuity.requestReconcileThread")( + function* (threadId: ThreadId) { + const accepted = yield* Ref.modify(requestedThreadIds, (current) => { + if (current.has(threadId)) return [false, current] as const; + const next = new Set(current); + next.add(threadId); + return [true, next] as const; + }); + if (!accepted) return; + + yield* Effect.forkIn( + reconcileThread(threadId).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.void + : Effect.logWarning("Failed to reconcile requested provider thread", { + threadId, + detail: Cause.pretty(cause), + }), + ), + Effect.ensuring( + Ref.update(requestedThreadIds, (current) => { + const next = new Set(current); + next.delete(threadId); + return next; + }), + ), + ), + scope, + { startImmediately: true }, + ); + }, + ); const resolveWorkspaceRoot = (cwd: string) => repositoryIdentityResolver .resolve(cwd) @@ -800,6 +866,7 @@ export const ProviderThreadReconcilerLive = Layer.effect( return ProviderThreadContinuity.of({ reconcileThread, + requestReconcileThread, startSyncAll: syncCoordinator.start, getSyncStatus: syncCoordinator.getStatus, streamSyncStatus: syncCoordinator.changes, diff --git a/apps/server/src/provider/Services/ProviderThreadContinuity.ts b/apps/server/src/provider/Services/ProviderThreadContinuity.ts index dd1d1264c8bf..a1b63efa0026 100644 --- a/apps/server/src/provider/Services/ProviderThreadContinuity.ts +++ b/apps/server/src/provider/Services/ProviderThreadContinuity.ts @@ -22,6 +22,11 @@ export interface ProviderThreadContinuityShape { readonly reconcileThread: ( threadId: ThreadId, ) => Effect.Effect; + /** + * Request server-owned convergence without waiting for the provider read. + * Duplicate requests for one in-flight thread are coalesced. + */ + readonly requestReconcileThread: (threadId: ThreadId) => Effect.Effect; readonly startSyncAll: Effect.Effect; readonly getSyncStatus: Effect.Effect; readonly streamSyncStatus: Stream.Stream; diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 1ecd43d7a36b..a67a7eb2beb4 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -655,6 +655,7 @@ const buildAppUnderTest = (options?: { }), Layer.mock(ProviderThreadContinuity.ProviderThreadContinuity)({ reconcileThread: () => Effect.succeed(false), + requestReconcileThread: () => Effect.void, startSyncAll: Effect.succeed({ status: "idle" }), getSyncStatus: Effect.succeed({ status: "idle" }), streamSyncStatus: Stream.make({ status: "idle" }), @@ -6389,56 +6390,56 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("reconciles before sending a socket thread snapshot", () => - Effect.gen(function* () { - const thread = makeDefaultOrchestrationReadModel().threads[0]!; - const effects: string[] = []; - yield* buildAppUnderTest({ - layers: { - providerThreadContinuity: { - reconcileThread: (threadId) => - Effect.sync(() => { - effects.push(`reconcile:${threadId}`); - return true; - }), - }, - projectionSnapshotQuery: { - getThreadDetailSnapshot: () => - Effect.sync(() => { - effects.push("snapshot"); - return Option.some({ snapshotSequence: 1, thread }); - }), + it.effect( + "serves a cached socket thread snapshot after requesting background reconciliation", + () => + Effect.gen(function* () { + const thread = makeDefaultOrchestrationReadModel().threads[0]!; + const effects: string[] = []; + yield* buildAppUnderTest({ + layers: { + providerThreadContinuity: { + requestReconcileThread: (threadId) => + Effect.sync(() => { + effects.push(`request:${threadId}`); + }), + }, + projectionSnapshotQuery: { + getThreadDetailSnapshot: () => + Effect.sync(() => { + effects.push("snapshot"); + return Option.some({ snapshotSequence: 1, thread }); + }), + }, }, - }, - }); + }); - const wsUrl = yield* getWsServerUrl("/ws"); - const items = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[ORCHESTRATION_WS_METHODS.subscribeThread]({ - threadId: defaultThreadId, - requestCompletionMarker: true, - }).pipe(Stream.take(2), Stream.runCollect), - ), - ); + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeThread]({ + threadId: defaultThreadId, + requestCompletionMarker: true, + }).pipe(Stream.take(2), Stream.runCollect), + ), + ); - assert.equal(items[0]?.kind, "snapshot"); - assert.deepEqual(items[1], { kind: "synchronized" }); - assert.deepEqual(effects, [`reconcile:${defaultThreadId}`, "snapshot"]); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), + assert.equal(items[0]?.kind, "snapshot"); + assert.deepEqual(items[1], { kind: "synchronized" }); + assert.deepEqual(effects, [`request:${defaultThreadId}`, "snapshot"]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("reconciles before serving an HTTP thread snapshot", () => + it.effect("serves a cached HTTP thread snapshot after requesting background reconciliation", () => Effect.gen(function* () { const thread = makeDefaultOrchestrationReadModel().threads[0]!; const effects: string[] = []; yield* buildAppUnderTest({ layers: { providerThreadContinuity: { - reconcileThread: (threadId) => + requestReconcileThread: (threadId) => Effect.sync(() => { - effects.push(`reconcile:${threadId}`); - return true; + effects.push(`request:${threadId}`); }), }, projectionSnapshotQuery: { @@ -6459,7 +6460,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }); assert.equal(response.status, 200); - assert.deepEqual(effects, [`reconcile:${defaultThreadId}`, "snapshot"]); + assert.deepEqual(effects, [`request:${defaultThreadId}`, "snapshot"]); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -6728,16 +6729,15 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("reconciles a warm-cache thread before capturing its replay head", () => + it.effect("requests warm-cache reconciliation before capturing its replay head", () => Effect.gen(function* () { const effects: string[] = []; yield* buildAppUnderTest({ layers: { providerThreadContinuity: { - reconcileThread: (threadId) => + requestReconcileThread: (threadId) => Effect.sync(() => { - effects.push(`reconcile:${threadId}`); - return true; + effects.push(`request:${threadId}`); }), }, orchestrationEngine: { @@ -6766,7 +6766,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); assert.deepEqual(Option.getOrThrow(firstItem), { kind: "synchronized" }); - assert.deepEqual(effects, [`reconcile:${defaultThreadId}`, "head", "replay"]); + assert.deepEqual(effects, [`request:${defaultThreadId}`, "head", "replay"]); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 7c4572aee630..926f7277ef40 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1484,18 +1484,12 @@ const makeWsRpcLayer = ( if (input.afterSequence !== undefined) { const afterSequence = input.afterSequence; // A cursor restored from client cache has not passed through - // the HTTP snapshot endpoint on this connection. Reconcile - // before capturing the replay head so any externally appended - // Codex turns are persisted and included in the catch-up. + // the HTTP snapshot endpoint on this connection. Request + // reconciliation before capturing the replay head. Provider + // events that land after the captured head are already covered + // by the live buffer attached above. if (input.reconcileBeforeReplay === true) { - yield* providerThreadContinuity.reconcileThread(input.threadId).pipe( - Effect.catch((cause) => - Effect.logWarning("Failed to reconcile provider thread before replay", { - threadId: input.threadId, - detail: cause.message, - }), - ), - ); + yield* providerThreadContinuity.requestReconcileThread(input.threadId); } const headSequence = yield* orchestrationEngine.latestSequence; const replayGap = headSequence - afterSequence; @@ -1532,18 +1526,11 @@ const makeWsRpcLayer = ( // fresh thread detail instead of an unbounded replay. } - // HTTP-loaded snapshots already reconcile before their sequence is - // passed into this subscription. Reconcile here only on the - // fallback snapshot path, avoiding a duplicate Codex process and - // exact read for the normal HTTP-then-WebSocket flow. - yield* providerThreadContinuity.reconcileThread(input.threadId).pipe( - Effect.catch((cause) => - Effect.logWarning("Failed to reconcile provider thread before subscribing", { - threadId: input.threadId, - detail: cause.message, - }), - ), - ); + // HTTP-loaded snapshots already request reconciliation before their + // sequence is passed into this subscription. Request it here only on + // the fallback snapshot path; the cached snapshot renders now and + // the live buffer delivers any imported Codex events afterward. + yield* providerThreadContinuity.requestReconcileThread(input.threadId); const snapshot = yield* projectionSnapshotQuery .getThreadDetailSnapshot( input.threadId, diff --git a/docs/internals/overview.md b/docs/internals/overview.md index 2291ead0b014..6a25e0d8fab7 100644 --- a/docs/internals/overview.md +++ b/docs/internals/overview.md @@ -102,11 +102,13 @@ Each of the three services exposes `drain` for exactly this. [`ProviderThreadReconciler`][reconciler] is a separate activation-gated background service rather than a drainable worker. It discovers durable Codex conversations, imports missing historical -messages through the event-sourced command path, and reconciles an exact provider transcript before -thread reads and new local turns. Native T3-bound Codex identities remain attached to their original -thread; unmatched external conversations use a deterministic imported thread and project derived -from their Git root or working directory. Its explicit maintenance operation is server-owned and -streams replayable progress, while periodic and per-thread reconciliation remain incremental. +messages through the event-sourced command path, and reconciles exact provider transcripts. Thread +reads serve their cached projection while a scope-bound, per-thread-coalesced reconciliation runs in +the background; new local turns still wait for exact reconciliation before dispatch. Native +T3-bound Codex identities remain attached to their original thread; unmatched external +conversations use a deterministic imported thread and project derived from their Git root or working +directory. Its explicit maintenance operation is server-owned and streams replayable progress, +while periodic and per-thread reconciliation remain incremental. Runtime receipts are a test-only mechanism. `RuntimeReceiptBusLive` in [`RuntimeReceiptBus.ts`][receipts] publishes nothing; only the test layer is PubSub-backed. Do not diff --git a/docs/internals/providers.md b/docs/internals/providers.md index 7a3d6286ae32..253d74889dfa 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -94,11 +94,19 @@ command and message IDs. Imported message events are marked historical, so proje completed turns without pending provider work or retroactive checkpoints. The discovery cursor is advanced only after every import command lands, making a crash retry safe. -HTTP and WebSocket thread reads attempt exact reconciliation before loading the projection. Those -reads remain available if Codex is temporarily unavailable. `thread.turn.start` is stricter: exact -reconciliation must finish before the local user command is persisted, preventing a stale local -prompt from overtaking externally appended turns. Background loops start only after server -activation, and all clients observe the resulting ordinary orchestration projections. +HTTP and WebSocket thread reads request exact reconciliation but load the cached projection without +waiting for the provider read. Per-thread requests are coalesced while one is in flight and run in +the reconciler layer scope. The WebSocket subscription attaches its live event buffer first, so +messages imported after the cached snapshot or replay head still reach the client as ordinary +orchestration events. Reads therefore stay responsive if Codex is slow or temporarily unavailable. + +`thread.turn.start` is deliberately stricter: exact reconciliation must finish before the local +user command is persisted, preventing a stale local prompt from overtaking externally appended +turns. When a successful reconciliation observes provider messages newer than a generic error +session, it repairs that session to `stopped` and clears `lastError` without deleting the historical activity. +A typed active-writer conflict remains in error because a read-only transcript refresh does not +prove that exclusive writer ownership ended. Background loops start only after server activation, +and all clients observe the resulting ordinary orchestration projections. ### Codex active-writer recovery diff --git a/docs/user/providers-codex.md b/docs/user/providers-codex.md index 8da762f316c5..d91da2950b0b 100644 --- a/docs/user/providers-codex.md +++ b/docs/user/providers-codex.md @@ -41,10 +41,14 @@ Settings or reconnect from another device. Its progress shows how many sessions how many were organized, updated, unchanged, or failed. You can also start a thread in T3 Code, continue it in Codex CLI or the Codex app, and return to the -same T3 Code thread. T3 Code refreshes the durable Codex history when the thread opens and again -before it sends your next message. If that refresh fails, T3 Code does not send the new message, so -it cannot silently fork the conversation from stale history. Retry after the Codex thread is -available again. +same T3 Code thread. When the thread opens, T3 Code shows its saved messages immediately and +refreshes the durable Codex history in the background. Externally added messages appear as that +refresh completes. If newer Codex history proves that an older generic failure is obsolete, T3 Code +clears the stale **Failed** state while keeping the failure in the activity history. + +T3 Code refreshes the history synchronously again before it sends your next message. If that refresh +fails, T3 Code does not send the new message, so it cannot silently fork the conversation from stale +history. Retry after the Codex thread is available again. Codex allows only one client to write to a conversation at a time. If the conversation is still open in Codex CLI or the Codex app, T3 Code keeps your message pending and explains the two safe @@ -56,6 +60,8 @@ choices: original. Retry and copy both reuse the pending message; neither adds a duplicate to the T3 Code transcript. +Background history refresh does not dismiss this active-writer warning; it stays actionable until +you retry or continue in a copy. ## Send feedback to OpenAI