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 929080b38c4d..56ed5149c1bc 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"; @@ -31,6 +32,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 +133,18 @@ const withLiveProjectCliServer = (baseDir: string, run: () => Effect.Ef ), ), Layer.provideMerge(makeProjectPersistenceLayer(config)), + Layer.provide( + Layer.succeed( + 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" }), + }), + ), + ), Layer.provideMerge( NodeHttpServer.layer(NodeHttp.createServer, { host: "127.0.0.1", 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/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..31d08df3f4b7 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") { @@ -809,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 ? { @@ -890,6 +895,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 +1338,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 +1409,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 +1430,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/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.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.snoozed.test.ts b/apps/server/src/orchestration/decider.snoozed.test.ts index 1012240b18a0..a464c97f6883 100644 --- a/apps/server/src/orchestration/decider.snoozed.test.ts +++ b/apps/server/src/orchestration/decider.snoozed.test.ts @@ -23,10 +23,12 @@ 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"]; readonly messages?: OrchestrationThread["messages"]; + readonly session?: OrchestrationThread["session"]; }): OrchestrationReadModel { return { snapshotSequence: 0, @@ -47,20 +49,67 @@ 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 ?? [], proposedPlans: [], activities: input.activities ?? [], checkpoints: [], - session: null, + session: input.session ?? null, }, ], updatedAt: NOW, }; } +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* () { @@ -283,4 +332,51 @@ 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: makeRecoverableReadModel({ + messageId, + snoozedUntil: FUTURE_WAKE, + }), + }); + const events = Array.isArray(result) ? result : [result]; + expect(events.map((event) => event.type)).toEqual([ + "thread.unsnoozed", + "thread.turn-start-requested", + ]); + }), + ); + + 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 f3fdd462f437..6c5a96392973 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 @@ -811,6 +816,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 +840,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 ? { @@ -1039,6 +1052,94 @@ 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}'.`, + }); + } + const turnStartRequestedEvent: Omit = { + ...(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, + }, + }; + 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": { yield* requireThread({ readModel, @@ -1255,6 +1356,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..964af36aa1b0 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,7 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( Effect.fn("environment.orchestration.threadSnapshot")(function* (args) { yield* annotateEnvironmentRequest(args.endpoint.name); yield* requireEnvironmentScope(AuthOrchestrationReadScope); + yield* providerThreadContinuity.requestReconcileThread(args.params.threadId); const snapshot = yield* projectionSnapshotQuery .getThreadDetailSnapshot( args.params.threadId, @@ -96,7 +99,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/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/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 0f7d999662e9..ee781a91ee79 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( @@ -1689,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 } @@ -1884,6 +1886,57 @@ 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: + input?.includeUnchangedMetadata === true ? false : completedInitialPersistedDiscovery, + ...(input?.includeUnchangedMetadata !== undefined + ? { includeUnchangedMetadata: input.includeUnchangedMetadata } + : {}), + }, + input?.includeUnchangedMetadata === true + ? undefined + : completedInitialPersistedDiscovery + ? undefined + : { startCursor: initialPersistedDiscoveryCursor, maxPages: 1 }, + ).pipe( + Effect.tap((result) => + Effect.sync(() => { + 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( + (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 +2026,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 +2078,8 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( sendTurn, interruptTurn, readThread, + discoverPersistedThreads, + readPersistedThread, rollbackThread, uploadFeedback, respondToRequest, 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/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 51604abb23f9..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], ) => { @@ -815,10 +863,10 @@ 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: ( + request: ( method: M, _payload: CodexRpc.ClientRequestParamsByMethod[M], ) => { @@ -826,7 +874,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 +895,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/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 new file mode 100644 index 000000000000..c97b35eab97f --- /dev/null +++ b/apps/server/src/provider/Layers/CodexThreadDiscovery.test.ts @@ -0,0 +1,368 @@ +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import type * as EffectCodexSchema from "effect-codex-app-server/schema"; + +import { + listCodexThreadsForRead, + materializeCodexPersistedThreads, + 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 result = 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(result.threads.map((thread) => thread.id)).toEqual(["new"]); + expect(requestedCursors).toEqual([undefined, "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 = []; + 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 new file mode 100644 index 000000000000..4bfc13c217b7 --- /dev/null +++ b/apps/server/src/provider/Layers/CodexThreadDiscovery.ts @@ -0,0 +1,345 @@ +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"; + +// 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 THREAD_READ_TIMEOUT = "30 seconds" as const; +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}`; +} + +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 (!isVisibleCodexThread(thread, discoveryInput)) { + 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.timeout(THREAD_READ_TIMEOUT), + 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))); +} + +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, +) { + 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, + pagination?: { + readonly startCursor?: string | undefined; + readonly maxPages?: number | undefined; + }, +): 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 { + const page: EffectCodexSchema.V2ThreadListResponse = yield* requestPage(cursor ?? undefined); + 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 && + discoveryInput.includeUnchangedMetadata !== true && + changed.length === 0 + ) { + break; + } + if (pagination?.maxPages !== undefined && pagesRead >= pagination.maxPages) { + break; + } + } while (cursor); + return { threads: selected, metadataThreads, 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", + }), + pagination, + ); + + 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/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); 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..249b87dba958 --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderThreadReconciler.test.ts @@ -0,0 +1,1168 @@ +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 { 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"; + +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("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[] = []; + 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(); + + const outcome = yield* reconcilePersistedThread({ + instance, + thread: persistedThread, + model: "gpt-5.6-sol", + threadByProviderIdentity: identities, + directory, + snapshots, + engine, + }); + + expect(commands.map((command) => command.type)).toEqual([ + "project.create", + "thread.create", + "thread.message.import", + "thread.message.import", + ]); + expect(commands[1]).toMatchObject({ + type: "thread.create", + projectId: "provider-workspace:b8e66955aa280f1e6b3db463", + title: "External work", + 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", + 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", + }, + }); + 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")}`, + }); + }), +); + +it.effect("reuses the workspace 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"], + }); + + expect(commands.map((command) => command.type)).toEqual([ + "thread.create", + "thread.message.import", + "thread.message.import", + ]); + expect(commands[0]).toMatchObject({ + type: "thread.create", + projectId: "provider-workspace:b8e66955aa280f1e6b3db463", + }); + }), +); + +it.effect("moves an existing imported thread from unassigned into its workspace project", () => + 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.some({ + id: importedThreadId, + projectId: "provider-imports:legacy", + modelSelection: { + instanceId: ProviderInstanceId.make("codex-work"), + model: "gpt-5.6-sol", + }, + }), + ), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShellById: () => + Effect.succeed( + Option.some({ + id: "provider-workspace:b8e66955aa280f1e6b3db463", + }), + ), + 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"], + }); + + 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-work", + runtimePayload: { + modelSelection: { instanceId: "codex-work", 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, + }); + + 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"], + }), + ); + + 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"], + }); + + 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("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"); + 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"], + }); + + 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"], + }); + + 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"], + }); + + 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, + 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: [ + { 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"], + }); + + 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, + 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: [ + { + 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"], + }); + + 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..fe8e2043547c --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderThreadReconciler.ts @@ -0,0 +1,875 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeCrypto from "node:crypto"; + +import { + CommandId, + DEFAULT_MODEL_BY_PROVIDER, + MessageId, + ProjectId, + type ProviderThreadSyncProgress, + 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 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"; +import { + ProviderThreadContinuity, + 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); +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 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 { + 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* ( + 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; + const engine = yield* OrchestrationEngineService; + 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(); + const progress = yield* Ref.make({ + total: 0, + completed: 0, + organized: 0, + updated: 0, + unchanged: 0, + failed: 0, + }); + + 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); + } + } + } + + 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()), + ...(options.includeUnchangedMetadata !== undefined + ? { includeUnchangedMetadata: options.includeUnchangedMetadata } + : {}), + }); + 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* Ref.update(progress, (current) => ({ + ...current, + total: current.total + discovered.length, + })); + + yield* Effect.forEach( + discovered, + (thread) => + 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; + }).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" }, + ); + const result = yield* Ref.get(progress); + if (result.completed === 0 && options.onProgress) yield* options.onProgress(result); + return result; + }, +); + +export const reconcilePersistedThread = Effect.fn("reconcilePersistedProviderThread")( + 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); + 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 "unchanged" as const; + } + + const matchingProject = managesImportedThread + ? yield* input.snapshots.getActiveProjectByWorkspaceRoot(workspaceRoot) + : Option.none(); + const projectId = Option.isSome(matchingProject) + ? matchingProject.value.id + : managesImportedThread + ? 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(workspaceRoot)), + projectId, + title: workspaceProjectTitle(workspaceRoot), + workspaceRoot, + defaultModelSelection: { instanceId: input.instance.instanceId, model: input.model }, + createdAt: input.thread.createdAt, + }); + } + } + + if (importsNewThread && Option.isNone(existingThread)) { + organized = true; + 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: null, + createdAt: input.thread.createdAt, + }); + } 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) { + organized ||= projectChanged; + updated ||= ownerChanged; + 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) + ? 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; + }); + 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, + (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 }, + ); + + 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. + 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); + return organized + ? ("organized" as const) + : updated + ? ("updated" as const) + : ("unchanged" as const); + }, +); + +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 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, + }); + return true; +}); + +export const ProviderThreadReconcilerLive = Layer.effect( + ProviderThreadContinuity, + Effect.gen(function* () { + const scope = yield* Effect.scope; + const continuityContext = yield* Effect.context< + | OrchestrationEngineService + | ProjectionSnapshotQuery + | ProviderInstanceRegistry + | 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)( + reconcilePersistedProviderThreads().pipe( + Effect.provideContext(continuityContext), + Effect.catchCause((cause) => + recoverReconciliationCause( + cause, + "persisted provider thread reconciliation failed", + {}, + { + total: 0, + completed: 0, + organized: 0, + updated: 0, + unchanged: 0, + 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, + }), + ), + ), + ); + }); + 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) + .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((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, + requestReconcileThread, + 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 634745832b37..8b3120c94b2e 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -44,6 +44,39 @@ 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; + /** + * 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 { /** * Provider kind implemented by this adapter. @@ -108,6 +141,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..a1b63efa0026 --- /dev/null +++ b/apps/server/src/provider/Services/ProviderThreadContinuity.ts @@ -0,0 +1,38 @@ +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", + { + 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; + /** + * 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; +} + +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..a67a7eb2beb4 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,14 @@ 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), + requestReconcileThread: () => Effect.void, + startSyncAll: Effect.succeed({ status: "idle" }), + getSyncStatus: Effect.succeed({ status: "idle" }), + streamSyncStatus: Stream.make({ status: "idle" }), + ...options?.layers?.providerThreadContinuity, + }), ), ), Layer.provide( @@ -4486,6 +4498,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(); @@ -5256,6 +5316,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,30 +6390,77 @@ 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( + "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), + ), + ); + + 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("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: { + requestReconcileThread: (threadId) => + Effect.sync(() => { + effects.push(`request:${threadId}`); + }), + }, projectionSnapshotQuery: { getThreadDetailSnapshot: () => - Effect.succeed(Option.some({ snapshotSequence: 1, thread })), + 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 baseUrl = yield* getHttpServerUrl(); + const cookie = yield* getAuthenticatedSessionCookieHeader(); + const response = yield* measureHttpGet({ + url: `${baseUrl}/api/orchestration/threads/${defaultThreadId}`, + headers: { cookie }, + }); - assert.equal(items[0]?.kind, "snapshot"); - assert.deepEqual(items[1], { kind: "synchronized" }); + assert.equal(response.status, 200); + assert.deepEqual(effects, [`request:${defaultThreadId}`, "snapshot"]); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -6456,6 +6663,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 +6690,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 +6725,48 @@ 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)), + ); + + it.effect("requests warm-cache reconciliation before capturing its replay head", () => + Effect.gen(function* () { + const effects: string[] = []; + yield* buildAppUnderTest({ + layers: { + providerThreadContinuity: { + requestReconcileThread: (threadId) => + Effect.sync(() => { + effects.push(`request:${threadId}`); + }), + }, + 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, [`request:${defaultThreadId}`, "head", "replay"]); }).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..926f7277ef40 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"), @@ -1469,6 +1483,14 @@ 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. 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.requestReconcileThread(input.threadId); + } const headSequence = yield* orchestrationEngine.latestSequence; const replayGap = headSequence - afterSequence; if (replayGap >= 0 && replayGap <= THREAD_RESUME_MAX_GAP) { @@ -1504,6 +1526,11 @@ const makeWsRpcLayer = ( // fresh thread detail instead of an unbounded replay. } + // 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, @@ -1566,6 +1593,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, @@ -1619,6 +1652,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..e9d277e808ae 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, + getActiveWriterRecovery, 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,48 @@ function ChatViewContent(props: ChatViewProps) { const threadError = isServerThread ? (localServerError ?? activeServerThread?.session?.lastError ?? null) : localDraftError; + const activeWriterRecoveryRaw = isServerThread + ? getActiveWriterRecovery({ + sessionStatus: activeServerThread?.session?.status, + error: threadError, + activities: activeServerThread?.activities ?? [], + }) + : null; + const activeWriterRecovery = + activeThread?.messages.find( + (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 || activeWriterRecovery === null) return; + setPendingActiveWriterConflictId(activeWriterRecovery.conflictId); + const result = await recoverThreadTurn({ + environmentId, + input: { + threadId: activeThread.id, + messageId: activeWriterRecovery.messageId, + strategy, + }, + }); + if (result._tag === "Failure") { + setPendingActiveWriterConflictId(null); + if (!isAtomCommandInterrupted(result)) { + toastManager.add({ + type: "error", + title: "Could not continue the Codex session", + }); + } + } + }, + [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 // just fall through to the persisted one. Mask the current error until a @@ -6869,6 +6915,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..4c822faf8fe9 100644 --- a/apps/web/src/components/chat/ThreadErrorBanner.test.tsx +++ b/apps/web/src/components/chat/ThreadErrorBanner.test.tsx @@ -3,13 +3,76 @@ import { describe, expect, it } from "vite-plus/test"; import { dismissThreadErrorBannerForSession, + getActiveWriterRecovery, 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("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); @@ -85,4 +148,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..943d10b0119a 100644 --- a/apps/web/src/components/chat/ThreadErrorBanner.tsx +++ b/apps/web/src/components/chat/ThreadErrorBanner.tsx @@ -1,9 +1,41 @@ import { memo } from "react"; +import { CODEX_ACTIVE_WRITER_CONFLICT_MESSAGE, MessageId } 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 getActiveWriterRecovery(input: { + sessionStatus: string | null | undefined; + error: string | null | undefined; + 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; + } + 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; + 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}`; } @@ -36,9 +68,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 +84,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/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/docs/internals/overview.md b/docs/internals/overview.md index b9454f7b58d0..6a25e0d8fab7 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,16 @@ 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 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 build production behavior on receipts. @@ -147,6 +157,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..253d74889dfa 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. @@ -45,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 @@ -66,6 +74,55 @@ 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 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 +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 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 + +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 @@ -85,6 +142,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..d91da2950b0b 100644 --- a/docs/user/providers-codex.md +++ b/docs/user/providers-codex.md @@ -28,6 +28,41 @@ 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`. 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. 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 +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. +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 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/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/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.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..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", @@ -591,6 +606,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 +810,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), @@ -866,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, @@ -951,6 +983,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadRuntimeModeSetCommand, ThreadInteractionModeSetCommand, ThreadTurnStartCommand, + ThreadTurnRecoverCommand, ThreadTurnInterruptCommand, ThreadApprovalRespondCommand, ThreadUserInputRespondCommand, @@ -979,6 +1012,7 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadRuntimeModeSetCommand, ThreadInteractionModeSetCommand, ClientThreadTurnStartCommand, + ThreadTurnRecoverCommand, ThreadTurnInterruptCommand, ThreadApprovalRespondCommand, ThreadUserInputRespondCommand, @@ -1014,6 +1048,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 +1107,7 @@ const ThreadTitleRegenerationCompleteCommand = Schema.Struct({ const InternalOrchestrationCommand = Schema.Union([ ThreadSessionSetCommand, + ThreadMessageImportCommand, ThreadMessageAssistantDeltaCommand, ThreadMessageAssistantCompleteCommand, ThreadProposedPlanUpsertCommand, @@ -1227,6 +1273,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. */ @@ -1264,6 +1311,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, }); @@ -1278,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", 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}`); } }