authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-25 23:38:02-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-26 13:20:36-08:00
logbb8a8444f4c9e1b4e9ad6c33c9a3c0e020f1b6c7
treefc1a31c9184bdd7ef72546244c7eae2ac75c60aa
parent3ee2e1011dcff6dc46f5a1e0610d6265fb485702

Bind provider sessions to thread IDs and preserve resume cursors

- Require `threadId` when starting provider sessions and route recovery via persisted bindings - Persist and propagate provider `resumeCursor`/`providerThreadId` instead of synthesizing values - Expose full session bindings in `ProviderSessionDirectory` and update tests/integration fixtures - Rename sqlite state file from `orchestration.sqlite` to `state.sqlite`

15 files changed, 277 insertions(+), 150 deletions(-)

.plans/16c-pr89-remediation-checklist.md+3-3
......@@ -192,14 +192,14 @@ Counts: active `51` (`valid=33`, `partially-valid=18`), closed-invalid `6`
192192
193193### Phase 3
194194
195- [ ] `C008` Inconsistent input normalization across CheckpointStore methods
196 - Status: `TODO`
195- [x] `C008` Inconsistent input normalization across CheckpointStore methods
196 - Status: `DONE`
197197 - Verdict: `partially-valid`
198198 - Severity: `Medium`
199199 - Area: `Checkpointing correctness`
200200 - File: `apps/server/src/checkpointing/Layers/CheckpointStore.ts:94`
201201 - Threads: PRRT_kwDORLtfbc5widJw, PRRT_kwDORLtfbc5wnWv_, PRRT_kwDORLtfbc5w0_g7, PRRT_kwDORLtfbc5w1C36 (+3 duplicate thread(s))
202 - Audit note: Normalization inconsistency remains, but upstream callsites now mostly canonicalize inputs.
202 - Audit note: Edge schema strategy is in place across contracts/consumers (trim/normalize via schemas and decode at boundaries); CheckpointStore remains an internal repository boundary.
203203
204204- [ ] `C017` `REQUIRED_SNAPSHOT_PROJECTORS` includes `pending-approvals` and `thread-turns`, but `getSnapshot` doesn't query their data. If these projectors lag behind, the returned `snapshotSequence` will be lower than what the included data actually reflects, causing clients to replay already-applied events. Consider filtering `REQUIRED_SNAPSHOT_PROJECTORS` to only include projectors whose data is actually fetched in the snapshot. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary>
205205 - Status: `TODO`
apps/server/integration/OrchestrationEngineHarness.integration.ts+1-1
......@@ -159,7 +159,7 @@ export const makeOrchestrationIntegrationHarness = Effect.gen(function* () {
159159 const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-orchestration-integration-"));
160160 const workspaceDir = path.join(rootDir, "workspace");
161161 const stateDir = path.join(rootDir, "state");
162 const dbPath = path.join(stateDir, "orchestration.sqlite");
162 const dbPath = path.join(stateDir, "state.sqlite");
163163 fs.mkdirSync(workspaceDir, { recursive: true });
164164 fs.mkdirSync(stateDir, { recursive: true });
165165 initializeGitWorkspace(workspaceDir);
apps/server/integration/providerService.integration.test.ts+29-16
......@@ -1,4 +1,5 @@
11import type { ProviderRuntimeEvent, ProviderSessionId } from "@t3tools/contracts";
2import { ThreadId } from "@t3tools/contracts";
23import { NodeServices } from "@effect/platform-node";
34import { it, assert } from "@effect/vitest";
45import { Effect, FileSystem, Layer, Path, Queue, Stream } from "effect";
......@@ -116,10 +117,13 @@ it.effect("replays typed runtime fixture events", () =>
116117
117118 yield* Effect.gen(function* () {
118119 const provider = yield* ProviderService;
119 const session = yield* provider.startSession({
120 provider: "codex",
121 cwd: fixture.cwd,
122 });
120 const session = yield* provider.startSession(
121 ThreadId.makeUnsafe("thread-integration-typed"),
122 {
123 provider: "codex",
124 cwd: fixture.cwd,
125 },
126 );
123127 assert.equal((session.threadId ?? "").length > 0, true);
124128
125129 const observedEvents = yield* runTurn({
......@@ -146,10 +150,13 @@ it.effect("replays file-changing fixture turn events", () =>
146150
147151 yield* Effect.gen(function* () {
148152 const provider = yield* ProviderService;
149 const session = yield* provider.startSession({
150 provider: "codex",
151 cwd: fixture.cwd,
152 });
153 const session = yield* provider.startSession(
154 ThreadId.makeUnsafe("thread-integration-tools"),
155 {
156 provider: "codex",
157 cwd: fixture.cwd,
158 },
159 );
153160 assert.equal((session.threadId ?? "").length > 0, true);
154161
155162 const observedEvents = yield* runTurn({
......@@ -180,10 +187,13 @@ it.effect("runs multi-turn tool/approval flow", () =>
180187
181188 yield* Effect.gen(function* () {
182189 const provider = yield* ProviderService;
183 const session = yield* provider.startSession({
184 provider: "codex",
185 cwd: fixture.cwd,
186 });
190 const session = yield* provider.startSession(
191 ThreadId.makeUnsafe("thread-integration-multi"),
192 {
193 provider: "codex",
194 cwd: fixture.cwd,
195 },
196 );
187197 assert.equal((session.threadId ?? "").length > 0, true);
188198
189199 const firstTurnEvents = yield* runTurn({
......@@ -229,10 +239,13 @@ it.effect("rolls back provider conversation state only", () =>
229239
230240 yield* Effect.gen(function* () {
231241 const provider = yield* ProviderService;
232 const session = yield* provider.startSession({
233 provider: "codex",
234 cwd: fixture.cwd,
235 });
242 const session = yield* provider.startSession(
243 ThreadId.makeUnsafe("thread-integration-rollback"),
244 {
245 provider: "codex",
246 cwd: fixture.cwd,
247 },
248 );
236249 assert.equal((session.threadId ?? "").length > 0, true);
237250
238251 yield* runTurn({
apps/server/src/codexAppServerManager.test.ts+6
......@@ -204,6 +204,9 @@ describe("sendTurn", () => {
204204 expect(result).toEqual({
205205 threadId: "thread_1",
206206 turnId: "turn_1",
207 resumeCursor: {
208 threadId: "thread_1",
209 },
207210 });
208211 expect(requireSession).toHaveBeenCalledWith("sess_1");
209212 expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", {
......@@ -225,6 +228,9 @@ describe("sendTurn", () => {
225228 expect(updateSession).toHaveBeenCalledWith(context, {
226229 status: "running",
227230 activeTurnId: "turn_1",
231 resumeCursor: {
232 threadId: "thread_1",
233 },
228234 });
229235 });
230236
apps/server/src/codexAppServerManager.ts+19-3
......@@ -221,15 +221,16 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
221221 ...sessionOverrides,
222222 experimentalRawEvents: false,
223223 };
224 const resumeThreadId = readResumeThreadId(input);
224225
225226 let threadOpenMethod: "thread/start" | "thread/resume" = "thread/start";
226227 let threadOpenResponse: unknown;
227 if (input.resumeThreadId) {
228 if (resumeThreadId) {
228229 try {
229230 threadOpenMethod = "thread/resume";
230231 threadOpenResponse = await this.sendRequest(context, "thread/resume", {
231232 ...sessionOverrides,
232 threadId: input.resumeThreadId,
233 threadId: resumeThreadId,
233234 });
234235 } catch (error) {
235236 if (!isRecoverableThreadResumeError(error)) {
......@@ -240,7 +241,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
240241 this.emitLifecycleEvent(
241242 context,
242243 "session/threadResumeFallback",
243 `Could not resume thread ${input.resumeThreadId}; started a new thread instead.`,
244 `Could not resume thread ${resumeThreadId}; started a new thread instead.`,
244245 );
245246 threadOpenResponse = await this.sendRequest(context, "thread/start", threadStartParams);
246247 }
......@@ -261,6 +262,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
261262 this.updateSession(context, {
262263 status: "ready",
263264 threadId,
265 resumeCursor: { threadId },
264266 });
265267 this.emitLifecycleEvent(context, "session/ready", `Connected to thread ${threadId}`);
266268 return { ...context.session };
......@@ -347,11 +349,13 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
347349 this.updateSession(context, {
348350 status: "running",
349351 activeTurnId: turnId,
352 resumeCursor: context.session.resumeCursor ?? { threadId: context.session.threadId },
350353 });
351354
352355 return {
353356 threadId: context.session.threadId,
354357 turnId,
358 resumeCursor: context.session.resumeCursor ?? { threadId: context.session.threadId },
355359 };
356360 }
357361
......@@ -976,6 +980,18 @@ function toProviderThreadId(value: string | undefined): ProviderThreadId | undef
976980 return brandIfNonEmpty(value, ProviderThreadId.makeUnsafe);
977981}
978982
983function readResumeThreadId(input: ProviderSessionStartInput): ProviderThreadId | undefined {
984 if (
985 !input.resumeCursor ||
986 typeof input.resumeCursor !== "object" ||
987 Array.isArray(input.resumeCursor)
988 ) {
989 return undefined;
990 }
991 const rawThreadId = (input.resumeCursor as Record<string, unknown>).threadId;
992 return typeof rawThreadId === "string" ? toProviderThreadId(rawThreadId) : input.resumeThreadId;
993}
994
979995function toProviderTurnId(value: string | undefined): ProviderTurnId | undefined {
980996 return brandIfNonEmpty(value, ProviderTurnId.makeUnsafe);
981997}
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+3-2
......@@ -71,7 +71,7 @@ describe("ProviderCommandReactor", () => {
7171 async function createHarness() {
7272 const now = new Date().toISOString();
7373 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
74 const startSession = vi.fn((_: unknown) =>
74 const startSession = vi.fn((_: unknown, __: unknown) =>
7575 Effect.succeed({
7676 sessionId: asSessionId("sess-1"),
7777 provider: "codex" as const,
......@@ -177,7 +177,8 @@ describe("ProviderCommandReactor", () => {
177177
178178 await waitFor(() => harness.startSession.mock.calls.length === 1);
179179 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
180 expect(harness.startSession.mock.calls[0]?.[0]).toMatchObject({
180 expect(harness.startSession.mock.calls[0]?.[0]).toEqual(ThreadId.makeUnsafe("thread-1"));
181 expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
181182 cwd: "/tmp/provider-project",
182183 model: "gpt-5-codex",
183184 });
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+8-5
......@@ -152,11 +152,14 @@ const make = Effect.gen(function* () {
152152 projects: readModel.projects,
153153 });
154154
155 const startedSession = yield* providerService.startSession({
156 ...(preferredProvider ? { provider: preferredProvider } : {}),
157 ...(effectiveCwd ? { cwd: effectiveCwd } : {}),
158 ...(thread.model ? { model: thread.model } : {}),
159 });
155 const startedSession = yield* providerService.startSession(
156 threadId,
157 {
158 ...(preferredProvider ? { provider: preferredProvider } : {}),
159 ...(effectiveCwd ? { cwd: effectiveCwd } : {}),
160 ...(thread.model ? { model: thread.model } : {}),
161 },
162 );
160163
161164 yield* setThreadSession({
162165 threadId,
apps/server/src/persistence/Layers/Sqlite.ts+1-1
......@@ -52,6 +52,6 @@ export const SqlitePersistenceMemory = Layer.provideMerge(
5252export const layerConfig = Effect.gen(function* () {
5353 const { stateDir } = yield* ServerConfig;
5454 const { join } = yield* Path.Path;
55 const dbPath = join(stateDir, "orchestration.sqlite");
55 const dbPath = join(stateDir, "state.sqlite");
5656 return makeSqlitePersistenceLive(dbPath);
5757}).pipe(Layer.unwrap);
apps/server/src/provider/Layers/ProviderService.test.ts+67-40
......@@ -49,6 +49,7 @@ const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.make
4949const asTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);
5050const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value);
5151const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
52const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
5253
5354function makeFakeCodexAdapter() {
5455 const sessions = new Map<ProviderSessionId, ProviderSession>();
......@@ -322,10 +323,13 @@ it.effect(
322323
323324 const startedSession = yield* Effect.gen(function* () {
324325 const provider = yield* ProviderService;
325 return yield* provider.startSession({
326 provider: "codex",
327 cwd: "/tmp/project",
328 });
326 return yield* provider.startSession(
327 asThreadId("thread-1"),
328 {
329 provider: "codex",
330 cwd: "/tmp/project",
331 },
332 );
329333 }).pipe(Effect.provide(firstProviderLayer));
330334
331335 yield* Effect.gen(function* () {
......@@ -342,9 +346,7 @@ it.effect(
342346 assert.equal(Option.isSome(persistedAfterStopAll), true);
343347 if (Option.isSome(persistedAfterStopAll)) {
344348 assert.equal(persistedAfterStopAll.value.status, "stopped");
345 assert.deepEqual(persistedAfterStopAll.value.resumeCursor, {
346 resumeThreadId: startedSession.threadId,
347 });
349 assert.equal(persistedAfterStopAll.value.resumeCursor, null);
348350 }
349351
350352 const secondCodex = makeFakeCodexAdapter();
......@@ -396,10 +398,13 @@ routing.layer("ProviderServiceLive routing", (it) => {
396398 Effect.gen(function* () {
397399 const provider = yield* ProviderService;
398400
399 const session = yield* provider.startSession({
400 provider: "codex",
401 cwd: "/tmp/project",
402 });
401 const session = yield* provider.startSession(
402 asThreadId("thread-1"),
403 {
404 provider: "codex",
405 cwd: "/tmp/project",
406 },
407 );
403408 assert.equal(session.provider, "codex");
404409
405410 const sessions = yield* provider.listSessions();
......@@ -448,10 +453,13 @@ routing.layer("ProviderServiceLive routing", (it) => {
448453 Effect.gen(function* () {
449454 const provider = yield* ProviderService;
450455
451 const initial = yield* provider.startSession({
452 provider: "codex",
453 cwd: "/tmp/project",
454 });
456 const initial = yield* provider.startSession(
457 asThreadId("thread-1"),
458 {
459 provider: "codex",
460 cwd: "/tmp/project",
461 },
462 );
455463 yield* routing.codex.stopSession(initial.sessionId);
456464 routing.codex.startSession.mockClear();
457465 routing.codex.rollbackThread.mockClear();
......@@ -479,12 +487,18 @@ routing.layer("ProviderServiceLive routing", (it) => {
479487 Effect.gen(function* () {
480488 const provider = yield* ProviderService;
481489
482 yield* provider.startSession({
483 provider: "codex",
484 });
485 yield* provider.startSession({
486 provider: "codex",
487 });
490 yield* provider.startSession(
491 asThreadId("thread-1"),
492 {
493 provider: "codex",
494 },
495 );
496 yield* provider.startSession(
497 asThreadId("thread-2"),
498 {
499 provider: "codex",
500 },
501 );
488502
489503 yield* provider.stopAll();
490504
......@@ -498,9 +512,12 @@ routing.layer("ProviderServiceLive routing", (it) => {
498512 const provider = yield* ProviderService;
499513 const runtimeRepository = yield* ProviderSessionRuntimeRepository;
500514
501 const session = yield* provider.startSession({
502 provider: "codex",
503 });
515 const session = yield* provider.startSession(
516 asThreadId("thread-1"),
517 {
518 provider: "codex",
519 },
520 );
504521 yield* provider.sendTurn({
505522 sessionId: session.sessionId,
506523 input: "hello",
......@@ -513,9 +530,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
513530 assert.equal(Option.isSome(runningRuntime), true);
514531 if (Option.isSome(runningRuntime)) {
515532 assert.equal(runningRuntime.value.status, "running");
516 assert.deepEqual(runningRuntime.value.resumeCursor, {
517 resumeThreadId: "thread-1",
518 });
533 assert.equal(runningRuntime.value.resumeCursor, null);
519534 const payload = runningRuntime.value.runtimePayload;
520535 assert.equal(payload !== null && typeof payload === "object", true);
521536 if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) {
......@@ -551,9 +566,12 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
551566 it.effect("fans out adapter turn completion events", () =>
552567 Effect.gen(function* () {
553568 const provider = yield* ProviderService;
554 const session = yield* provider.startSession({
555 provider: "codex",
556 });
569 const session = yield* provider.startSession(
570 asThreadId("thread-1"),
571 {
572 provider: "codex",
573 },
574 );
557575
558576 const eventsRef = yield* Ref.make<Array<ProviderRuntimeEvent>>([]);
559577 const consumer = yield* Stream.runForEach(provider.streamEvents, (event) =>
......@@ -588,9 +606,12 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
588606 it.effect("keeps subscriber delivery ordered and isolates failing subscribers", () =>
589607 Effect.gen(function* () {
590608 const provider = yield* ProviderService;
591 const session = yield* provider.startSession({
592 provider: "codex",
593 });
609 const session = yield* provider.startSession(
610 asThreadId("thread-1"),
611 {
612 provider: "codex",
613 },
614 );
594615
595616 const receivedByHealthy: string[] = [];
596617 const expectedEventIds = new Set<string>(["evt-ordered-1", "evt-ordered-2", "evt-ordered-3"]);
......@@ -665,9 +686,12 @@ validation.layer("ProviderServiceLive validation", (it) => {
665686 const provider = yield* ProviderService;
666687
667688 const failure = yield* Effect.result(
668 provider.startSession({
669 provider: "invalid-provider",
670 } as never),
689 provider.startSession(
690 asThreadId("thread-validation"),
691 {
692 provider: "invalid-provider",
693 } as never,
694 ),
671695 );
672696
673697 assert.equal(failure._tag, "Failure");
......@@ -702,10 +726,13 @@ validation.layer("ProviderServiceLive validation", (it) => {
702726 );
703727
704728 const failure = yield* Effect.result(
705 provider.startSession({
706 provider: "codex",
707 cwd: "/tmp/project",
708 }),
729 provider.startSession(
730 asThreadId("thread-missing"),
731 {
732 provider: "codex",
733 cwd: "/tmp/project",
734 },
735 ),
709736 );
710737
711738 assertFailure(
apps/server/src/provider/Layers/ProviderService.ts+71-43
......@@ -24,10 +24,13 @@ import {
2424} from "@t3tools/contracts";
2525import { Effect, Layer, Option, PubSub, Queue, Ref, Schema, Stream } from "effect";
2626
27import { ProviderValidationError } from "../Errors.ts";
27import { ProviderSessionNotFoundError, ProviderValidationError } from "../Errors.ts";
2828import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts";
2929import { ProviderService, type ProviderServiceShape } from "../Services/ProviderService.ts";
30import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
30import {
31 ProviderSessionDirectory,
32 type ProviderSessionBinding,
33} from "../Services/ProviderSessionDirectory.ts";
3134import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
3235
3336export interface ProviderServiceLiveOptions {
......@@ -154,7 +157,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
154157 Effect.asVoid,
155158 );
156159
157 const upsertSessionBinding = (session: ProviderSession, operation: string) =>
160 const upsertSessionBinding = (
161 session: ProviderSession,
162 operation: string,
163 threadId: ThreadId,
164 ) =>
158165 Effect.gen(function* () {
159166 const providerThreadId = session.threadId;
160167 if (!providerThreadId) {
......@@ -165,18 +172,17 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
165172 }
166173
167174 const providerSessionId = asProviderSessionId(session.sessionId);
168 const brandedThreadId = ThreadId.makeUnsafe(providerThreadId);
169175 yield* directory.upsert({
170176 sessionId: providerSessionId,
171177 provider: session.provider,
172 threadId: brandedThreadId,
178 threadId,
173179 providerThreadId,
174180 status: toRuntimeStatus(session),
175 resumeCursor: { resumeThreadId: providerThreadId },
181 ...(session.resumeCursor !== undefined ? { resumeCursor: session.resumeCursor } : {}),
176182 runtimePayload: toRuntimePayloadFromSession(session),
177183 });
178184
179 return brandedThreadId;
185 return providerThreadId;
180186 });
181187
182188 const clearAliasKey = (staleSessionId: ProviderSessionId) =>
......@@ -235,26 +241,33 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
235241
236242 const recoverSessionForThread = (input: {
237243 readonly staleSessionId: ProviderSessionId;
238 readonly provider: ProviderSession["provider"];
239 readonly threadId: ThreadId;
244 readonly binding: ProviderSessionBinding & { readonly threadId: ThreadId };
240245 readonly operation: string;
241246 }) =>
242247 Effect.gen(function* () {
243 const adapter = yield* registry.getByProvider(input.provider);
248 const adapter = yield* registry.getByProvider(input.binding.provider);
244249 const activeSessions = yield* adapter.listSessions();
245 const expectedProviderThreadId = asProviderThreadId(input.threadId);
246 const existing = activeSessions.find(
247 (session) => session.threadId === expectedProviderThreadId,
248 );
250 const resumeThreadId = input.binding.providerThreadId ?? undefined;
251 const hasResumeCursor =
252 input.binding.resumeCursor !== null && input.binding.resumeCursor !== undefined;
253 const existing =
254 resumeThreadId === undefined
255 ? undefined
256 : activeSessions.find((session) => session.threadId === resumeThreadId);
249257 if (existing) {
250 const existingThreadId = yield* upsertSessionBinding(
258 const existingProviderThreadId = yield* upsertSessionBinding(
251259 existing,
252260 `${input.operation}:upsertExistingSession`,
261 input.binding.threadId,
253262 );
254263 yield* directory.upsert({
255264 sessionId: input.staleSessionId,
256265 provider: existing.provider,
257 threadId: existingThreadId,
266 threadId: input.binding.threadId,
267 providerThreadId: existingProviderThreadId,
268 ...(existing.resumeCursor !== undefined
269 ? { resumeCursor: existing.resumeCursor }
270 : {}),
258271 });
259272 if (existing.sessionId !== input.staleSessionId) {
260273 yield* setAlias(input.staleSessionId, existing.sessionId);
......@@ -267,9 +280,17 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
267280 } as const;
268281 }
269282
283 if (!resumeThreadId && !hasResumeCursor) {
284 return yield* toValidationError(
285 input.operation,
286 `Cannot recover stale session '${input.staleSessionId}' because no provider resume state is persisted.`,
287 );
288 }
289
270290 const resumed = yield* adapter.startSession({
271 provider: input.provider,
272 resumeThreadId: ProviderThreadId.makeUnsafe(input.threadId),
291 provider: input.binding.provider,
292 ...(resumeThreadId ? { resumeThreadId } : {}),
293 ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}),
273294 });
274295 if (resumed.provider !== adapter.provider) {
275296 return yield* toValidationError(
......@@ -278,21 +299,20 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
278299 );
279300 }
280301
281 const resumedThreadId = yield* upsertSessionBinding(
302 const resumedProviderThreadId = yield* upsertSessionBinding(
282303 resumed,
283304 `${input.operation}:upsertRecoveredSession`,
305 input.binding.threadId,
284306 );
285 if (resumedThreadId !== input.threadId) {
286 return yield* toValidationError(
287 input.operation,
288 `Recovered session thread '${resumedThreadId}' does not match expected thread '${input.threadId}'.`,
289 );
290 }
291307
292308 yield* directory.upsert({
293309 sessionId: input.staleSessionId,
294310 provider: resumed.provider,
295 threadId: resumedThreadId,
311 threadId: input.binding.threadId,
312 providerThreadId: resumedProviderThreadId,
313 ...(resumed.resumeCursor !== undefined
314 ? { resumeCursor: resumed.resumeCursor }
315 : {}),
296316 });
297317
298318 if (resumed.sessionId !== input.staleSessionId) {
......@@ -313,8 +333,26 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
313333 readonly allowRecovery: boolean;
314334 }) =>
315335 Effect.gen(function* () {
316 const provider = yield* directory.getProvider(input.sessionId);
317 const adapter = yield* registry.getByProvider(provider);
336 const bindingOption = yield* directory.getBinding(input.sessionId);
337 const binding = Option.getOrUndefined(bindingOption);
338 if (!binding) {
339 return yield* new ProviderSessionNotFoundError({
340 sessionId: input.sessionId,
341 });
342 }
343 if (!binding.threadId) {
344 return yield* toValidationError(
345 input.operation,
346 `Cannot route session '${input.sessionId}' because no orchestration thread id is persisted.`,
347 );
348 }
349 const bindingWithThreadId: ProviderSessionBinding & {
350 readonly threadId: ThreadId;
351 } = {
352 ...binding,
353 threadId: binding.threadId,
354 };
355 const adapter = yield* registry.getByProvider(binding.provider);
318356
319357 const hasRequestedSession = yield* adapter.hasSession(input.sessionId);
320358 if (hasRequestedSession) {
......@@ -349,19 +387,9 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
349387 } as const;
350388 }
351389
352 const threadIdOption = yield* directory.getThreadId(input.sessionId);
353 const threadId = Option.getOrUndefined(threadIdOption);
354 if (!threadId) {
355 return yield* toValidationError(
356 input.operation,
357 `Cannot recover stale session '${input.sessionId}' because no thread id is persisted.`,
358 );
359 }
360
361390 const recovered = yield* recoverSessionForThread({
362391 staleSessionId: input.sessionId,
363 provider,
364 threadId,
392 binding: bindingWithThreadId,
365393 operation: input.operation,
366394 });
367395
......@@ -372,7 +400,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
372400 } as const;
373401 });
374402
375 const startSession: ProviderServiceShape["startSession"] = (rawInput) =>
403 const startSession: ProviderServiceShape["startSession"] = (threadId, rawInput) =>
376404 Effect.gen(function* () {
377405 const parsed = yield* decodeInputOrValidationError({
378406 operation: "ProviderService.startSession",
......@@ -396,7 +424,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
396424 );
397425 }
398426
399 yield* upsertSessionBinding(session, "ProviderService.startSession");
427 yield* upsertSessionBinding(session, "ProviderService.startSession", threadId);
400428
401429 return session;
402430 });
......@@ -443,7 +471,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
443471 threadId,
444472 providerThreadId: asProviderThreadId(turn.threadId),
445473 status: "running",
446 resumeCursor: { resumeThreadId: turn.threadId },
474 ...(turn.resumeCursor !== undefined ? { resumeCursor: turn.resumeCursor } : {}),
447475 runtimePayload: {
448476 activeTurnId: turn.turnId,
449477 lastRuntimeEvent: "provider.sendTurn",
......@@ -550,7 +578,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
550578 ),
551579 ).pipe(Effect.asVoid);
552580 // Keep persisted session bindings so stale sessions can be resumed after
553 // process restart via resumeThreadId.
581 // process restart via providerThreadId.
554582 yield* Ref.set(routedSessionAliasesRef, new Map());
555583 });
556584
apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts+48-36
......@@ -19,10 +19,6 @@ import { ProviderSessionNotFoundError, ProviderValidationError } from "../Errors
1919import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
2020import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts";
2121
22const sessionId = (value: string) => ProviderSessionId.makeUnsafe(value);
23const threadId = (value: string) => ThreadId.makeUnsafe(value);
24const providerThreadId = (value: string) => ProviderThreadId.makeUnsafe(value);
25
2622function makeDirectoryLayer<E, R>(persistenceLayer: Layer.Layer<SqlClient.SqlClient, E, R>) {
2723 const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe(
2824 Layer.provide(persistenceLayer),
......@@ -33,49 +29,58 @@ function makeDirectoryLayer<E, R>(persistenceLayer: Layer.Layer<SqlClient.SqlCli
3329 );
3430}
3531
36const layer = it.layer(makeDirectoryLayer(SqlitePersistenceMemory));
37
38layer("ProviderSessionDirectoryLive", (it) => {
32it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryLive", (it) => {
3933 it("upserts, reads, and removes session bindings", () =>
4034 Effect.gen(function* () {
4135 const directory = yield* ProviderSessionDirectory;
4236 const runtimeRepository = yield* ProviderSessionRuntimeRepository;
4337
38 const sessionId = ProviderSessionId.makeUnsafe("sess-1");
39 const initialThreadId = ThreadId.makeUnsafe("thread-1");
40
4441 yield* directory.upsert({
45 sessionId: sessionId("sess-1"),
42 sessionId,
4643 provider: "codex",
47 threadId: threadId("thread-1"),
44 threadId: initialThreadId,
4845 });
4946
50 const provider = yield* directory.getProvider(sessionId("sess-1"));
47 const provider = yield* directory.getProvider(sessionId);
5148 assert.equal(provider, "codex");
52 const resolvedThreadId = yield* directory.getThreadId(sessionId("sess-1"));
53 assertSome(resolvedThreadId, threadId("thread-1"));
49 const resolvedThreadId = yield* directory.getThreadId(sessionId);
50 assertSome(resolvedThreadId, initialThreadId);
51
52 const nextThreadId = ThreadId.makeUnsafe("thread-2");
5453
5554 yield* directory.upsert({
56 sessionId: sessionId("sess-1"),
55 sessionId: sessionId,
5756 provider: "codex",
58 threadId: threadId("thread-2"),
57 threadId: nextThreadId,
5958 });
60 const updatedThreadId = yield* directory.getThreadId(sessionId("sess-1"));
61 assertSome(updatedThreadId, threadId("thread-2"));
59 const updatedThreadId = yield* directory.getThreadId(sessionId);
60 assertSome(updatedThreadId, nextThreadId);
61 const updatedBinding = yield* directory.getBinding(sessionId);
62 assert.equal(Option.isSome(updatedBinding), true);
63 if (Option.isSome(updatedBinding)) {
64 assert.equal(updatedBinding.value.threadId, nextThreadId);
65 assert.equal(updatedBinding.value.providerThreadId, null);
66 }
6267
6368 const runtime = yield* runtimeRepository.getBySessionId({
64 providerSessionId: sessionId("sess-1"),
69 providerSessionId: sessionId,
6570 });
6671 assert.equal(Option.isSome(runtime), true);
6772 if (Option.isSome(runtime)) {
68 assert.equal(runtime.value.threadId, "thread-2");
69 assert.equal(runtime.value.providerThreadId, "thread-2");
73 assert.equal(runtime.value.threadId, nextThreadId);
74 assert.equal(runtime.value.providerThreadId, null);
7075 assert.equal(runtime.value.status, "running");
7176 assert.equal(runtime.value.adapterKey, "codex");
7277 }
7378
7479 const sessionIds = yield* directory.listSessionIds();
75 assert.deepEqual(sessionIds, [sessionId("sess-1")]);
80 assert.deepEqual(sessionIds, [sessionId]);
7681
77 yield* directory.remove(sessionId("sess-1"));
78 const missingProvider = yield* directory.getProvider(sessionId("sess-1")).pipe(Effect.result);
82 yield* directory.remove(sessionId);
83 const missingProvider = yield* directory.getProvider(sessionId).pipe(Effect.result);
7984 assertFailure(missingProvider, new ProviderSessionNotFoundError({ sessionId: "sess-1" }));
8085 }));
8186
......@@ -84,7 +89,7 @@ layer("ProviderSessionDirectoryLive", (it) => {
8489 const directory = yield* ProviderSessionDirectory;
8590 const result = yield* Effect.result(
8691 directory.upsert({
87 sessionId: sessionId("sess-no-thread"),
92 sessionId: ProviderSessionId.makeUnsafe("sess-no-thread"),
8893 provider: "codex",
8994 }),
9095 );
......@@ -102,11 +107,15 @@ layer("ProviderSessionDirectoryLive", (it) => {
102107 const directory = yield* ProviderSessionDirectory;
103108 const runtimeRepository = yield* ProviderSessionRuntimeRepository;
104109
110 const sessionId = ProviderSessionId.makeUnsafe("sess-runtime");
111 const threadId = ThreadId.makeUnsafe("thread-runtime");
112 const providerThreadId = ProviderThreadId.makeUnsafe("provider-thread-runtime");
113
105114 yield* directory.upsert({
106 sessionId: sessionId("sess-runtime"),
115 sessionId,
107116 provider: "codex",
108 threadId: threadId("thread-runtime"),
109 providerThreadId: providerThreadId("provider-thread-runtime"),
117 threadId,
118 providerThreadId,
110119 status: "starting",
111120 resumeCursor: {
112121 resumeThreadId: "provider-thread-runtime",
......@@ -118,7 +127,7 @@ layer("ProviderSessionDirectoryLive", (it) => {
118127 });
119128
120129 yield* directory.upsert({
121 sessionId: sessionId("sess-runtime"),
130 sessionId,
122131 provider: "codex",
123132 status: "running",
124133 runtimePayload: {
......@@ -127,15 +136,15 @@ layer("ProviderSessionDirectoryLive", (it) => {
127136 });
128137
129138 const runtime = yield* runtimeRepository.getBySessionId({
130 providerSessionId: sessionId("sess-runtime"),
139 providerSessionId: sessionId,
131140 });
132141 assert.equal(Option.isSome(runtime), true);
133142 if (Option.isSome(runtime)) {
134 assert.equal(runtime.value.threadId, "thread-runtime");
135 assert.equal(runtime.value.providerThreadId, "provider-thread-runtime");
143 assert.equal(runtime.value.threadId, threadId);
144 assert.equal(runtime.value.providerThreadId, providerThreadId);
136145 assert.equal(runtime.value.status, "running");
137146 assert.deepEqual(runtime.value.resumeCursor, {
138 resumeThreadId: "provider-thread-runtime",
147 resumeThreadId: providerThreadId,
139148 });
140149 assert.deepEqual(runtime.value.runtimePayload, {
141150 cwd: "/tmp/project",
......@@ -151,23 +160,26 @@ layer("ProviderSessionDirectoryLive", (it) => {
151160 const dbPath = path.join(tempDir, "orchestration.sqlite");
152161 const directoryLayer = makeDirectoryLayer(makeSqlitePersistenceLive(dbPath));
153162
163 const sessionId = ProviderSessionId.makeUnsafe("sess-restart");
164 const threadId = ThreadId.makeUnsafe("thread-restart");
165
154166 yield* Effect.gen(function* () {
155167 const directory = yield* ProviderSessionDirectory;
156168 yield* directory.upsert({
157 sessionId: sessionId("sess-restart"),
169 sessionId,
158170 provider: "codex",
159 threadId: threadId("thread-restart"),
171 threadId,
160172 });
161173 }).pipe(Effect.provide(directoryLayer));
162174
163175 yield* Effect.gen(function* () {
164176 const directory = yield* ProviderSessionDirectory;
165177 const sql = yield* SqlClient.SqlClient;
166 const provider = yield* directory.getProvider(sessionId("sess-restart"));
178 const provider = yield* directory.getProvider(sessionId);
167179 assert.equal(provider, "codex");
168180
169 const resolvedThreadId = yield* directory.getThreadId(sessionId("sess-restart"));
170 assertSome(resolvedThreadId, threadId("thread-restart"));
181 const resolvedThreadId = yield* directory.getThreadId(sessionId);
182 assertSome(resolvedThreadId, threadId);
171183
172184 const legacyTableRows = yield* sql<{ readonly name: string }>`
173185 SELECT name
apps/server/src/provider/Layers/ProviderSessionDirectory.ts+9
......@@ -73,6 +73,11 @@ const makeProviderSessionDirectory = Effect.gen(function* () {
7373 sessionId: value.providerSessionId,
7474 provider,
7575 threadId: value.threadId,
76 adapterKey: value.adapterKey,
77 providerThreadId: value.providerThreadId,
78 status: value.status,
79 resumeCursor: value.resumeCursor,
80 runtimePayload: value.runtimePayload,
7681 }),
7782 ),
7883 ),
......@@ -128,6 +133,9 @@ const makeProviderSessionDirectory = Effect.gen(function* () {
128133 ),
129134 );
130135
136 const getBindingBySessionId: ProviderSessionDirectoryShape["getBinding"] = (sessionId) =>
137 getBinding(sessionId);
138
131139 const getThreadId: ProviderSessionDirectoryShape["getThreadId"] = (sessionId) =>
132140 getBinding(sessionId).pipe(
133141 Effect.flatMap((binding) =>
......@@ -168,6 +176,7 @@ const makeProviderSessionDirectory = Effect.gen(function* () {
168176 return {
169177 upsert,
170178 getProvider,
179 getBinding: getBindingBySessionId,
171180 getThreadId,
172181 remove,
173182 listSessionIds,
apps/server/src/provider/Services/ProviderService.ts+2
......@@ -20,6 +20,7 @@ import type {
2020 ProviderSessionId,
2121 ProviderSessionStartInput,
2222 ProviderStopSessionInput,
23 ThreadId,
2324 ProviderTurnStartResult,
2425} from "@t3tools/contracts";
2526import { ServiceMap } from "effect";
......@@ -32,6 +33,7 @@ export interface ProviderServiceShape {
3233 * Start a provider session.
3334 */
3435 readonly startSession: (
36 threadId: ThreadId,
3537 input: ProviderSessionStartInput,
3638 ) => Effect.Effect<ProviderSession, ProviderServiceError>;
3739
apps/server/src/provider/Services/ProviderSessionDirectory.ts+7
......@@ -57,6 +57,13 @@ export interface ProviderSessionDirectoryShape {
5757 sessionId: ProviderSessionId,
5858 ) => Effect.Effect<ProviderKind, ProviderSessionDirectoryReadError>;
5959
60 /**
61 * Resolve the full tracked binding for a session id.
62 */
63 readonly getBinding: (
64 sessionId: ProviderSessionId,
65 ) => Effect.Effect<Option.Option<ProviderSessionBinding>, ProviderSessionDirectoryReadError>;
66
6067 /**
6168 * Resolve the tracked thread id for a session, if known.
6269 */
packages/contracts/src/provider.ts+3
......@@ -41,6 +41,7 @@ export const ProviderSession = Schema.Struct({
4141 cwd: Schema.optional(TrimmedNonEmptyStringSchema),
4242 model: Schema.optional(TrimmedNonEmptyStringSchema),
4343 threadId: Schema.optional(ProviderThreadId),
44 resumeCursor: Schema.optional(Schema.Unknown),
4445 activeTurnId: Schema.optional(ProviderTurnId),
4546 createdAt: IsoDateTime,
4647 updatedAt: IsoDateTime,
......@@ -53,6 +54,7 @@ export const ProviderSessionStartInput = Schema.Struct({
5354 cwd: Schema.optional(TrimmedNonEmptyStringSchema),
5455 model: Schema.optional(TrimmedNonEmptyStringSchema),
5556 resumeThreadId: Schema.optional(ProviderThreadId),
57 resumeCursor: Schema.optional(Schema.Unknown),
5658 codexBinaryPath: Schema.optional(TrimmedNonEmptyStringSchema),
5759 codexHomePath: Schema.optional(TrimmedNonEmptyStringSchema),
5860 approvalPolicy: Schema.optional(ProviderApprovalPolicy),
......@@ -76,6 +78,7 @@ export type ProviderSendTurnInput = typeof ProviderSendTurnInput.Type;
7678export const ProviderTurnStartResult = Schema.Struct({
7779 threadId: ProviderThreadId,
7880 turnId: ProviderTurnId,
81 resumeCursor: Schema.optional(Schema.Unknown),
7982});
8083export type ProviderTurnStartResult = typeof ProviderTurnStartResult.Type;
8184