| author | |
| committer | |
| log | e0117b271a79f66d0ac793da334991e7a2ccb800 |
| tree | f3c00351452bb1fe1bbf950436543b97dfefac3f |
| parent | 188a40c315bceaeec6237f05ae253941ad6bb4dc |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius0216@outlook.com>
Co-authored-by: codex <codex@users.noreply.github.com>19 files changed, 1686 insertions(+), 49 deletions(-)
apps/server/package.json+2-1| ... | @@ -20,7 +20,8 @@ | ... | @@ -20,7 +20,8 @@ |
| 20 | "start": "node dist/bin.mjs", | 20 | "start": "node dist/bin.mjs", |
| 21 | "prepare": "effect-language-service patch", | 21 | "prepare": "effect-language-service patch", |
| 22 | "typecheck": "tsc --noEmit", | 22 | "typecheck": "tsc --noEmit", |
| 23 | "test": "vitest run" | 23 | "test": "vitest run", |
| 24 | "test:process-reaper": "vitest run src/server.test.ts src/provider/Layers/ClaudeAdapter.test.ts src/provider/Layers/ProviderSessionDirectory.test.ts src/provider/Layers/ProviderSessionReaper.test.ts src/provider/Layers/CodexAdapter.test.ts" | ||
| 24 | }, | 25 | }, |
| 25 | "dependencies": { | 26 | "dependencies": { |
| 26 | "@anthropic-ai/claude-agent-sdk": "^0.2.111", | 27 | "@anthropic-ai/claude-agent-sdk": "^0.2.111", |
apps/server/src/codexAppServerManager.test.ts+148| ... | @@ -470,6 +470,154 @@ describe("startSession", () => { | ... | @@ -470,6 +470,154 @@ describe("startSession", () => { |
| 470 | manager.stopAll(); | 470 | manager.stopAll(); |
| 471 | } | 471 | } |
| 472 | }); | 472 | }); |
| 473 | |||
| 474 | it("disposes an existing session before starting a replacement for the same thread", async () => { | ||
| 475 | const manager = new CodexAppServerManager(); | ||
| 476 | const existingContext = { | ||
| 477 | session: { | ||
| 478 | provider: "codex", | ||
| 479 | status: "ready", | ||
| 480 | threadId: asThreadId("thread-1"), | ||
| 481 | runtimeMode: "full-access", | ||
| 482 | createdAt: "2026-02-10T00:00:00.000Z", | ||
| 483 | updatedAt: "2026-02-10T00:00:00.000Z", | ||
| 484 | }, | ||
| 485 | }; | ||
| 486 | |||
| 487 | ( | ||
| 488 | manager as unknown as { | ||
| 489 | sessions: Map<ThreadId, typeof existingContext>; | ||
| 490 | } | ||
| 491 | ).sessions.set(asThreadId("thread-1"), existingContext); | ||
| 492 | |||
| 493 | const disposeSession = vi | ||
| 494 | .spyOn( | ||
| 495 | manager as unknown as { | ||
| 496 | disposeSession: ( | ||
| 497 | context: typeof existingContext, | ||
| 498 | options?: { readonly emitLifecycleEvent?: boolean }, | ||
| 499 | ) => void; | ||
| 500 | }, | ||
| 501 | "disposeSession", | ||
| 502 | ) | ||
| 503 | .mockImplementation(() => {}); | ||
| 504 | const assertSupportedCodexCliVersion = vi | ||
| 505 | .spyOn( | ||
| 506 | manager as unknown as { | ||
| 507 | assertSupportedCodexCliVersion: (input: { | ||
| 508 | binaryPath: string; | ||
| 509 | cwd: string; | ||
| 510 | homePath?: string; | ||
| 511 | }) => void; | ||
| 512 | }, | ||
| 513 | "assertSupportedCodexCliVersion", | ||
| 514 | ) | ||
| 515 | .mockImplementation(() => {}); | ||
| 516 | const processCwd = vi.spyOn(process, "cwd").mockImplementation(() => { | ||
| 517 | throw new Error("cwd missing"); | ||
| 518 | }); | ||
| 519 | |||
| 520 | try { | ||
| 521 | await expect( | ||
| 522 | manager.startSession({ | ||
| 523 | threadId: asThreadId("thread-1"), | ||
| 524 | provider: "codex", | ||
| 525 | binaryPath: "codex", | ||
| 526 | runtimeMode: "full-access", | ||
| 527 | }), | ||
| 528 | ).rejects.toThrow("cwd missing"); | ||
| 529 | |||
| 530 | expect(disposeSession).toHaveBeenCalledWith(existingContext, { | ||
| 531 | emitLifecycleEvent: false, | ||
| 532 | }); | ||
| 533 | expect(assertSupportedCodexCliVersion).not.toHaveBeenCalled(); | ||
| 534 | } finally { | ||
| 535 | disposeSession.mockRestore(); | ||
| 536 | assertSupportedCodexCliVersion.mockRestore(); | ||
| 537 | processCwd.mockRestore(); | ||
| 538 | ( | ||
| 539 | manager as unknown as { | ||
| 540 | sessions: Map<ThreadId, typeof existingContext>; | ||
| 541 | } | ||
| 542 | ).sessions.clear(); | ||
| 543 | manager.stopAll(); | ||
| 544 | } | ||
| 545 | }); | ||
| 546 | |||
| 547 | it("continues replacement start when existing session disposal fails", async () => { | ||
| 548 | const manager = new CodexAppServerManager(); | ||
| 549 | const existingContext = { | ||
| 550 | session: { | ||
| 551 | provider: "codex", | ||
| 552 | status: "ready", | ||
| 553 | threadId: asThreadId("thread-1"), | ||
| 554 | runtimeMode: "full-access", | ||
| 555 | createdAt: "2026-02-10T00:00:00.000Z", | ||
| 556 | updatedAt: "2026-02-10T00:00:00.000Z", | ||
| 557 | }, | ||
| 558 | }; | ||
| 559 | |||
| 560 | ( | ||
| 561 | manager as unknown as { | ||
| 562 | sessions: Map<ThreadId, typeof existingContext>; | ||
| 563 | } | ||
| 564 | ).sessions.set(asThreadId("thread-1"), existingContext); | ||
| 565 | |||
| 566 | const disposeSession = vi | ||
| 567 | .spyOn( | ||
| 568 | manager as unknown as { | ||
| 569 | disposeSession: ( | ||
| 570 | context: typeof existingContext, | ||
| 571 | options?: { readonly emitLifecycleEvent?: boolean }, | ||
| 572 | ) => void; | ||
| 573 | }, | ||
| 574 | "disposeSession", | ||
| 575 | ) | ||
| 576 | .mockImplementation(() => { | ||
| 577 | throw new Error("dispose failed"); | ||
| 578 | }); | ||
| 579 | const assertSupportedCodexCliVersion = vi | ||
| 580 | .spyOn( | ||
| 581 | manager as unknown as { | ||
| 582 | assertSupportedCodexCliVersion: (input: { | ||
| 583 | binaryPath: string; | ||
| 584 | cwd: string; | ||
| 585 | homePath?: string; | ||
| 586 | }) => void; | ||
| 587 | }, | ||
| 588 | "assertSupportedCodexCliVersion", | ||
| 589 | ) | ||
| 590 | .mockImplementation(() => {}); | ||
| 591 | const processCwd = vi.spyOn(process, "cwd").mockImplementation(() => { | ||
| 592 | throw new Error("cwd missing"); | ||
| 593 | }); | ||
| 594 | |||
| 595 | try { | ||
| 596 | await expect( | ||
| 597 | manager.startSession({ | ||
| 598 | threadId: asThreadId("thread-1"), | ||
| 599 | provider: "codex", | ||
| 600 | binaryPath: "codex", | ||
| 601 | runtimeMode: "full-access", | ||
| 602 | }), | ||
| 603 | ).rejects.toThrow("cwd missing"); | ||
| 604 | |||
| 605 | expect(disposeSession).toHaveBeenCalledWith(existingContext, { | ||
| 606 | emitLifecycleEvent: false, | ||
| 607 | }); | ||
| 608 | expect(assertSupportedCodexCliVersion).not.toHaveBeenCalled(); | ||
| 609 | } finally { | ||
| 610 | disposeSession.mockRestore(); | ||
| 611 | assertSupportedCodexCliVersion.mockRestore(); | ||
| 612 | processCwd.mockRestore(); | ||
| 613 | ( | ||
| 614 | manager as unknown as { | ||
| 615 | sessions: Map<ThreadId, typeof existingContext>; | ||
| 616 | } | ||
| 617 | ).sessions.clear(); | ||
| 618 | manager.stopAll(); | ||
| 619 | } | ||
| 620 | }); | ||
| 473 | }); | 621 | }); |
| 474 | 622 | ||
| 475 | describe("sendTurn", () => { | 623 | describe("sendTurn", () => { |
apps/server/src/codexAppServerManager.ts+36-2| ... | @@ -450,6 +450,25 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -450,6 +450,25 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 450 | let context: CodexSessionContext | undefined; | 450 | let context: CodexSessionContext | undefined; |
| 451 | 451 | ||
| 452 | try { | 452 | try { |
| 453 | const existingContext = this.sessions.get(threadId); | ||
| 454 | if (existingContext) { | ||
| 455 | await Effect.logWarning("codex app-server replacing existing session", { | ||
| 456 | threadId, | ||
| 457 | existingStatus: existingContext.session.status, | ||
| 458 | }).pipe(this.runPromise); | ||
| 459 | try { | ||
| 460 | this.disposeSession(existingContext, { | ||
| 461 | emitLifecycleEvent: false, | ||
| 462 | }); | ||
| 463 | } catch (error) { | ||
| 464 | await Effect.logWarning("codex app-server failed to dispose existing session", { | ||
| 465 | threadId, | ||
| 466 | existingStatus: existingContext.session.status, | ||
| 467 | cause: error instanceof Error ? error.message : String(error), | ||
| 468 | }).pipe(this.runPromise); | ||
| 469 | } | ||
| 470 | } | ||
| 471 | |||
| 453 | const resolvedCwd = input.cwd ?? process.cwd(); | 472 | const resolvedCwd = input.cwd ?? process.cwd(); |
| 454 | 473 | ||
| 455 | const session: ProviderSession = { | 474 | const session: ProviderSession = { |
| ... | @@ -901,6 +920,19 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -901,6 +920,19 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 901 | return; | 920 | return; |
| 902 | } | 921 | } |
| 903 | 922 | ||
| 923 | this.disposeSession(context, { | ||
| 924 | emitLifecycleEvent: true, | ||
| 925 | }); | ||
| 926 | } | ||
| 927 | |||
| 928 | private disposeSession( | ||
| 929 | context: CodexSessionContext, | ||
| 930 | options?: { readonly emitLifecycleEvent?: boolean }, | ||
| 931 | ): void { | ||
| 932 | if (context.stopping) { | ||
| 933 | return; | ||
| 934 | } | ||
| 935 | |||
| 904 | context.stopping = true; | 936 | context.stopping = true; |
| 905 | 937 | ||
| 906 | for (const pending of context.pending.values()) { | 938 | for (const pending of context.pending.values()) { |
| ... | @@ -921,8 +953,10 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -921,8 +953,10 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 921 | status: "closed", | 953 | status: "closed", |
| 922 | activeTurnId: undefined, | 954 | activeTurnId: undefined, |
| 923 | }); | 955 | }); |
| 924 | this.emitLifecycleEvent(context, "session/closed", "Session stopped"); | 956 | if (options?.emitLifecycleEvent !== false) { |
| 925 | this.sessions.delete(threadId); | 957 | this.emitLifecycleEvent(context, "session/closed", "Session stopped"); |
| 958 | } | ||
| 959 | this.sessions.delete(context.session.threadId); | ||
| 926 | } | 960 | } |
| 927 | 961 | ||
| 928 | listSessions(): ProviderSession[] { | 962 | listSessions(): ProviderSession[] { |
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+65| ... | @@ -1307,6 +1307,71 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -1307,6 +1307,71 @@ describe("ClaudeAdapterLive", () => { |
| 1307 | ); | 1307 | ); |
| 1308 | }); | 1308 | }); |
| 1309 | 1309 | ||
| 1310 | it.effect("closes the previous session before replacing an existing thread session", () => { | ||
| 1311 | const queries: FakeClaudeQuery[] = []; | ||
| 1312 | const layer = makeClaudeAdapterLive({ | ||
| 1313 | createQuery: () => { | ||
| 1314 | const query = new FakeClaudeQuery(); | ||
| 1315 | queries.push(query); | ||
| 1316 | return query; | ||
| 1317 | }, | ||
| 1318 | }).pipe( | ||
| 1319 | Layer.provideMerge(ServerConfig.layerTest("/tmp/claude-adapter-test", "/tmp")), | ||
| 1320 | Layer.provideMerge(ServerSettingsService.layerTest()), | ||
| 1321 | Layer.provideMerge(NodeServices.layer), | ||
| 1322 | ); | ||
| 1323 | |||
| 1324 | return Effect.gen(function* () { | ||
| 1325 | const adapter = yield* ClaudeAdapter; | ||
| 1326 | |||
| 1327 | const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 6).pipe( | ||
| 1328 | Stream.runCollect, | ||
| 1329 | Effect.forkChild, | ||
| 1330 | ); | ||
| 1331 | |||
| 1332 | const firstSession = yield* adapter.startSession({ | ||
| 1333 | threadId: THREAD_ID, | ||
| 1334 | provider: "claudeAgent", | ||
| 1335 | runtimeMode: "full-access", | ||
| 1336 | }); | ||
| 1337 | |||
| 1338 | const secondSession = yield* adapter.startSession({ | ||
| 1339 | threadId: THREAD_ID, | ||
| 1340 | provider: "claudeAgent", | ||
| 1341 | runtimeMode: "full-access", | ||
| 1342 | resumeCursor: firstSession.resumeCursor, | ||
| 1343 | }); | ||
| 1344 | |||
| 1345 | const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); | ||
| 1346 | const activeSessions = yield* adapter.listSessions(); | ||
| 1347 | |||
| 1348 | assert.equal(queries.length, 2); | ||
| 1349 | assert.equal(queries[0]?.closeCalls, 1); | ||
| 1350 | assert.equal(queries[1]?.closeCalls, 0); | ||
| 1351 | assert.equal(yield* adapter.hasSession(THREAD_ID), true); | ||
| 1352 | assert.equal(activeSessions.length, 1); | ||
| 1353 | assert.deepEqual(activeSessions[0]?.resumeCursor, secondSession.resumeCursor); | ||
| 1354 | assert.deepEqual( | ||
| 1355 | runtimeEvents.map((event) => event.type), | ||
| 1356 | [ | ||
| 1357 | "session.started", | ||
| 1358 | "session.configured", | ||
| 1359 | "session.state.changed", | ||
| 1360 | "session.started", | ||
| 1361 | "session.configured", | ||
| 1362 | "session.state.changed", | ||
| 1363 | ], | ||
| 1364 | ); | ||
| 1365 | assert.equal( | ||
| 1366 | runtimeEvents.some((event) => event.type === "session.exited"), | ||
| 1367 | false, | ||
| 1368 | ); | ||
| 1369 | }).pipe( | ||
| 1370 | Effect.provideService(Random.Random, makeDeterministicRandomService()), | ||
| 1371 | Effect.provide(layer), | ||
| 1372 | ); | ||
| 1373 | }); | ||
| 1374 | |||
| 1310 | it.effect("stopSession does not throw into the SDK prompt consumer", () => { | 1375 | it.effect("stopSession does not throw into the SDK prompt consumer", () => { |
| 1311 | // The SDK consumes user messages via `for await (... of prompt)`. | 1376 | // The SDK consumes user messages via `for await (... of prompt)`. |
| 1312 | // Stopping a session must end that loop cleanly — not throw an error. | 1377 | // Stopping a session must end that loop cleanly — not throw an error. |
apps/server/src/provider/Layers/ClaudeAdapter.ts+96-20| ... | @@ -470,7 +470,10 @@ function isTodoTool(toolName: string): boolean { | ... | @@ -470,7 +470,10 @@ function isTodoTool(toolName: string): boolean { |
| 470 | return toolName.toLowerCase().includes("todowrite"); | 470 | return toolName.toLowerCase().includes("todowrite"); |
| 471 | } | 471 | } |
| 472 | 472 | ||
| 473 | type PlanStep = { step: string; status: "pending" | "inProgress" | "completed" }; | 473 | type PlanStep = { |
| 474 | step: string; | ||
| 475 | status: "pending" | "inProgress" | "completed"; | ||
| 476 | }; | ||
| 474 | 477 | ||
| 475 | function extractPlanStepsFromTodoInput(input: Record<string, unknown>): PlanStep[] | null { | 478 | function extractPlanStepsFromTodoInput(input: Record<string, unknown>): PlanStep[] | null { |
| 476 | // TodoWrite format: { todos: [{ content, status, activeForm? }] } | 479 | // TodoWrite format: { todos: [{ content, status, activeForm? }] } |
| ... | @@ -973,7 +976,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -973,7 +976,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 973 | ((input: { | 976 | ((input: { |
| 974 | readonly prompt: AsyncIterable<SDKUserMessage>; | 977 | readonly prompt: AsyncIterable<SDKUserMessage>; |
| 975 | readonly options: ClaudeQueryOptions; | 978 | readonly options: ClaudeQueryOptions; |
| 976 | }) => query({ prompt: input.prompt, options: input.options }) as ClaudeQueryRuntime); | 979 | }) => |
| 980 | query({ | ||
| 981 | prompt: input.prompt, | ||
| 982 | options: input.options, | ||
| 983 | }) as ClaudeQueryRuntime); | ||
| 977 | 984 | ||
| 978 | const sessions = new Map<ThreadId, ClaudeSessionContext>(); | 985 | const sessions = new Map<ThreadId, ClaudeSessionContext>(); |
| 979 | const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>(); | 986 | const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>(); |
| ... | @@ -1012,7 +1019,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -1012,7 +1019,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 1012 | ...(typeof message.session_id === "string" | 1019 | ...(typeof message.session_id === "string" |
| 1013 | ? { providerThreadId: message.session_id } | 1020 | ? { providerThreadId: message.session_id } |
| 1014 | : {}), | 1021 | : {}), |
| 1015 | ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), | 1022 | ...(context.turnState |
| 1023 | ? { | ||
| 1024 | turnId: asCanonicalTurnId(context.turnState.turnId), | ||
| 1025 | } | ||
| 1026 | : {}), | ||
| 1016 | ...(itemId ? { itemId: ProviderItemId.make(itemId) } : {}), | 1027 | ...(itemId ? { itemId: ProviderItemId.make(itemId) } : {}), |
| 1017 | payload: message, | 1028 | payload: message, |
| 1018 | }, | 1029 | }, |
| ... | @@ -1401,7 +1412,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -1401,7 +1412,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 1401 | ...(typeof accumulatedTotalProcessedTokens === "number" && | 1412 | ...(typeof accumulatedTotalProcessedTokens === "number" && |
| 1402 | Number.isFinite(accumulatedTotalProcessedTokens) && | 1413 | Number.isFinite(accumulatedTotalProcessedTokens) && |
| 1403 | accumulatedTotalProcessedTokens > lastGoodUsage.usedTokens | 1414 | accumulatedTotalProcessedTokens > lastGoodUsage.usedTokens |
| 1404 | ? { totalProcessedTokens: accumulatedTotalProcessedTokens } | 1415 | ? { |
| 1416 | totalProcessedTokens: accumulatedTotalProcessedTokens, | ||
| 1417 | } | ||
| 1405 | : {}), | 1418 | : {}), |
| 1406 | } | 1419 | } |
| 1407 | : accumulatedSnapshot; | 1420 | : accumulatedSnapshot; |
| ... | @@ -1465,7 +1478,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -1465,7 +1478,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 1465 | input: tool.input, | 1478 | input: tool.input, |
| 1466 | }, | 1479 | }, |
| 1467 | }, | 1480 | }, |
| 1468 | providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), | 1481 | providerRefs: nativeProviderRefs(context, { |
| 1482 | providerItemId: tool.itemId, | ||
| 1483 | }), | ||
| 1469 | raw: { | 1484 | raw: { |
| 1470 | source: "claude.sdk.message", | 1485 | source: "claude.sdk.message", |
| 1471 | method: "claude/result", | 1486 | method: "claude/result", |
| ... | @@ -1587,7 +1602,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -1587,7 +1602,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 1587 | threadId: context.session.threadId, | 1602 | threadId: context.session.threadId, |
| 1588 | turnId: context.turnState.turnId, | 1603 | turnId: context.turnState.turnId, |
| 1589 | ...(assistantBlockEntry?.block | 1604 | ...(assistantBlockEntry?.block |
| 1590 | ? { itemId: asRuntimeItemId(assistantBlockEntry.block.itemId) } | 1605 | ? { |
| 1606 | itemId: asRuntimeItemId(assistantBlockEntry.block.itemId), | ||
| 1607 | } | ||
| 1591 | : {}), | 1608 | : {}), |
| 1592 | payload: { | 1609 | payload: { |
| 1593 | streamKind, | 1610 | streamKind, |
| ... | @@ -1646,7 +1663,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -1646,7 +1663,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 1646 | provider: PROVIDER, | 1663 | provider: PROVIDER, |
| 1647 | createdAt: stamp.createdAt, | 1664 | createdAt: stamp.createdAt, |
| 1648 | threadId: context.session.threadId, | 1665 | threadId: context.session.threadId, |
| 1649 | ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), | 1666 | ...(context.turnState |
| 1667 | ? { | ||
| 1668 | turnId: asCanonicalTurnId(context.turnState.turnId), | ||
| 1669 | } | ||
| 1670 | : {}), | ||
| 1650 | itemId: asRuntimeItemId(nextTool.itemId), | 1671 | itemId: asRuntimeItemId(nextTool.itemId), |
| 1651 | payload: { | 1672 | payload: { |
| 1652 | itemType: nextTool.itemType, | 1673 | itemType: nextTool.itemType, |
| ... | @@ -1658,7 +1679,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -1658,7 +1679,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 1658 | input: nextTool.input, | 1679 | input: nextTool.input, |
| 1659 | }, | 1680 | }, |
| 1660 | }, | 1681 | }, |
| 1661 | providerRefs: nativeProviderRefs(context, { providerItemId: nextTool.itemId }), | 1682 | providerRefs: nativeProviderRefs(context, { |
| 1683 | providerItemId: nextTool.itemId, | ||
| 1684 | }), | ||
| 1662 | raw: { | 1685 | raw: { |
| 1663 | source: "claude.sdk.message", | 1686 | source: "claude.sdk.message", |
| 1664 | method: "claude/stream_event/content_block_delta/input_json_delta", | 1687 | method: "claude/stream_event/content_block_delta/input_json_delta", |
| ... | @@ -1677,7 +1700,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -1677,7 +1700,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 1677 | provider: PROVIDER, | 1700 | provider: PROVIDER, |
| 1678 | createdAt: planStamp.createdAt, | 1701 | createdAt: planStamp.createdAt, |
| 1679 | threadId: context.session.threadId, | 1702 | threadId: context.session.threadId, |
| 1680 | ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), | 1703 | ...(context.turnState |
| 1704 | ? { | ||
| 1705 | turnId: asCanonicalTurnId(context.turnState.turnId), | ||
| 1706 | } | ||
| 1707 | : {}), | ||
| 1681 | payload: { | 1708 | payload: { |
| 1682 | plan: planSteps, | 1709 | plan: planSteps, |
| 1683 | }, | 1710 | }, |
| ... | @@ -1747,7 +1774,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -1747,7 +1774,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 1747 | input: toolInput, | 1774 | input: toolInput, |
| 1748 | }, | 1775 | }, |
| 1749 | }, | 1776 | }, |
| 1750 | providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), | 1777 | providerRefs: nativeProviderRefs(context, { |
| 1778 | providerItemId: tool.itemId, | ||
| 1779 | }), | ||
| 1751 | raw: { | 1780 | raw: { |
| 1752 | source: "claude.sdk.message", | 1781 | source: "claude.sdk.message", |
| 1753 | method: "claude/stream_event/content_block_start", | 1782 | method: "claude/stream_event/content_block_start", |
| ... | @@ -1819,7 +1848,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -1819,7 +1848,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 1819 | ...(tool.detail ? { detail: tool.detail } : {}), | 1848 | ...(tool.detail ? { detail: tool.detail } : {}), |
| 1820 | data: toolData, | 1849 | data: toolData, |
| 1821 | }, | 1850 | }, |
| 1822 | providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), | 1851 | providerRefs: nativeProviderRefs(context, { |
| 1852 | providerItemId: tool.itemId, | ||
| 1853 | }), | ||
| 1823 | raw: { | 1854 | raw: { |
| 1824 | source: "claude.sdk.message", | 1855 | source: "claude.sdk.message", |
| 1825 | method: "claude/user", | 1856 | method: "claude/user", |
| ... | @@ -1842,7 +1873,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -1842,7 +1873,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 1842 | streamKind, | 1873 | streamKind, |
| 1843 | delta: toolResult.text, | 1874 | delta: toolResult.text, |
| 1844 | }, | 1875 | }, |
| 1845 | providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), | 1876 | providerRefs: nativeProviderRefs(context, { |
| 1877 | providerItemId: tool.itemId, | ||
| 1878 | }), | ||
| 1846 | raw: { | 1879 | raw: { |
| 1847 | source: "claude.sdk.message", | 1880 | source: "claude.sdk.message", |
| 1848 | method: "claude/user", | 1881 | method: "claude/user", |
| ... | @@ -1867,7 +1900,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -1867,7 +1900,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 1867 | ...(tool.detail ? { detail: tool.detail } : {}), | 1900 | ...(tool.detail ? { detail: tool.detail } : {}), |
| 1868 | data: toolData, | 1901 | data: toolData, |
| 1869 | }, | 1902 | }, |
| 1870 | providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), | 1903 | providerRefs: nativeProviderRefs(context, { |
| 1904 | providerItemId: tool.itemId, | ||
| 1905 | }), | ||
| 1871 | raw: { | 1906 | raw: { |
| 1872 | source: "claude.sdk.message", | 1907 | source: "claude.sdk.message", |
| 1873 | method: "claude/user", | 1908 | method: "claude/user", |
| ... | @@ -2223,7 +2258,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -2223,7 +2258,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 2223 | payload: { | 2258 | payload: { |
| 2224 | summary: message.summary, | 2259 | summary: message.summary, |
| 2225 | ...(message.preceding_tool_use_ids.length > 0 | 2260 | ...(message.preceding_tool_use_ids.length > 0 |
| 2226 | ? { precedingToolUseIds: message.preceding_tool_use_ids } | 2261 | ? { |
| 2262 | precedingToolUseIds: message.preceding_tool_use_ids, | ||
| 2263 | } | ||
| 2227 | : {}), | 2264 | : {}), |
| 2228 | }, | 2265 | }, |
| 2229 | }); | 2266 | }); |
| ... | @@ -2440,6 +2477,27 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -2440,6 +2477,27 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 2440 | }); | 2477 | }); |
| 2441 | } | 2478 | } |
| 2442 | 2479 | ||
| 2480 | const existingContext = sessions.get(input.threadId); | ||
| 2481 | if (existingContext) { | ||
| 2482 | yield* Effect.logWarning("claude.session.replacing", { | ||
| 2483 | threadId: input.threadId, | ||
| 2484 | existingSessionStatus: existingContext.session.status, | ||
| 2485 | reason: "startSession called with existing active session", | ||
| 2486 | }); | ||
| 2487 | yield* stopSessionInternal(existingContext, { | ||
| 2488 | emitExitEvent: false, | ||
| 2489 | }).pipe( | ||
| 2490 | // Replacement cleanup is best-effort: never block the new session on | ||
| 2491 | // either typed failures or unexpected defects from tearing down the old one. | ||
| 2492 | Effect.catchCause((cause) => | ||
| 2493 | Effect.logWarning("claude.session.replace.stop-failed", { | ||
| 2494 | threadId: input.threadId, | ||
| 2495 | cause, | ||
| 2496 | }), | ||
| 2497 | ), | ||
| 2498 | ); | ||
| 2499 | } | ||
| 2500 | |||
| 2443 | const startedAt = yield* nowIso; | 2501 | const startedAt = yield* nowIso; |
| 2444 | const resumeState = readClaudeResumeState(input.resumeCursor); | 2502 | const resumeState = readClaudeResumeState(input.resumeCursor); |
| 2445 | const threadId = input.threadId; | 2503 | const threadId = input.threadId; |
| ... | @@ -2475,7 +2533,10 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -2475,7 +2533,10 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 2475 | const handleAskUserQuestion = Effect.fn("handleAskUserQuestion")(function* ( | 2533 | const handleAskUserQuestion = Effect.fn("handleAskUserQuestion")(function* ( |
| 2476 | context: ClaudeSessionContext, | 2534 | context: ClaudeSessionContext, |
| 2477 | toolInput: Record<string, unknown>, | 2535 | toolInput: Record<string, unknown>, |
| 2478 | callbackOptions: { readonly signal: AbortSignal; readonly toolUseID?: string }, | 2536 | callbackOptions: { |
| 2537 | readonly signal: AbortSignal; | ||
| 2538 | readonly toolUseID?: string; | ||
| 2539 | }, | ||
| 2479 | ) { | 2540 | ) { |
| 2480 | const requestId = ApprovalRequestId.make(yield* Random.nextUUIDv4); | 2541 | const requestId = ApprovalRequestId.make(yield* Random.nextUUIDv4); |
| 2481 | 2542 | ||
| ... | @@ -2511,7 +2572,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -2511,7 +2572,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 2511 | provider: PROVIDER, | 2572 | provider: PROVIDER, |
| 2512 | createdAt: requestedStamp.createdAt, | 2573 | createdAt: requestedStamp.createdAt, |
| 2513 | threadId: context.session.threadId, | 2574 | threadId: context.session.threadId, |
| 2514 | ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), | 2575 | ...(context.turnState |
| 2576 | ? { | ||
| 2577 | turnId: asCanonicalTurnId(context.turnState.turnId), | ||
| 2578 | } | ||
| 2579 | : {}), | ||
| 2515 | requestId: asRuntimeRequestId(requestId), | 2580 | requestId: asRuntimeRequestId(requestId), |
| 2516 | payload: { questions }, | 2581 | payload: { questions }, |
| 2517 | providerRefs: nativeProviderRefs(context, { | 2582 | providerRefs: nativeProviderRefs(context, { |
| ... | @@ -2520,7 +2585,10 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -2520,7 +2585,10 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 2520 | raw: { | 2585 | raw: { |
| 2521 | source: "claude.sdk.permission", | 2586 | source: "claude.sdk.permission", |
| 2522 | method: "canUseTool/AskUserQuestion", | 2587 | method: "canUseTool/AskUserQuestion", |
| 2523 | payload: { toolName: "AskUserQuestion", input: toolInput }, | 2588 | payload: { |
| 2589 | toolName: "AskUserQuestion", | ||
| 2590 | input: toolInput, | ||
| 2591 | }, | ||
| 2524 | }, | 2592 | }, |
| 2525 | }); | 2593 | }); |
| 2526 | 2594 | ||
| ... | @@ -2535,7 +2603,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -2535,7 +2603,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 2535 | pendingUserInputs.delete(requestId); | 2603 | pendingUserInputs.delete(requestId); |
| 2536 | runFork(Deferred.succeed(answersDeferred, {} as ProviderUserInputAnswers)); | 2604 | runFork(Deferred.succeed(answersDeferred, {} as ProviderUserInputAnswers)); |
| 2537 | }; | 2605 | }; |
| 2538 | callbackOptions.signal.addEventListener("abort", onAbort, { once: true }); | 2606 | callbackOptions.signal.addEventListener("abort", onAbort, { |
| 2607 | once: true, | ||
| 2608 | }); | ||
| 2539 | 2609 | ||
| 2540 | // Block until the user provides answers. | 2610 | // Block until the user provides answers. |
| 2541 | const answers = yield* Deferred.await(answersDeferred); | 2611 | const answers = yield* Deferred.await(answersDeferred); |
| ... | @@ -2549,7 +2619,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -2549,7 +2619,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 2549 | provider: PROVIDER, | 2619 | provider: PROVIDER, |
| 2550 | createdAt: resolvedStamp.createdAt, | 2620 | createdAt: resolvedStamp.createdAt, |
| 2551 | threadId: context.session.threadId, | 2621 | threadId: context.session.threadId, |
| 2552 | ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), | 2622 | ...(context.turnState |
| 2623 | ? { | ||
| 2624 | turnId: asCanonicalTurnId(context.turnState.turnId), | ||
| 2625 | } | ||
| 2626 | : {}), | ||
| 2553 | requestId: asRuntimeRequestId(requestId), | 2627 | requestId: asRuntimeRequestId(requestId), |
| 2554 | payload: { answers }, | 2628 | payload: { answers }, |
| 2555 | providerRefs: nativeProviderRefs(context, { | 2629 | providerRefs: nativeProviderRefs(context, { |
| ... | @@ -2719,7 +2793,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -2719,7 +2793,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 2719 | behavior: "allow", | 2793 | behavior: "allow", |
| 2720 | updatedInput: toolInput, | 2794 | updatedInput: toolInput, |
| 2721 | ...(decision === "acceptForSession" && pendingApproval.suggestions | 2795 | ...(decision === "acceptForSession" && pendingApproval.suggestions |
| 2722 | ? { updatedPermissions: [...pendingApproval.suggestions] } | 2796 | ? { |
| 2797 | updatedPermissions: [...pendingApproval.suggestions], | ||
| 2798 | } | ||
| 2723 | : {}), | 2799 | : {}), |
| 2724 | } satisfies PermissionResult; | 2800 | } satisfies PermissionResult; |
| 2725 | } | 2801 | } |
apps/server/src/provider/Layers/CodexAdapter.test.ts+1| ... | @@ -146,6 +146,7 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory | ... | @@ -146,6 +146,7 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory |
| 146 | getBinding: () => Effect.succeed(Option.none()), | 146 | getBinding: () => Effect.succeed(Option.none()), |
| 147 | remove: () => Effect.void, | 147 | remove: () => Effect.void, |
| 148 | listThreadIds: () => Effect.succeed([]), | 148 | listThreadIds: () => Effect.succeed([]), |
| 149 | listBindings: () => Effect.succeed([]), | ||
| 149 | }); | 150 | }); |
| 150 | 151 | ||
| 151 | const validationManager = new FakeCodexManager(); | 152 | const validationManager = new FakeCodexManager(); |
apps/server/src/provider/Layers/ProviderService.test.ts+37| ... | @@ -654,6 +654,43 @@ routing.layer("ProviderServiceLive routing", (it) => { | ... | @@ -654,6 +654,43 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 654 | }), | 654 | }), |
| 655 | ); | 655 | ); |
| 656 | 656 | ||
| 657 | it.effect("stops stale sessions in other providers after a successful replacement start", () => | ||
| 658 | Effect.gen(function* () { | ||
| 659 | const provider = yield* ProviderService; | ||
| 660 | const threadId = asThreadId("thread-provider-replacement"); | ||
| 661 | |||
| 662 | const codexSession = yield* provider.startSession(threadId, { | ||
| 663 | provider: "codex", | ||
| 664 | threadId, | ||
| 665 | cwd: "/tmp/project-provider-replacement", | ||
| 666 | runtimeMode: "full-access", | ||
| 667 | }); | ||
| 668 | |||
| 669 | routing.codex.stopSession.mockClear(); | ||
| 670 | routing.claude.stopSession.mockClear(); | ||
| 671 | |||
| 672 | const claudeSession = yield* provider.startSession(threadId, { | ||
| 673 | provider: "claudeAgent", | ||
| 674 | threadId, | ||
| 675 | cwd: "/tmp/project-provider-replacement", | ||
| 676 | runtimeMode: "full-access", | ||
| 677 | }); | ||
| 678 | |||
| 679 | assert.equal(codexSession.provider, "codex"); | ||
| 680 | assert.equal(claudeSession.provider, "claudeAgent"); | ||
| 681 | assert.deepEqual(routing.codex.stopSession.mock.calls, [[threadId]]); | ||
| 682 | assert.equal(routing.claude.stopSession.mock.calls.length, 0); | ||
| 683 | |||
| 684 | const sessions = yield* provider.listSessions(); | ||
| 685 | assert.deepEqual( | ||
| 686 | sessions | ||
| 687 | .filter((session) => session.threadId === threadId) | ||
| 688 | .map((session) => session.provider), | ||
| 689 | ["claudeAgent"], | ||
| 690 | ); | ||
| 691 | }), | ||
| 692 | ); | ||
| 693 | |||
| 657 | it.effect("recovers stale sessions for sendTurn using persisted cwd", () => | 694 | it.effect("recovers stale sessions for sendTurn using persisted cwd", () => |
| 658 | Effect.gen(function* () { | 695 | Effect.gen(function* () { |
| 659 | const provider = yield* ProviderService; | 696 | const provider = yield* ProviderService; |
apps/server/src/provider/Layers/ProviderService.ts+38| ... | @@ -297,6 +297,40 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( | ... | @@ -297,6 +297,40 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 297 | return { adapter: recovered.adapter, threadId: input.threadId, isActive: true } as const; | 297 | return { adapter: recovered.adapter, threadId: input.threadId, isActive: true } as const; |
| 298 | }); | 298 | }); |
| 299 | 299 | ||
| 300 | const stopStaleSessionsForThread = Effect.fn("stopStaleSessionsForThread")(function* (input: { | ||
| 301 | readonly threadId: ThreadId; | ||
| 302 | readonly currentProvider: ProviderSession["provider"]; | ||
| 303 | }) { | ||
| 304 | yield* Effect.forEach( | ||
| 305 | adapters, | ||
| 306 | (adapter) => | ||
| 307 | adapter.provider === input.currentProvider | ||
| 308 | ? Effect.void | ||
| 309 | : Effect.gen(function* () { | ||
| 310 | const hasSession = yield* adapter.hasSession(input.threadId); | ||
| 311 | if (!hasSession) { | ||
| 312 | return; | ||
| 313 | } | ||
| 314 | |||
| 315 | yield* adapter.stopSession(input.threadId).pipe( | ||
| 316 | Effect.tap(() => | ||
| 317 | analytics.record("provider.session.stopped", { | ||
| 318 | provider: adapter.provider, | ||
| 319 | }), | ||
| 320 | ), | ||
| 321 | Effect.catchCause((cause) => | ||
| 322 | Effect.logWarning("provider.session.stop-stale-failed", { | ||
| 323 | threadId: input.threadId, | ||
| 324 | provider: adapter.provider, | ||
| 325 | cause, | ||
| 326 | }), | ||
| 327 | ), | ||
| 328 | ); | ||
| 329 | }), | ||
| 330 | { discard: true }, | ||
| 331 | ); | ||
| 332 | }); | ||
| 333 | |||
| 300 | const startSession: ProviderServiceShape["startSession"] = Effect.fn("startSession")( | 334 | const startSession: ProviderServiceShape["startSession"] = Effect.fn("startSession")( |
| 301 | function* (threadId, rawInput) { | 335 | function* (threadId, rawInput) { |
| 302 | const parsed = yield* decodeInputOrValidationError({ | 336 | const parsed = yield* decodeInputOrValidationError({ |
| ... | @@ -351,6 +385,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( | ... | @@ -351,6 +385,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 351 | ); | 385 | ); |
| 352 | } | 386 | } |
| 353 | 387 | ||
| 388 | yield* stopStaleSessionsForThread({ | ||
| 389 | threadId, | ||
| 390 | currentProvider: adapter.provider, | ||
| 391 | }); | ||
| 354 | yield* upsertSessionBinding(session, threadId, { | 392 | yield* upsertSessionBinding(session, threadId, { |
| 355 | modelSelection: input.modelSelection, | 393 | modelSelection: input.modelSelection, |
| 356 | }); | 394 | }); |
apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts+72| ... | @@ -133,6 +133,78 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL | ... | @@ -133,6 +133,78 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL |
| 133 | } | 133 | } |
| 134 | })); | 134 | })); |
| 135 | 135 | ||
| 136 | it("lists persisted bindings with metadata in oldest-first order", () => | ||
| 137 | Effect.gen(function* () { | ||
| 138 | const directory = yield* ProviderSessionDirectory; | ||
| 139 | const runtimeRepository = yield* ProviderSessionRuntimeRepository; | ||
| 140 | |||
| 141 | const olderThreadId = ThreadId.make("thread-runtime-older"); | ||
| 142 | const newerThreadId = ThreadId.make("thread-runtime-newer"); | ||
| 143 | |||
| 144 | yield* runtimeRepository.upsert({ | ||
| 145 | threadId: newerThreadId, | ||
| 146 | providerName: "codex", | ||
| 147 | adapterKey: "codex", | ||
| 148 | runtimeMode: "full-access", | ||
| 149 | status: "running", | ||
| 150 | lastSeenAt: "2026-04-14T12:05:00.000Z", | ||
| 151 | resumeCursor: { | ||
| 152 | opaque: "resume-newer", | ||
| 153 | }, | ||
| 154 | runtimePayload: { | ||
| 155 | cwd: "/tmp/newer", | ||
| 156 | }, | ||
| 157 | }); | ||
| 158 | |||
| 159 | yield* runtimeRepository.upsert({ | ||
| 160 | threadId: olderThreadId, | ||
| 161 | providerName: "claudeAgent", | ||
| 162 | adapterKey: "claudeAgent", | ||
| 163 | runtimeMode: "approval-required", | ||
| 164 | status: "starting", | ||
| 165 | lastSeenAt: "2026-04-14T12:00:00.000Z", | ||
| 166 | resumeCursor: { | ||
| 167 | opaque: "resume-older", | ||
| 168 | }, | ||
| 169 | runtimePayload: { | ||
| 170 | cwd: "/tmp/older", | ||
| 171 | }, | ||
| 172 | }); | ||
| 173 | |||
| 174 | const bindings = yield* directory.listBindings(); | ||
| 175 | |||
| 176 | assert.deepEqual(bindings, [ | ||
| 177 | { | ||
| 178 | threadId: olderThreadId, | ||
| 179 | provider: "claudeAgent", | ||
| 180 | adapterKey: "claudeAgent", | ||
| 181 | runtimeMode: "approval-required", | ||
| 182 | status: "starting", | ||
| 183 | lastSeenAt: "2026-04-14T12:00:00.000Z", | ||
| 184 | resumeCursor: { | ||
| 185 | opaque: "resume-older", | ||
| 186 | }, | ||
| 187 | runtimePayload: { | ||
| 188 | cwd: "/tmp/older", | ||
| 189 | }, | ||
| 190 | }, | ||
| 191 | { | ||
| 192 | threadId: newerThreadId, | ||
| 193 | provider: "codex", | ||
| 194 | adapterKey: "codex", | ||
| 195 | runtimeMode: "full-access", | ||
| 196 | status: "running", | ||
| 197 | lastSeenAt: "2026-04-14T12:05:00.000Z", | ||
| 198 | resumeCursor: { | ||
| 199 | opaque: "resume-newer", | ||
| 200 | }, | ||
| 201 | runtimePayload: { | ||
| 202 | cwd: "/tmp/newer", | ||
| 203 | }, | ||
| 204 | }, | ||
| 205 | ]); | ||
| 206 | })); | ||
| 207 | |||
| 136 | it("resets adapterKey to the new provider when provider changes without an explicit adapter key", () => | 208 | it("resets adapterKey to the new provider when provider changes without an explicit adapter key", () => |
| 137 | Effect.gen(function* () { | 209 | Effect.gen(function* () { |
| 138 | const directory = yield* ProviderSessionDirectory; | 210 | const directory = yield* ProviderSessionDirectory; |
apps/server/src/provider/Layers/ProviderSessionDirectory.ts+38-12| ... | @@ -1,11 +1,13 @@ | ... | @@ -1,11 +1,13 @@ |
| 1 | import { type ProviderKind, type ThreadId } from "@t3tools/contracts"; | 1 | import { type ProviderKind, type ThreadId } from "@t3tools/contracts"; |
| 2 | import { Effect, Layer, Option } from "effect"; | 2 | import { Effect, Layer, Option } from "effect"; |
| 3 | 3 | ||
| 4 | import type { ProviderSessionRuntime } from "../../persistence/Services/ProviderSessionRuntime.ts"; | ||
| 4 | import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; | 5 | import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; |
| 5 | import { ProviderSessionDirectoryPersistenceError, ProviderValidationError } from "../Errors.ts"; | 6 | import { ProviderSessionDirectoryPersistenceError, ProviderValidationError } from "../Errors.ts"; |
| 6 | import { | 7 | import { |
| 7 | ProviderSessionDirectory, | 8 | ProviderSessionDirectory, |
| 8 | type ProviderRuntimeBinding, | 9 | type ProviderRuntimeBinding, |
| 10 | type ProviderRuntimeBindingWithMetadata, | ||
| 9 | type ProviderSessionDirectoryShape, | 11 | type ProviderSessionDirectoryShape, |
| 10 | } from "../Services/ProviderSessionDirectory.ts"; | 12 | } from "../Services/ProviderSessionDirectory.ts"; |
| 11 | 13 | ||
| ... | @@ -50,6 +52,27 @@ function mergeRuntimePayload( | ... | @@ -50,6 +52,27 @@ function mergeRuntimePayload( |
| 50 | return next; | 52 | return next; |
| 51 | } | 53 | } |
| 52 | 54 | ||
| 55 | function toRuntimeBinding( | ||
| 56 | runtime: ProviderSessionRuntime, | ||
| 57 | operation: string, | ||
| 58 | ): Effect.Effect<ProviderRuntimeBindingWithMetadata, ProviderSessionDirectoryPersistenceError> { | ||
| 59 | return decodeProviderKind(runtime.providerName, operation).pipe( | ||
| 60 | Effect.map( | ||
| 61 | (provider) => | ||
| 62 | ({ | ||
| 63 | threadId: runtime.threadId, | ||
| 64 | provider, | ||
| 65 | adapterKey: runtime.adapterKey, | ||
| 66 | runtimeMode: runtime.runtimeMode, | ||
| 67 | status: runtime.status, | ||
| 68 | resumeCursor: runtime.resumeCursor, | ||
| 69 | runtimePayload: runtime.runtimePayload, | ||
| 70 | lastSeenAt: runtime.lastSeenAt, | ||
| 71 | }) satisfies ProviderRuntimeBindingWithMetadata, | ||
| 72 | ), | ||
| 73 | ); | ||
| 74 | } | ||
| 75 | |||
| 53 | const makeProviderSessionDirectory = Effect.gen(function* () { | 76 | const makeProviderSessionDirectory = Effect.gen(function* () { |
| 54 | const repository = yield* ProviderSessionRuntimeRepository; | 77 | const repository = yield* ProviderSessionRuntimeRepository; |
| 55 | 78 | ||
| ... | @@ -60,18 +83,8 @@ const makeProviderSessionDirectory = Effect.gen(function* () { | ... | @@ -60,18 +83,8 @@ const makeProviderSessionDirectory = Effect.gen(function* () { |
| 60 | Option.match(runtime, { | 83 | Option.match(runtime, { |
| 61 | onNone: () => Effect.succeed(Option.none<ProviderRuntimeBinding>()), | 84 | onNone: () => Effect.succeed(Option.none<ProviderRuntimeBinding>()), |
| 62 | onSome: (value) => | 85 | onSome: (value) => |
| 63 | decodeProviderKind(value.providerName, "ProviderSessionDirectory.getBinding").pipe( | 86 | toRuntimeBinding(value, "ProviderSessionDirectory.getBinding").pipe( |
| 64 | Effect.map((provider) => | 87 | Effect.map((binding) => Option.some(binding)), |
| 65 | Option.some({ | ||
| 66 | threadId: value.threadId, | ||
| 67 | provider, | ||
| 68 | adapterKey: value.adapterKey, | ||
| 69 | runtimeMode: value.runtimeMode, | ||
| 70 | status: value.status, | ||
| 71 | resumeCursor: value.resumeCursor, | ||
| 72 | runtimePayload: value.runtimePayload, | ||
| 73 | }), | ||
| 74 | ), | ||
| 75 | ), | 88 | ), |
| 76 | }), | 89 | }), |
| 77 | ), | 90 | ), |
| ... | @@ -145,12 +158,25 @@ const makeProviderSessionDirectory = Effect.gen(function* () { | ... | @@ -145,12 +158,25 @@ const makeProviderSessionDirectory = Effect.gen(function* () { |
| 145 | Effect.map((rows) => rows.map((row) => row.threadId)), | 158 | Effect.map((rows) => rows.map((row) => row.threadId)), |
| 146 | ); | 159 | ); |
| 147 | 160 | ||
| 161 | const listBindings: ProviderSessionDirectoryShape["listBindings"] = () => | ||
| 162 | repository.list().pipe( | ||
| 163 | Effect.mapError(toPersistenceError("ProviderSessionDirectory.listBindings:list")), | ||
| 164 | Effect.flatMap((rows) => | ||
| 165 | Effect.forEach( | ||
| 166 | rows, | ||
| 167 | (row) => toRuntimeBinding(row, "ProviderSessionDirectory.listBindings"), | ||
| 168 | { concurrency: "unbounded" }, | ||
| 169 | ), | ||
| 170 | ), | ||
| 171 | ); | ||
| 172 | |||
| 148 | return { | 173 | return { |
| 149 | upsert, | 174 | upsert, |
| 150 | getProvider, | 175 | getProvider, |
| 151 | getBinding, | 176 | getBinding, |
| 152 | remove, | 177 | remove, |
| 153 | listThreadIds, | 178 | listThreadIds, |
| 179 | listBindings, | ||
| 154 | } satisfies ProviderSessionDirectoryShape; | 180 | } satisfies ProviderSessionDirectoryShape; |
| 155 | }); | 181 | }); |
| 156 | 182 |
apps/server/src/provider/Layers/ProviderSessionReaper.test.ts created+522| ... | @@ -0,0 +1,522 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; | ||
| 3 | import { Effect, Exit, Layer, ManagedRuntime, Option, Scope, Stream } from "effect"; | ||
| 4 | import { afterEach, describe, expect, it, vi } from "vitest"; | ||
| 5 | |||
| 6 | import { | ||
| 7 | OrchestrationEngineService, | ||
| 8 | type OrchestrationEngineShape, | ||
| 9 | } from "../../orchestration/Services/OrchestrationEngine.ts"; | ||
| 10 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; | ||
| 11 | import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts"; | ||
| 12 | import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; | ||
| 13 | import { ProviderValidationError } from "../Errors.ts"; | ||
| 14 | import { ProviderSessionReaper } from "../Services/ProviderSessionReaper.ts"; | ||
| 15 | import { ProviderService, type ProviderServiceShape } from "../Services/ProviderService.ts"; | ||
| 16 | import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; | ||
| 17 | import { makeProviderSessionReaperLive } from "./ProviderSessionReaper.ts"; | ||
| 18 | |||
| 19 | const defaultModelSelection = { | ||
| 20 | provider: "codex", | ||
| 21 | model: "gpt-5-codex", | ||
| 22 | } as const; | ||
| 23 | |||
| 24 | async function waitFor( | ||
| 25 | predicate: () => boolean | Promise<boolean>, | ||
| 26 | timeoutMs = 2_000, | ||
| 27 | ): Promise<void> { | ||
| 28 | const deadline = Date.now() + timeoutMs; | ||
| 29 | const poll = async (): Promise<void> => { | ||
| 30 | if (await predicate()) { | ||
| 31 | return; | ||
| 32 | } | ||
| 33 | if (Date.now() >= deadline) { | ||
| 34 | throw new Error("Timed out waiting for expectation."); | ||
| 35 | } | ||
| 36 | await new Promise((resolve) => setTimeout(resolve, 10)); | ||
| 37 | return poll(); | ||
| 38 | }; | ||
| 39 | |||
| 40 | return poll(); | ||
| 41 | } | ||
| 42 | |||
| 43 | const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; | ||
| 44 | |||
| 45 | function makeReadModel( | ||
| 46 | threads: ReadonlyArray<{ | ||
| 47 | readonly id: ThreadId; | ||
| 48 | readonly session: { | ||
| 49 | readonly threadId: ThreadId; | ||
| 50 | readonly status: "starting" | "running" | "ready" | "interrupted" | "stopped" | "error"; | ||
| 51 | readonly providerName: "codex" | "claudeAgent"; | ||
| 52 | readonly runtimeMode: "approval-required" | "full-access" | "auto-accept-edits"; | ||
| 53 | readonly activeTurnId: TurnId | null; | ||
| 54 | readonly lastError: string | null; | ||
| 55 | readonly updatedAt: string; | ||
| 56 | } | null; | ||
| 57 | }>, | ||
| 58 | ) { | ||
| 59 | const now = new Date().toISOString(); | ||
| 60 | const projectId = ProjectId.make("project-provider-session-reaper"); | ||
| 61 | |||
| 62 | return { | ||
| 63 | snapshotSequence: 0, | ||
| 64 | updatedAt: now, | ||
| 65 | projects: [ | ||
| 66 | { | ||
| 67 | id: projectId, | ||
| 68 | title: "Provider Reaper Project", | ||
| 69 | workspaceRoot: "/tmp/provider-reaper-project", | ||
| 70 | defaultModelSelection, | ||
| 71 | scripts: [], | ||
| 72 | createdAt: now, | ||
| 73 | updatedAt: now, | ||
| 74 | deletedAt: null, | ||
| 75 | }, | ||
| 76 | ], | ||
| 77 | threads: threads.map((thread) => ({ | ||
| 78 | id: thread.id, | ||
| 79 | projectId, | ||
| 80 | title: `Thread ${thread.id}`, | ||
| 81 | modelSelection: defaultModelSelection, | ||
| 82 | interactionMode: "default" as const, | ||
| 83 | runtimeMode: "full-access" as const, | ||
| 84 | branch: null, | ||
| 85 | worktreePath: null, | ||
| 86 | createdAt: now, | ||
| 87 | updatedAt: now, | ||
| 88 | archivedAt: null, | ||
| 89 | latestTurn: null, | ||
| 90 | messages: [], | ||
| 91 | session: thread.session, | ||
| 92 | activities: [], | ||
| 93 | proposedPlans: [], | ||
| 94 | checkpoints: [], | ||
| 95 | deletedAt: null, | ||
| 96 | })), | ||
| 97 | }; | ||
| 98 | } | ||
| 99 | |||
| 100 | describe("ProviderSessionReaper", () => { | ||
| 101 | let runtime: ManagedRuntime.ManagedRuntime< | ||
| 102 | ProviderSessionReaper | ProviderSessionRuntimeRepository, | ||
| 103 | unknown | ||
| 104 | > | null = null; | ||
| 105 | let scope: Scope.Closeable | null = null; | ||
| 106 | |||
| 107 | afterEach(async () => { | ||
| 108 | if (scope) { | ||
| 109 | await Effect.runPromise(Scope.close(scope, Exit.void)); | ||
| 110 | } | ||
| 111 | scope = null; | ||
| 112 | if (runtime) { | ||
| 113 | await runtime.dispose(); | ||
| 114 | } | ||
| 115 | runtime = null; | ||
| 116 | }); | ||
| 117 | |||
| 118 | async function createHarness(input: { | ||
| 119 | readonly readModel: ReturnType<typeof makeReadModel>; | ||
| 120 | readonly stopSessionImplementation?: (input: { | ||
| 121 | readonly threadId: ThreadId; | ||
| 122 | }) => ReturnType<ProviderServiceShape["stopSession"]>; | ||
| 123 | }) { | ||
| 124 | const stoppedThreadIds = new Set<ThreadId>(); | ||
| 125 | const stopSession = vi.fn<ProviderServiceShape["stopSession"]>( | ||
| 126 | (request) => | ||
| 127 | (input.stopSessionImplementation | ||
| 128 | ? input.stopSessionImplementation(request) | ||
| 129 | : Effect.sync(() => { | ||
| 130 | stoppedThreadIds.add(request.threadId); | ||
| 131 | })) as ReturnType<ProviderServiceShape["stopSession"]>, | ||
| 132 | ); | ||
| 133 | |||
| 134 | const providerService: ProviderServiceShape = { | ||
| 135 | startSession: () => unsupported(), | ||
| 136 | sendTurn: () => unsupported(), | ||
| 137 | interruptTurn: () => unsupported(), | ||
| 138 | respondToRequest: () => unsupported(), | ||
| 139 | respondToUserInput: () => unsupported(), | ||
| 140 | stopSession, | ||
| 141 | listSessions: () => Effect.succeed([]), | ||
| 142 | getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), | ||
| 143 | rollbackConversation: () => unsupported(), | ||
| 144 | streamEvents: Stream.empty, | ||
| 145 | }; | ||
| 146 | |||
| 147 | const orchestrationEngine: OrchestrationEngineShape = { | ||
| 148 | getReadModel: () => Effect.succeed(input.readModel), | ||
| 149 | readEvents: () => Stream.empty, | ||
| 150 | dispatch: () => unsupported(), | ||
| 151 | streamDomainEvents: Stream.empty, | ||
| 152 | }; | ||
| 153 | |||
| 154 | const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( | ||
| 155 | Layer.provide(SqlitePersistenceMemory), | ||
| 156 | ); | ||
| 157 | const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( | ||
| 158 | Layer.provide(runtimeRepositoryLayer), | ||
| 159 | ); | ||
| 160 | const layer = makeProviderSessionReaperLive({ | ||
| 161 | inactivityThresholdMs: 1_000, | ||
| 162 | sweepIntervalMs: 60_000, | ||
| 163 | }).pipe( | ||
| 164 | Layer.provideMerge(providerSessionDirectoryLayer), | ||
| 165 | Layer.provideMerge(runtimeRepositoryLayer), | ||
| 166 | Layer.provideMerge(Layer.succeed(ProviderService, providerService)), | ||
| 167 | Layer.provideMerge(Layer.succeed(OrchestrationEngineService, orchestrationEngine)), | ||
| 168 | Layer.provideMerge(NodeServices.layer), | ||
| 169 | ); | ||
| 170 | |||
| 171 | runtime = ManagedRuntime.make(layer); | ||
| 172 | return { stopSession, stoppedThreadIds }; | ||
| 173 | } | ||
| 174 | |||
| 175 | it("reaps stale persisted sessions without active turns", async () => { | ||
| 176 | const threadId = ThreadId.make("thread-reaper-stale"); | ||
| 177 | const now = new Date().toISOString(); | ||
| 178 | const harness = await createHarness({ | ||
| 179 | readModel: makeReadModel([ | ||
| 180 | { | ||
| 181 | id: threadId, | ||
| 182 | session: { | ||
| 183 | threadId, | ||
| 184 | status: "ready", | ||
| 185 | providerName: "claudeAgent", | ||
| 186 | runtimeMode: "full-access", | ||
| 187 | activeTurnId: null, | ||
| 188 | lastError: null, | ||
| 189 | updatedAt: now, | ||
| 190 | }, | ||
| 191 | }, | ||
| 192 | ]), | ||
| 193 | }); | ||
| 194 | const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); | ||
| 195 | |||
| 196 | await runtime!.runPromise( | ||
| 197 | repository.upsert({ | ||
| 198 | threadId, | ||
| 199 | providerName: "claudeAgent", | ||
| 200 | adapterKey: "claudeAgent", | ||
| 201 | runtimeMode: "full-access", | ||
| 202 | status: "running", | ||
| 203 | lastSeenAt: "2026-04-14T00:00:00.000Z", | ||
| 204 | resumeCursor: { | ||
| 205 | opaque: "resume-stale", | ||
| 206 | }, | ||
| 207 | runtimePayload: null, | ||
| 208 | }), | ||
| 209 | ); | ||
| 210 | |||
| 211 | const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); | ||
| 212 | scope = await Effect.runPromise(Scope.make("sequential")); | ||
| 213 | await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); | ||
| 214 | |||
| 215 | await waitFor(() => harness.stopSession.mock.calls.length === 1); | ||
| 216 | |||
| 217 | expect(harness.stopSession.mock.calls[0]?.[0]).toEqual({ threadId }); | ||
| 218 | expect(harness.stoppedThreadIds.has(threadId)).toBe(true); | ||
| 219 | }); | ||
| 220 | |||
| 221 | it("skips stale sessions when the thread still has an active turn", async () => { | ||
| 222 | const threadId = ThreadId.make("thread-reaper-active-turn"); | ||
| 223 | const turnId = TurnId.make("turn-reaper-active"); | ||
| 224 | const now = new Date().toISOString(); | ||
| 225 | const harness = await createHarness({ | ||
| 226 | readModel: makeReadModel([ | ||
| 227 | { | ||
| 228 | id: threadId, | ||
| 229 | session: { | ||
| 230 | threadId, | ||
| 231 | status: "running", | ||
| 232 | providerName: "claudeAgent", | ||
| 233 | runtimeMode: "full-access", | ||
| 234 | activeTurnId: turnId, | ||
| 235 | lastError: null, | ||
| 236 | updatedAt: now, | ||
| 237 | }, | ||
| 238 | }, | ||
| 239 | ]), | ||
| 240 | }); | ||
| 241 | const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); | ||
| 242 | |||
| 243 | await runtime!.runPromise( | ||
| 244 | repository.upsert({ | ||
| 245 | threadId, | ||
| 246 | providerName: "claudeAgent", | ||
| 247 | adapterKey: "claudeAgent", | ||
| 248 | runtimeMode: "full-access", | ||
| 249 | status: "running", | ||
| 250 | lastSeenAt: "2026-04-14T00:00:00.000Z", | ||
| 251 | resumeCursor: { | ||
| 252 | opaque: "resume-active-turn", | ||
| 253 | }, | ||
| 254 | runtimePayload: null, | ||
| 255 | }), | ||
| 256 | ); | ||
| 257 | |||
| 258 | const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); | ||
| 259 | scope = await Effect.runPromise(Scope.make("sequential")); | ||
| 260 | await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); | ||
| 261 | await new Promise((resolve) => setTimeout(resolve, 50)); | ||
| 262 | |||
| 263 | expect(harness.stopSession).not.toHaveBeenCalled(); | ||
| 264 | const remaining = await runtime!.runPromise(repository.getByThreadId({ threadId })); | ||
| 265 | expect(Option.isSome(remaining)).toBe(true); | ||
| 266 | }); | ||
| 267 | |||
| 268 | it("does not reap sessions that are still within the inactivity threshold", async () => { | ||
| 269 | const threadId = ThreadId.make("thread-reaper-fresh"); | ||
| 270 | const now = new Date().toISOString(); | ||
| 271 | const harness = await createHarness({ | ||
| 272 | readModel: makeReadModel([ | ||
| 273 | { | ||
| 274 | id: threadId, | ||
| 275 | session: { | ||
| 276 | threadId, | ||
| 277 | status: "ready", | ||
| 278 | providerName: "claudeAgent", | ||
| 279 | runtimeMode: "full-access", | ||
| 280 | activeTurnId: null, | ||
| 281 | lastError: null, | ||
| 282 | updatedAt: now, | ||
| 283 | }, | ||
| 284 | }, | ||
| 285 | ]), | ||
| 286 | }); | ||
| 287 | const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); | ||
| 288 | |||
| 289 | await runtime!.runPromise( | ||
| 290 | repository.upsert({ | ||
| 291 | threadId, | ||
| 292 | providerName: "claudeAgent", | ||
| 293 | adapterKey: "claudeAgent", | ||
| 294 | runtimeMode: "full-access", | ||
| 295 | status: "running", | ||
| 296 | lastSeenAt: now, | ||
| 297 | resumeCursor: { | ||
| 298 | opaque: "resume-fresh", | ||
| 299 | }, | ||
| 300 | runtimePayload: null, | ||
| 301 | }), | ||
| 302 | ); | ||
| 303 | |||
| 304 | const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); | ||
| 305 | scope = await Effect.runPromise(Scope.make("sequential")); | ||
| 306 | await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); | ||
| 307 | await new Promise((resolve) => setTimeout(resolve, 50)); | ||
| 308 | |||
| 309 | expect(harness.stopSession).not.toHaveBeenCalled(); | ||
| 310 | const remaining = await runtime!.runPromise(repository.getByThreadId({ threadId })); | ||
| 311 | expect(Option.isSome(remaining)).toBe(true); | ||
| 312 | }); | ||
| 313 | |||
| 314 | it("skips persisted sessions that are already marked stopped", async () => { | ||
| 315 | const threadId = ThreadId.make("thread-reaper-stopped"); | ||
| 316 | const now = new Date().toISOString(); | ||
| 317 | const harness = await createHarness({ | ||
| 318 | readModel: makeReadModel([ | ||
| 319 | { | ||
| 320 | id: threadId, | ||
| 321 | session: { | ||
| 322 | threadId, | ||
| 323 | status: "stopped", | ||
| 324 | providerName: "claudeAgent", | ||
| 325 | runtimeMode: "full-access", | ||
| 326 | activeTurnId: null, | ||
| 327 | lastError: null, | ||
| 328 | updatedAt: now, | ||
| 329 | }, | ||
| 330 | }, | ||
| 331 | ]), | ||
| 332 | }); | ||
| 333 | const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); | ||
| 334 | |||
| 335 | await runtime!.runPromise( | ||
| 336 | repository.upsert({ | ||
| 337 | threadId, | ||
| 338 | providerName: "claudeAgent", | ||
| 339 | adapterKey: "claudeAgent", | ||
| 340 | runtimeMode: "full-access", | ||
| 341 | status: "stopped", | ||
| 342 | lastSeenAt: "2026-04-14T00:00:00.000Z", | ||
| 343 | resumeCursor: { | ||
| 344 | opaque: "resume-stopped", | ||
| 345 | }, | ||
| 346 | runtimePayload: null, | ||
| 347 | }), | ||
| 348 | ); | ||
| 349 | |||
| 350 | const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); | ||
| 351 | scope = await Effect.runPromise(Scope.make("sequential")); | ||
| 352 | await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); | ||
| 353 | await new Promise((resolve) => setTimeout(resolve, 50)); | ||
| 354 | |||
| 355 | expect(harness.stopSession).not.toHaveBeenCalled(); | ||
| 356 | const remaining = await runtime!.runPromise(repository.getByThreadId({ threadId })); | ||
| 357 | expect(Option.isSome(remaining)).toBe(true); | ||
| 358 | }); | ||
| 359 | |||
| 360 | it("continues reaping other sessions when one stop attempt fails", async () => { | ||
| 361 | const failedThreadId = ThreadId.make("thread-reaper-stop-failure"); | ||
| 362 | const reapedThreadId = ThreadId.make("thread-reaper-stop-success"); | ||
| 363 | const now = new Date().toISOString(); | ||
| 364 | const harness = await createHarness({ | ||
| 365 | readModel: makeReadModel([ | ||
| 366 | { | ||
| 367 | id: failedThreadId, | ||
| 368 | session: { | ||
| 369 | threadId: failedThreadId, | ||
| 370 | status: "ready", | ||
| 371 | providerName: "claudeAgent", | ||
| 372 | runtimeMode: "full-access", | ||
| 373 | activeTurnId: null, | ||
| 374 | lastError: null, | ||
| 375 | updatedAt: now, | ||
| 376 | }, | ||
| 377 | }, | ||
| 378 | { | ||
| 379 | id: reapedThreadId, | ||
| 380 | session: { | ||
| 381 | threadId: reapedThreadId, | ||
| 382 | status: "ready", | ||
| 383 | providerName: "codex", | ||
| 384 | runtimeMode: "full-access", | ||
| 385 | activeTurnId: null, | ||
| 386 | lastError: null, | ||
| 387 | updatedAt: now, | ||
| 388 | }, | ||
| 389 | }, | ||
| 390 | ]), | ||
| 391 | stopSessionImplementation: (request) => | ||
| 392 | request.threadId === failedThreadId | ||
| 393 | ? Effect.fail( | ||
| 394 | new ProviderValidationError({ | ||
| 395 | operation: "ProviderSessionReaper.test", | ||
| 396 | issue: "simulated stop failure", | ||
| 397 | }), | ||
| 398 | ) | ||
| 399 | : Effect.void, | ||
| 400 | }); | ||
| 401 | const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); | ||
| 402 | |||
| 403 | await runtime!.runPromise( | ||
| 404 | repository.upsert({ | ||
| 405 | threadId: failedThreadId, | ||
| 406 | providerName: "claudeAgent", | ||
| 407 | adapterKey: "claudeAgent", | ||
| 408 | runtimeMode: "full-access", | ||
| 409 | status: "running", | ||
| 410 | lastSeenAt: "2026-04-14T00:00:00.000Z", | ||
| 411 | resumeCursor: { | ||
| 412 | opaque: "resume-failure", | ||
| 413 | }, | ||
| 414 | runtimePayload: null, | ||
| 415 | }), | ||
| 416 | ); | ||
| 417 | await runtime!.runPromise( | ||
| 418 | repository.upsert({ | ||
| 419 | threadId: reapedThreadId, | ||
| 420 | providerName: "codex", | ||
| 421 | adapterKey: "codex", | ||
| 422 | runtimeMode: "full-access", | ||
| 423 | status: "running", | ||
| 424 | lastSeenAt: "2026-04-14T00:01:00.000Z", | ||
| 425 | resumeCursor: { | ||
| 426 | opaque: "resume-success", | ||
| 427 | }, | ||
| 428 | runtimePayload: null, | ||
| 429 | }), | ||
| 430 | ); | ||
| 431 | |||
| 432 | const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); | ||
| 433 | scope = await Effect.runPromise(Scope.make("sequential")); | ||
| 434 | await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); | ||
| 435 | |||
| 436 | await waitFor(() => harness.stopSession.mock.calls.length === 2); | ||
| 437 | |||
| 438 | expect(harness.stopSession.mock.calls.map(([request]) => request.threadId)).toEqual([ | ||
| 439 | failedThreadId, | ||
| 440 | reapedThreadId, | ||
| 441 | ]); | ||
| 442 | }); | ||
| 443 | |||
| 444 | it("continues reaping other sessions when one stop attempt defects", async () => { | ||
| 445 | const defectThreadId = ThreadId.make("thread-reaper-stop-defect"); | ||
| 446 | const reapedThreadId = ThreadId.make("thread-reaper-stop-after-defect"); | ||
| 447 | const now = new Date().toISOString(); | ||
| 448 | const harness = await createHarness({ | ||
| 449 | readModel: makeReadModel([ | ||
| 450 | { | ||
| 451 | id: defectThreadId, | ||
| 452 | session: { | ||
| 453 | threadId: defectThreadId, | ||
| 454 | status: "ready", | ||
| 455 | providerName: "claudeAgent", | ||
| 456 | runtimeMode: "full-access", | ||
| 457 | activeTurnId: null, | ||
| 458 | lastError: null, | ||
| 459 | updatedAt: now, | ||
| 460 | }, | ||
| 461 | }, | ||
| 462 | { | ||
| 463 | id: reapedThreadId, | ||
| 464 | session: { | ||
| 465 | threadId: reapedThreadId, | ||
| 466 | status: "ready", | ||
| 467 | providerName: "codex", | ||
| 468 | runtimeMode: "full-access", | ||
| 469 | activeTurnId: null, | ||
| 470 | lastError: null, | ||
| 471 | updatedAt: now, | ||
| 472 | }, | ||
| 473 | }, | ||
| 474 | ]), | ||
| 475 | stopSessionImplementation: (request) => | ||
| 476 | request.threadId === defectThreadId | ||
| 477 | ? Effect.die(new Error("simulated stop defect")) | ||
| 478 | : Effect.void, | ||
| 479 | }); | ||
| 480 | const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); | ||
| 481 | |||
| 482 | await runtime!.runPromise( | ||
| 483 | repository.upsert({ | ||
| 484 | threadId: defectThreadId, | ||
| 485 | providerName: "claudeAgent", | ||
| 486 | adapterKey: "claudeAgent", | ||
| 487 | runtimeMode: "full-access", | ||
| 488 | status: "running", | ||
| 489 | lastSeenAt: "2026-04-14T00:00:00.000Z", | ||
| 490 | resumeCursor: { | ||
| 491 | opaque: "resume-defect", | ||
| 492 | }, | ||
| 493 | runtimePayload: null, | ||
| 494 | }), | ||
| 495 | ); | ||
| 496 | await runtime!.runPromise( | ||
| 497 | repository.upsert({ | ||
| 498 | threadId: reapedThreadId, | ||
| 499 | providerName: "codex", | ||
| 500 | adapterKey: "codex", | ||
| 501 | runtimeMode: "full-access", | ||
| 502 | status: "running", | ||
| 503 | lastSeenAt: "2026-04-14T00:01:00.000Z", | ||
| 504 | resumeCursor: { | ||
| 505 | opaque: "resume-after-defect", | ||
| 506 | }, | ||
| 507 | runtimePayload: null, | ||
| 508 | }), | ||
| 509 | ); | ||
| 510 | |||
| 511 | const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); | ||
| 512 | scope = await Effect.runPromise(Scope.make("sequential")); | ||
| 513 | await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); | ||
| 514 | |||
| 515 | await waitFor(() => harness.stopSession.mock.calls.length === 2); | ||
| 516 | |||
| 517 | expect(harness.stopSession.mock.calls.map(([request]) => request.threadId)).toEqual([ | ||
| 518 | defectThreadId, | ||
| 519 | reapedThreadId, | ||
| 520 | ]); | ||
| 521 | }); | ||
| 522 | }); | ||
apps/server/src/provider/Layers/ProviderSessionReaper.ts created+133| ... | @@ -0,0 +1,133 @@ | ||
| 1 | import { Duration, Effect, Layer, Schedule } from "effect"; | ||
| 2 | |||
| 3 | import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts"; | ||
| 4 | import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; | ||
| 5 | import { | ||
| 6 | ProviderSessionReaper, | ||
| 7 | type ProviderSessionReaperShape, | ||
| 8 | } from "../Services/ProviderSessionReaper.ts"; | ||
| 9 | import { ProviderService } from "../Services/ProviderService.ts"; | ||
| 10 | |||
| 11 | const DEFAULT_INACTIVITY_THRESHOLD_MS = 30 * 60 * 1000; | ||
| 12 | const DEFAULT_SWEEP_INTERVAL_MS = 5 * 60 * 1000; | ||
| 13 | |||
| 14 | export interface ProviderSessionReaperLiveOptions { | ||
| 15 | readonly inactivityThresholdMs?: number; | ||
| 16 | readonly sweepIntervalMs?: number; | ||
| 17 | } | ||
| 18 | |||
| 19 | const makeProviderSessionReaper = (options?: ProviderSessionReaperLiveOptions) => | ||
| 20 | Effect.gen(function* () { | ||
| 21 | const providerService = yield* ProviderService; | ||
| 22 | const directory = yield* ProviderSessionDirectory; | ||
| 23 | const orchestrationEngine = yield* OrchestrationEngineService; | ||
| 24 | |||
| 25 | const inactivityThresholdMs = Math.max( | ||
| 26 | 1, | ||
| 27 | options?.inactivityThresholdMs ?? DEFAULT_INACTIVITY_THRESHOLD_MS, | ||
| 28 | ); | ||
| 29 | const sweepIntervalMs = Math.max(1, options?.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS); | ||
| 30 | |||
| 31 | const sweep = Effect.gen(function* () { | ||
| 32 | const readModel = yield* orchestrationEngine.getReadModel(); | ||
| 33 | const threadsById = new Map(readModel.threads.map((thread) => [thread.id, thread] as const)); | ||
| 34 | const bindings = yield* directory.listBindings(); | ||
| 35 | const now = Date.now(); | ||
| 36 | let reapedCount = 0; | ||
| 37 | |||
| 38 | for (const binding of bindings) { | ||
| 39 | if (binding.status === "stopped") { | ||
| 40 | continue; | ||
| 41 | } | ||
| 42 | |||
| 43 | const lastSeenMs = Date.parse(binding.lastSeenAt); | ||
| 44 | if (Number.isNaN(lastSeenMs)) { | ||
| 45 | yield* Effect.logWarning("provider.session.reaper.invalid-last-seen", { | ||
| 46 | threadId: binding.threadId, | ||
| 47 | provider: binding.provider, | ||
| 48 | lastSeenAt: binding.lastSeenAt, | ||
| 49 | }); | ||
| 50 | continue; | ||
| 51 | } | ||
| 52 | |||
| 53 | const idleDurationMs = now - lastSeenMs; | ||
| 54 | if (idleDurationMs < inactivityThresholdMs) { | ||
| 55 | continue; | ||
| 56 | } | ||
| 57 | |||
| 58 | const thread = threadsById.get(binding.threadId); | ||
| 59 | if (thread?.session?.activeTurnId != null) { | ||
| 60 | yield* Effect.logDebug("provider.session.reaper.skipped-active-turn", { | ||
| 61 | threadId: binding.threadId, | ||
| 62 | activeTurnId: thread.session.activeTurnId, | ||
| 63 | idleDurationMs, | ||
| 64 | }); | ||
| 65 | continue; | ||
| 66 | } | ||
| 67 | |||
| 68 | const reaped = yield* providerService.stopSession({ threadId: binding.threadId }).pipe( | ||
| 69 | Effect.tap(() => | ||
| 70 | Effect.logInfo("provider.session.reaped", { | ||
| 71 | threadId: binding.threadId, | ||
| 72 | provider: binding.provider, | ||
| 73 | idleDurationMs, | ||
| 74 | reason: "inactivity_threshold", | ||
| 75 | }), | ||
| 76 | ), | ||
| 77 | Effect.as(true), | ||
| 78 | Effect.catchCause((cause) => | ||
| 79 | Effect.logWarning("provider.session.reaper.stop-failed", { | ||
| 80 | threadId: binding.threadId, | ||
| 81 | provider: binding.provider, | ||
| 82 | idleDurationMs, | ||
| 83 | cause, | ||
| 84 | }).pipe(Effect.as(false)), | ||
| 85 | ), | ||
| 86 | ); | ||
| 87 | |||
| 88 | if (reaped) { | ||
| 89 | reapedCount += 1; | ||
| 90 | } | ||
| 91 | } | ||
| 92 | |||
| 93 | if (reapedCount > 0) { | ||
| 94 | yield* Effect.logInfo("provider.session.reaper.sweep-complete", { | ||
| 95 | reapedCount, | ||
| 96 | totalBindings: bindings.length, | ||
| 97 | }); | ||
| 98 | } | ||
| 99 | }); | ||
| 100 | |||
| 101 | const start: ProviderSessionReaperShape["start"] = () => | ||
| 102 | Effect.gen(function* () { | ||
| 103 | yield* Effect.forkScoped( | ||
| 104 | sweep.pipe( | ||
| 105 | Effect.catch((error: unknown) => | ||
| 106 | Effect.logWarning("provider.session.reaper.sweep-failed", { | ||
| 107 | error, | ||
| 108 | }), | ||
| 109 | ), | ||
| 110 | Effect.catchDefect((defect: unknown) => | ||
| 111 | Effect.logWarning("provider.session.reaper.sweep-defect", { | ||
| 112 | defect, | ||
| 113 | }), | ||
| 114 | ), | ||
| 115 | Effect.repeat(Schedule.spaced(Duration.millis(sweepIntervalMs))), | ||
| 116 | ), | ||
| 117 | ); | ||
| 118 | |||
| 119 | yield* Effect.logInfo("provider.session.reaper.started", { | ||
| 120 | inactivityThresholdMs, | ||
| 121 | sweepIntervalMs, | ||
| 122 | }); | ||
| 123 | }); | ||
| 124 | |||
| 125 | return { | ||
| 126 | start, | ||
| 127 | } satisfies ProviderSessionReaperShape; | ||
| 128 | }); | ||
| 129 | |||
| 130 | export const makeProviderSessionReaperLive = (options?: ProviderSessionReaperLiveOptions) => | ||
| 131 | Layer.effect(ProviderSessionReaper, makeProviderSessionReaper(options)); | ||
| 132 | |||
| 133 | export const ProviderSessionReaperLive = makeProviderSessionReaperLive(); | ||
apps/server/src/provider/Services/ProviderSessionDirectory.ts+9| ... | @@ -22,6 +22,10 @@ export interface ProviderRuntimeBinding { | ... | @@ -22,6 +22,10 @@ export interface ProviderRuntimeBinding { |
| 22 | readonly runtimeMode?: RuntimeMode; | 22 | readonly runtimeMode?: RuntimeMode; |
| 23 | } | 23 | } |
| 24 | 24 | ||
| 25 | export interface ProviderRuntimeBindingWithMetadata extends ProviderRuntimeBinding { | ||
| 26 | readonly lastSeenAt: string; | ||
| 27 | } | ||
| 28 | |||
| 25 | export type ProviderSessionDirectoryReadError = ProviderSessionDirectoryPersistenceError; | 29 | export type ProviderSessionDirectoryReadError = ProviderSessionDirectoryPersistenceError; |
| 26 | 30 | ||
| 27 | export type ProviderSessionDirectoryWriteError = | 31 | export type ProviderSessionDirectoryWriteError = |
| ... | @@ -49,6 +53,11 @@ export interface ProviderSessionDirectoryShape { | ... | @@ -49,6 +53,11 @@ export interface ProviderSessionDirectoryShape { |
| 49 | ReadonlyArray<ThreadId>, | 53 | ReadonlyArray<ThreadId>, |
| 50 | ProviderSessionDirectoryPersistenceError | 54 | ProviderSessionDirectoryPersistenceError |
| 51 | >; | 55 | >; |
| 56 | |||
| 57 | readonly listBindings: () => Effect.Effect< | ||
| 58 | ReadonlyArray<ProviderRuntimeBindingWithMetadata>, | ||
| 59 | ProviderSessionDirectoryPersistenceError | ||
| 60 | >; | ||
| 52 | } | 61 | } |
| 53 | 62 | ||
| 54 | export class ProviderSessionDirectory extends Context.Service< | 63 | export class ProviderSessionDirectory extends Context.Service< |
apps/server/src/provider/Services/ProviderSessionReaper.ts created+14| ... | @@ -0,0 +1,14 @@ | ||
| 1 | import { Context } from "effect"; | ||
| 2 | import type { Effect, Scope } from "effect"; | ||
| 3 | |||
| 4 | export interface ProviderSessionReaperShape { | ||
| 5 | /** | ||
| 6 | * Start the background provider session reaper within the provided scope. | ||
| 7 | */ | ||
| 8 | readonly start: () => Effect.Effect<void, never, Scope.Scope>; | ||
| 9 | } | ||
| 10 | |||
| 11 | export class ProviderSessionReaper extends Context.Service< | ||
| 12 | ProviderSessionReaper, | ||
| 13 | ProviderSessionReaperShape | ||
| 14 | >()("t3/provider/Services/ProviderSessionReaper") {} | ||
apps/server/src/server.test.ts+415-6| ... | @@ -11,6 +11,7 @@ import { | ... | @@ -11,6 +11,7 @@ import { |
| 11 | KeybindingRule, | 11 | KeybindingRule, |
| 12 | MessageId, | 12 | MessageId, |
| 13 | OpenError, | 13 | OpenError, |
| 14 | type OrchestrationThreadShell, | ||
| 14 | TerminalNotRunningError, | 15 | TerminalNotRunningError, |
| 15 | type OrchestrationCommand, | 16 | type OrchestrationCommand, |
| 16 | type OrchestrationEvent, | 17 | type OrchestrationEvent, |
| ... | @@ -166,6 +167,32 @@ const makeDefaultOrchestrationReadModel = () => { | ... | @@ -166,6 +167,32 @@ const makeDefaultOrchestrationReadModel = () => { |
| 166 | }; | 167 | }; |
| 167 | }; | 168 | }; |
| 168 | 169 | ||
| 170 | const makeDefaultOrchestrationThreadShell = ( | ||
| 171 | overrides: Partial<OrchestrationThreadShell> = {}, | ||
| 172 | ): OrchestrationThreadShell => { | ||
| 173 | const now = new Date().toISOString(); | ||
| 174 | return { | ||
| 175 | id: defaultThreadId, | ||
| 176 | projectId: defaultProjectId, | ||
| 177 | title: "Default Thread", | ||
| 178 | modelSelection: defaultModelSelection, | ||
| 179 | runtimeMode: "full-access", | ||
| 180 | interactionMode: "default", | ||
| 181 | branch: null, | ||
| 182 | worktreePath: null, | ||
| 183 | latestTurn: null, | ||
| 184 | createdAt: now, | ||
| 185 | updatedAt: now, | ||
| 186 | archivedAt: null, | ||
| 187 | session: null, | ||
| 188 | latestUserMessageAt: null, | ||
| 189 | hasPendingApprovals: false, | ||
| 190 | hasPendingUserInput: false, | ||
| 191 | hasActionableProposedPlan: false, | ||
| 192 | ...overrides, | ||
| 193 | }; | ||
| 194 | }; | ||
| 195 | |||
| 169 | const workspaceAndProjectServicesLayer = Layer.mergeAll( | 196 | const workspaceAndProjectServicesLayer = Layer.mergeAll( |
| 170 | WorkspacePathsLive, | 197 | WorkspacePathsLive, |
| 171 | WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive)), | 198 | WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive)), |
| ... | @@ -2945,21 +2972,48 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2945,21 +2972,48 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2945 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 2972 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 2946 | ); | 2973 | ); |
| 2947 | 2974 | ||
| 2948 | it.effect("closes thread terminals after a successful archive command", () => | 2975 | it.effect("stops the provider session and closes thread terminals after archive", () => |
| 2949 | Effect.gen(function* () { | 2976 | Effect.gen(function* () { |
| 2950 | const threadId = ThreadId.make("thread-archive"); | 2977 | const threadId = ThreadId.make("thread-archive"); |
| 2951 | const closeInputs: Array<Parameters<TerminalManagerShape["close"]>[0]> = []; | 2978 | const effects: string[] = []; |
| 2979 | const dispatchedCommands: Array<OrchestrationCommand> = []; | ||
| 2980 | const now = new Date().toISOString(); | ||
| 2952 | 2981 | ||
| 2953 | yield* buildAppUnderTest({ | 2982 | yield* buildAppUnderTest({ |
| 2954 | layers: { | 2983 | layers: { |
| 2955 | terminalManager: { | 2984 | terminalManager: { |
| 2956 | close: (input) => | 2985 | close: (input) => |
| 2957 | Effect.sync(() => { | 2986 | Effect.sync(() => { |
| 2958 | closeInputs.push(input); | 2987 | effects.push(`terminal.close:${input.threadId}`); |
| 2959 | }), | 2988 | }), |
| 2960 | }, | 2989 | }, |
| 2961 | orchestrationEngine: { | 2990 | orchestrationEngine: { |
| 2962 | dispatch: () => Effect.succeed({ sequence: 8 }), | 2991 | dispatch: (command) => |
| 2992 | Effect.sync(() => { | ||
| 2993 | dispatchedCommands.push(command); | ||
| 2994 | effects.push(`dispatch:${command.type}`); | ||
| 2995 | return { sequence: dispatchedCommands.length }; | ||
| 2996 | }), | ||
| 2997 | }, | ||
| 2998 | projectionSnapshotQuery: { | ||
| 2999 | getThreadShellById: () => | ||
| 3000 | Effect.succeed( | ||
| 3001 | Option.some( | ||
| 3002 | makeDefaultOrchestrationThreadShell({ | ||
| 3003 | id: threadId, | ||
| 3004 | updatedAt: now, | ||
| 3005 | session: { | ||
| 3006 | threadId, | ||
| 3007 | status: "ready", | ||
| 3008 | providerName: "claudeAgent", | ||
| 3009 | runtimeMode: "full-access", | ||
| 3010 | activeTurnId: null, | ||
| 3011 | lastError: null, | ||
| 3012 | updatedAt: now, | ||
| 3013 | }, | ||
| 3014 | }), | ||
| 3015 | ), | ||
| 3016 | ), | ||
| 2963 | }, | 3017 | }, |
| 2964 | }, | 3018 | }, |
| 2965 | }); | 3019 | }); |
| ... | @@ -2975,8 +3029,363 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2975,8 +3029,363 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2975 | ), | 3029 | ), |
| 2976 | ); | 3030 | ); |
| 2977 | 3031 | ||
| 2978 | assert.equal(dispatchResult.sequence, 8); | 3032 | assert.equal(dispatchResult.sequence, 1); |
| 2979 | assert.deepEqual(closeInputs, [{ threadId }]); | 3033 | assert.deepEqual(effects, [ |
| 3034 | "dispatch:thread.archive", | ||
| 3035 | "dispatch:thread.session.stop", | ||
| 3036 | `terminal.close:${threadId}`, | ||
| 3037 | ]); | ||
| 3038 | const sessionStopCommand = dispatchedCommands[1]; | ||
| 3039 | assert.equal(sessionStopCommand?.type, "thread.session.stop"); | ||
| 3040 | if (sessionStopCommand?.type === "thread.session.stop") { | ||
| 3041 | assert.equal(sessionStopCommand.threadId, threadId); | ||
| 3042 | } | ||
| 3043 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 3044 | ); | ||
| 3045 | |||
| 3046 | it.effect("checks session status before archiving removes the thread from active lookups", () => | ||
| 3047 | Effect.gen(function* () { | ||
| 3048 | const threadId = ThreadId.make("thread-archive-precheck"); | ||
| 3049 | const effects: string[] = []; | ||
| 3050 | const dispatchedCommands: Array<OrchestrationCommand> = []; | ||
| 3051 | const now = new Date().toISOString(); | ||
| 3052 | let archived = false; | ||
| 3053 | |||
| 3054 | yield* buildAppUnderTest({ | ||
| 3055 | layers: { | ||
| 3056 | terminalManager: { | ||
| 3057 | close: (input) => | ||
| 3058 | Effect.sync(() => { | ||
| 3059 | effects.push(`terminal.close:${input.threadId}`); | ||
| 3060 | }), | ||
| 3061 | }, | ||
| 3062 | orchestrationEngine: { | ||
| 3063 | dispatch: (command) => | ||
| 3064 | Effect.sync(() => { | ||
| 3065 | dispatchedCommands.push(command); | ||
| 3066 | effects.push(`dispatch:${command.type}`); | ||
| 3067 | if (command.type === "thread.archive") { | ||
| 3068 | archived = true; | ||
| 3069 | } | ||
| 3070 | return { sequence: dispatchedCommands.length }; | ||
| 3071 | }), | ||
| 3072 | }, | ||
| 3073 | projectionSnapshotQuery: { | ||
| 3074 | getThreadShellById: () => | ||
| 3075 | Effect.sync(() => { | ||
| 3076 | effects.push(`query:thread-shell:${archived ? "archived" : "active"}`); | ||
| 3077 | return archived | ||
| 3078 | ? Option.none() | ||
| 3079 | : Option.some( | ||
| 3080 | makeDefaultOrchestrationThreadShell({ | ||
| 3081 | id: threadId, | ||
| 3082 | updatedAt: now, | ||
| 3083 | session: { | ||
| 3084 | threadId, | ||
| 3085 | status: "ready", | ||
| 3086 | providerName: "claudeAgent", | ||
| 3087 | runtimeMode: "full-access", | ||
| 3088 | activeTurnId: null, | ||
| 3089 | lastError: null, | ||
| 3090 | updatedAt: now, | ||
| 3091 | }, | ||
| 3092 | }), | ||
| 3093 | ); | ||
| 3094 | }), | ||
| 3095 | }, | ||
| 3096 | }, | ||
| 3097 | }); | ||
| 3098 | |||
| 3099 | const wsUrl = yield* getWsServerUrl("/ws"); | ||
| 3100 | const dispatchResult = yield* Effect.scoped( | ||
| 3101 | withWsRpcClient(wsUrl, (client) => | ||
| 3102 | client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ | ||
| 3103 | type: "thread.archive", | ||
| 3104 | commandId: CommandId.make("cmd-thread-archive-precheck"), | ||
| 3105 | threadId, | ||
| 3106 | }), | ||
| 3107 | ), | ||
| 3108 | ); | ||
| 3109 | |||
| 3110 | assert.equal(dispatchResult.sequence, 1); | ||
| 3111 | assert.deepEqual(effects, [ | ||
| 3112 | "query:thread-shell:active", | ||
| 3113 | "dispatch:thread.archive", | ||
| 3114 | "dispatch:thread.session.stop", | ||
| 3115 | `terminal.close:${threadId}`, | ||
| 3116 | ]); | ||
| 3117 | assert.deepEqual( | ||
| 3118 | dispatchedCommands.map((command) => command.type), | ||
| 3119 | ["thread.archive", "thread.session.stop"], | ||
| 3120 | ); | ||
| 3121 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 3122 | ); | ||
| 3123 | |||
| 3124 | it.effect("archives without dispatching session stop when the thread has no session", () => | ||
| 3125 | Effect.gen(function* () { | ||
| 3126 | const threadId = ThreadId.make("thread-archive-no-session"); | ||
| 3127 | const effects: string[] = []; | ||
| 3128 | const dispatchedCommands: Array<OrchestrationCommand> = []; | ||
| 3129 | |||
| 3130 | yield* buildAppUnderTest({ | ||
| 3131 | layers: { | ||
| 3132 | terminalManager: { | ||
| 3133 | close: (input) => | ||
| 3134 | Effect.sync(() => { | ||
| 3135 | effects.push(`terminal.close:${input.threadId}`); | ||
| 3136 | }), | ||
| 3137 | }, | ||
| 3138 | orchestrationEngine: { | ||
| 3139 | dispatch: (command) => | ||
| 3140 | Effect.sync(() => { | ||
| 3141 | dispatchedCommands.push(command); | ||
| 3142 | effects.push(`dispatch:${command.type}`); | ||
| 3143 | return { sequence: dispatchedCommands.length }; | ||
| 3144 | }), | ||
| 3145 | }, | ||
| 3146 | projectionSnapshotQuery: { | ||
| 3147 | getThreadShellById: () => | ||
| 3148 | Effect.succeed( | ||
| 3149 | Option.some(makeDefaultOrchestrationThreadShell({ id: threadId, session: null })), | ||
| 3150 | ), | ||
| 3151 | }, | ||
| 3152 | }, | ||
| 3153 | }); | ||
| 3154 | |||
| 3155 | const wsUrl = yield* getWsServerUrl("/ws"); | ||
| 3156 | const dispatchResult = yield* Effect.scoped( | ||
| 3157 | withWsRpcClient(wsUrl, (client) => | ||
| 3158 | client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ | ||
| 3159 | type: "thread.archive", | ||
| 3160 | commandId: CommandId.make("cmd-thread-archive-no-session"), | ||
| 3161 | threadId, | ||
| 3162 | }), | ||
| 3163 | ), | ||
| 3164 | ); | ||
| 3165 | |||
| 3166 | assert.equal(dispatchResult.sequence, 1); | ||
| 3167 | assert.deepEqual(effects, ["dispatch:thread.archive", `terminal.close:${threadId}`]); | ||
| 3168 | assert.deepEqual( | ||
| 3169 | dispatchedCommands.map((command) => command.type), | ||
| 3170 | ["thread.archive"], | ||
| 3171 | ); | ||
| 3172 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 3173 | ); | ||
| 3174 | |||
| 3175 | it.effect( | ||
| 3176 | "archives without dispatching session stop when the thread session is already stopped", | ||
| 3177 | () => | ||
| 3178 | Effect.gen(function* () { | ||
| 3179 | const threadId = ThreadId.make("thread-archive-stopped-session"); | ||
| 3180 | const effects: string[] = []; | ||
| 3181 | const dispatchedCommands: Array<OrchestrationCommand> = []; | ||
| 3182 | const now = new Date().toISOString(); | ||
| 3183 | |||
| 3184 | yield* buildAppUnderTest({ | ||
| 3185 | layers: { | ||
| 3186 | terminalManager: { | ||
| 3187 | close: (input) => | ||
| 3188 | Effect.sync(() => { | ||
| 3189 | effects.push(`terminal.close:${input.threadId}`); | ||
| 3190 | }), | ||
| 3191 | }, | ||
| 3192 | orchestrationEngine: { | ||
| 3193 | dispatch: (command) => | ||
| 3194 | Effect.sync(() => { | ||
| 3195 | dispatchedCommands.push(command); | ||
| 3196 | effects.push(`dispatch:${command.type}`); | ||
| 3197 | return { sequence: dispatchedCommands.length }; | ||
| 3198 | }), | ||
| 3199 | }, | ||
| 3200 | projectionSnapshotQuery: { | ||
| 3201 | getThreadShellById: () => | ||
| 3202 | Effect.succeed( | ||
| 3203 | Option.some( | ||
| 3204 | makeDefaultOrchestrationThreadShell({ | ||
| 3205 | id: threadId, | ||
| 3206 | updatedAt: now, | ||
| 3207 | session: { | ||
| 3208 | threadId, | ||
| 3209 | status: "stopped", | ||
| 3210 | providerName: "claudeAgent", | ||
| 3211 | runtimeMode: "full-access", | ||
| 3212 | activeTurnId: null, | ||
| 3213 | lastError: null, | ||
| 3214 | updatedAt: now, | ||
| 3215 | }, | ||
| 3216 | }), | ||
| 3217 | ), | ||
| 3218 | ), | ||
| 3219 | }, | ||
| 3220 | }, | ||
| 3221 | }); | ||
| 3222 | |||
| 3223 | const wsUrl = yield* getWsServerUrl("/ws"); | ||
| 3224 | const dispatchResult = yield* Effect.scoped( | ||
| 3225 | withWsRpcClient(wsUrl, (client) => | ||
| 3226 | client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ | ||
| 3227 | type: "thread.archive", | ||
| 3228 | commandId: CommandId.make("cmd-thread-archive-stopped-session"), | ||
| 3229 | threadId, | ||
| 3230 | }), | ||
| 3231 | ), | ||
| 3232 | ); | ||
| 3233 | |||
| 3234 | assert.equal(dispatchResult.sequence, 1); | ||
| 3235 | assert.deepEqual(effects, ["dispatch:thread.archive", `terminal.close:${threadId}`]); | ||
| 3236 | assert.deepEqual( | ||
| 3237 | dispatchedCommands.map((command) => command.type), | ||
| 3238 | ["thread.archive"], | ||
| 3239 | ); | ||
| 3240 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 3241 | ); | ||
| 3242 | |||
| 3243 | it.effect("archives and still closes terminals when session stop fails", () => | ||
| 3244 | Effect.gen(function* () { | ||
| 3245 | const threadId = ThreadId.make("thread-archive-stop-failure"); | ||
| 3246 | const effects: string[] = []; | ||
| 3247 | const dispatchedCommands: Array<OrchestrationCommand> = []; | ||
| 3248 | const now = new Date().toISOString(); | ||
| 3249 | |||
| 3250 | yield* buildAppUnderTest({ | ||
| 3251 | layers: { | ||
| 3252 | terminalManager: { | ||
| 3253 | close: (input) => | ||
| 3254 | Effect.sync(() => { | ||
| 3255 | effects.push(`terminal.close:${input.threadId}`); | ||
| 3256 | }), | ||
| 3257 | }, | ||
| 3258 | orchestrationEngine: { | ||
| 3259 | dispatch: (command) => { | ||
| 3260 | dispatchedCommands.push(command); | ||
| 3261 | effects.push(`dispatch:${command.type}`); | ||
| 3262 | if (command.type === "thread.session.stop") { | ||
| 3263 | return Effect.fail( | ||
| 3264 | new OrchestrationListenerCallbackError({ | ||
| 3265 | listener: "domain-event", | ||
| 3266 | detail: "simulated archive stop failure", | ||
| 3267 | }), | ||
| 3268 | ); | ||
| 3269 | } | ||
| 3270 | return Effect.succeed({ sequence: dispatchedCommands.length }); | ||
| 3271 | }, | ||
| 3272 | }, | ||
| 3273 | projectionSnapshotQuery: { | ||
| 3274 | getThreadShellById: () => | ||
| 3275 | Effect.succeed( | ||
| 3276 | Option.some( | ||
| 3277 | makeDefaultOrchestrationThreadShell({ | ||
| 3278 | id: threadId, | ||
| 3279 | updatedAt: now, | ||
| 3280 | session: { | ||
| 3281 | threadId, | ||
| 3282 | status: "ready", | ||
| 3283 | providerName: "claudeAgent", | ||
| 3284 | runtimeMode: "full-access", | ||
| 3285 | activeTurnId: null, | ||
| 3286 | lastError: null, | ||
| 3287 | updatedAt: now, | ||
| 3288 | }, | ||
| 3289 | }), | ||
| 3290 | ), | ||
| 3291 | ), | ||
| 3292 | }, | ||
| 3293 | }, | ||
| 3294 | }); | ||
| 3295 | |||
| 3296 | const wsUrl = yield* getWsServerUrl("/ws"); | ||
| 3297 | const dispatchResult = yield* Effect.scoped( | ||
| 3298 | withWsRpcClient(wsUrl, (client) => | ||
| 3299 | client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ | ||
| 3300 | type: "thread.archive", | ||
| 3301 | commandId: CommandId.make("cmd-thread-archive-stop-failure"), | ||
| 3302 | threadId, | ||
| 3303 | }), | ||
| 3304 | ), | ||
| 3305 | ); | ||
| 3306 | |||
| 3307 | assert.equal(dispatchResult.sequence, 1); | ||
| 3308 | assert.deepEqual(effects, [ | ||
| 3309 | "dispatch:thread.archive", | ||
| 3310 | "dispatch:thread.session.stop", | ||
| 3311 | `terminal.close:${threadId}`, | ||
| 3312 | ]); | ||
| 3313 | assert.deepEqual( | ||
| 3314 | dispatchedCommands.map((command) => command.type), | ||
| 3315 | ["thread.archive", "thread.session.stop"], | ||
| 3316 | ); | ||
| 3317 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 3318 | ); | ||
| 3319 | |||
| 3320 | it.effect("archives and still closes terminals when session stop defects", () => | ||
| 3321 | Effect.gen(function* () { | ||
| 3322 | const threadId = ThreadId.make("thread-archive-stop-defect"); | ||
| 3323 | const effects: string[] = []; | ||
| 3324 | const dispatchedCommands: Array<OrchestrationCommand> = []; | ||
| 3325 | const now = new Date().toISOString(); | ||
| 3326 | |||
| 3327 | yield* buildAppUnderTest({ | ||
| 3328 | layers: { | ||
| 3329 | terminalManager: { | ||
| 3330 | close: (input) => | ||
| 3331 | Effect.sync(() => { | ||
| 3332 | effects.push(`terminal.close:${input.threadId}`); | ||
| 3333 | }), | ||
| 3334 | }, | ||
| 3335 | orchestrationEngine: { | ||
| 3336 | dispatch: (command) => { | ||
| 3337 | dispatchedCommands.push(command); | ||
| 3338 | effects.push(`dispatch:${command.type}`); | ||
| 3339 | if (command.type === "thread.session.stop") { | ||
| 3340 | return Effect.die(new Error("simulated archive stop defect")); | ||
| 3341 | } | ||
| 3342 | return Effect.succeed({ sequence: dispatchedCommands.length }); | ||
| 3343 | }, | ||
| 3344 | }, | ||
| 3345 | projectionSnapshotQuery: { | ||
| 3346 | getThreadShellById: () => | ||
| 3347 | Effect.succeed( | ||
| 3348 | Option.some( | ||
| 3349 | makeDefaultOrchestrationThreadShell({ | ||
| 3350 | id: threadId, | ||
| 3351 | updatedAt: now, | ||
| 3352 | session: { | ||
| 3353 | threadId, | ||
| 3354 | status: "ready", | ||
| 3355 | providerName: "claudeAgent", | ||
| 3356 | runtimeMode: "full-access", | ||
| 3357 | activeTurnId: null, | ||
| 3358 | lastError: null, | ||
| 3359 | updatedAt: now, | ||
| 3360 | }, | ||
| 3361 | }), | ||
| 3362 | ), | ||
| 3363 | ), | ||
| 3364 | }, | ||
| 3365 | }, | ||
| 3366 | }); | ||
| 3367 | |||
| 3368 | const wsUrl = yield* getWsServerUrl("/ws"); | ||
| 3369 | const dispatchResult = yield* Effect.scoped( | ||
| 3370 | withWsRpcClient(wsUrl, (client) => | ||
| 3371 | client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ | ||
| 3372 | type: "thread.archive", | ||
| 3373 | commandId: CommandId.make("cmd-thread-archive-stop-defect"), | ||
| 3374 | threadId, | ||
| 3375 | }), | ||
| 3376 | ), | ||
| 3377 | ); | ||
| 3378 | |||
| 3379 | assert.equal(dispatchResult.sequence, 1); | ||
| 3380 | assert.deepEqual(effects, [ | ||
| 3381 | "dispatch:thread.archive", | ||
| 3382 | "dispatch:thread.session.stop", | ||
| 3383 | `terminal.close:${threadId}`, | ||
| 3384 | ]); | ||
| 3385 | assert.deepEqual( | ||
| 3386 | dispatchedCommands.map((command) => command.type), | ||
| 3387 | ["thread.archive", "thread.session.stop"], | ||
| 3388 | ); | ||
| 2980 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 3389 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 2981 | ); | 3390 | ); |
| 2982 | 3391 |
apps/server/src/server.ts+16-7| ... | @@ -23,6 +23,7 @@ import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; | ... | @@ -23,6 +23,7 @@ import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; |
| 23 | import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter"; | 23 | import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter"; |
| 24 | import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; | 24 | import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; |
| 25 | import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; | 25 | import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; |
| 26 | import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper"; | ||
| 26 | import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery"; | 27 | import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery"; |
| 27 | import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; | 28 | import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; |
| 28 | import { GitCoreLive } from "./git/Layers/GitCore"; | 29 | import { GitCoreLive } from "./git/Layers/GitCore"; |
| ... | @@ -134,6 +135,10 @@ const CheckpointingLayerLive = Layer.empty.pipe( | ... | @@ -134,6 +135,10 @@ const CheckpointingLayerLive = Layer.empty.pipe( |
| 134 | Layer.provideMerge(CheckpointStoreLive), | 135 | Layer.provideMerge(CheckpointStoreLive), |
| 135 | ); | 136 | ); |
| 136 | 137 | ||
| 138 | const ProviderSessionDirectoryLayerLive = ProviderSessionDirectoryLive.pipe( | ||
| 139 | Layer.provide(ProviderSessionRuntimeRepositoryLive), | ||
| 140 | ); | ||
| 141 | |||
| 137 | const ProviderLayerLive = Layer.unwrap( | 142 | const ProviderLayerLive = Layer.unwrap( |
| 138 | Effect.gen(function* () { | 143 | Effect.gen(function* () { |
| 139 | const { providerEventLogPath } = yield* ServerConfig; | 144 | const { providerEventLogPath } = yield* ServerConfig; |
| ... | @@ -143,9 +148,6 @@ const ProviderLayerLive = Layer.unwrap( | ... | @@ -143,9 +148,6 @@ const ProviderLayerLive = Layer.unwrap( |
| 143 | const canonicalEventLogger = yield* makeEventNdjsonLogger(providerEventLogPath, { | 148 | const canonicalEventLogger = yield* makeEventNdjsonLogger(providerEventLogPath, { |
| 144 | stream: "canonical", | 149 | stream: "canonical", |
| 145 | }); | 150 | }); |
| 146 | const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( | ||
| 147 | Layer.provide(ProviderSessionRuntimeRepositoryLive), | ||
| 148 | ); | ||
| 149 | const codexAdapterLayer = makeCodexAdapterLive( | 151 | const codexAdapterLayer = makeCodexAdapterLive( |
| 150 | nativeEventLogger ? { nativeEventLogger } : undefined, | 152 | nativeEventLogger ? { nativeEventLogger } : undefined, |
| 151 | ); | 153 | ); |
| ... | @@ -155,11 +157,14 @@ const ProviderLayerLive = Layer.unwrap( | ... | @@ -155,11 +157,14 @@ const ProviderLayerLive = Layer.unwrap( |
| 155 | const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( | 157 | const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( |
| 156 | Layer.provide(codexAdapterLayer), | 158 | Layer.provide(codexAdapterLayer), |
| 157 | Layer.provide(claudeAdapterLayer), | 159 | Layer.provide(claudeAdapterLayer), |
| 158 | Layer.provideMerge(providerSessionDirectoryLayer), | 160 | Layer.provideMerge(ProviderSessionDirectoryLayerLive), |
| 159 | ); | 161 | ); |
| 160 | return makeProviderServiceLive( | 162 | return makeProviderServiceLive( |
| 161 | canonicalEventLogger ? { canonicalEventLogger } : undefined, | 163 | canonicalEventLogger ? { canonicalEventLogger } : undefined, |
| 162 | ).pipe(Layer.provide(adapterRegistryLayer), Layer.provide(providerSessionDirectoryLayer)); | 164 | ).pipe( |
| 165 | Layer.provide(adapterRegistryLayer), | ||
| 166 | Layer.provideMerge(ProviderSessionDirectoryLayerLive), | ||
| 167 | ); | ||
| 163 | }), | 168 | }), |
| 164 | ); | 169 | ); |
| 165 | 170 | ||
| ... | @@ -194,12 +199,16 @@ const AuthLayerLive = ServerAuthLive.pipe( | ... | @@ -194,12 +199,16 @@ const AuthLayerLive = ServerAuthLive.pipe( |
| 194 | Layer.provide(ServerSecretStoreLive), | 199 | Layer.provide(ServerSecretStoreLive), |
| 195 | ); | 200 | ); |
| 196 | 201 | ||
| 202 | const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( | ||
| 203 | Layer.provideMerge(ProviderLayerLive), | ||
| 204 | Layer.provideMerge(OrchestrationLayerLive), | ||
| 205 | ); | ||
| 206 | |||
| 197 | const RuntimeDependenciesLive = ReactorLayerLive.pipe( | 207 | const RuntimeDependenciesLive = ReactorLayerLive.pipe( |
| 198 | // Core Services | 208 | // Core Services |
| 199 | Layer.provideMerge(CheckpointingLayerLive), | 209 | Layer.provideMerge(CheckpointingLayerLive), |
| 200 | Layer.provideMerge(GitLayerLive), | 210 | Layer.provideMerge(GitLayerLive), |
| 201 | Layer.provideMerge(OrchestrationLayerLive), | 211 | Layer.provideMerge(ProviderRuntimeLayerLive), |
| 202 | Layer.provideMerge(ProviderLayerLive), | ||
| 203 | Layer.provideMerge(TerminalLayerLive), | 212 | Layer.provideMerge(TerminalLayerLive), |
| 204 | Layer.provideMerge(PersistenceLayerLive), | 213 | Layer.provideMerge(PersistenceLayerLive), |
| 205 | Layer.provideMerge(KeybindingsLive), | 214 | Layer.provideMerge(KeybindingsLive), |
apps/server/src/serverRuntimeStartup.ts+6-1| ... | @@ -32,6 +32,7 @@ import { ServerSettingsService } from "./serverSettings"; | ... | @@ -32,6 +32,7 @@ import { ServerSettingsService } from "./serverSettings"; |
| 32 | import { ServerEnvironment } from "./environment/Services/ServerEnvironment"; | 32 | import { ServerEnvironment } from "./environment/Services/ServerEnvironment"; |
| 33 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; | 33 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; |
| 34 | import { ServerAuth } from "./auth/Services/ServerAuth"; | 34 | import { ServerAuth } from "./auth/Services/ServerAuth"; |
| 35 | import { ProviderSessionReaper } from "./provider/Services/ProviderSessionReaper"; | ||
| 35 | import { | 36 | import { |
| 36 | formatHeadlessServeOutput, | 37 | formatHeadlessServeOutput, |
| 37 | formatHostForUrl, | 38 | formatHostForUrl, |
| ... | @@ -281,6 +282,7 @@ export const makeServerRuntimeStartup = Effect.gen(function* () { | ... | @@ -281,6 +282,7 @@ export const makeServerRuntimeStartup = Effect.gen(function* () { |
| 281 | const serverConfig = yield* ServerConfig; | 282 | const serverConfig = yield* ServerConfig; |
| 282 | const keybindings = yield* Keybindings; | 283 | const keybindings = yield* Keybindings; |
| 283 | const orchestrationReactor = yield* OrchestrationReactor; | 284 | const orchestrationReactor = yield* OrchestrationReactor; |
| 285 | const providerSessionReaper = yield* ProviderSessionReaper; | ||
| 284 | const lifecycleEvents = yield* ServerLifecycleEvents; | 286 | const lifecycleEvents = yield* ServerLifecycleEvents; |
| 285 | const serverSettings = yield* ServerSettingsService; | 287 | const serverSettings = yield* ServerSettingsService; |
| 286 | const serverEnvironment = yield* ServerEnvironment; | 288 | const serverEnvironment = yield* ServerEnvironment; |
| ... | @@ -325,7 +327,10 @@ export const makeServerRuntimeStartup = Effect.gen(function* () { | ... | @@ -325,7 +327,10 @@ export const makeServerRuntimeStartup = Effect.gen(function* () { |
| 325 | yield* Effect.logDebug("startup phase: starting orchestration reactors"); | 327 | yield* Effect.logDebug("startup phase: starting orchestration reactors"); |
| 326 | yield* runStartupPhase( | 328 | yield* runStartupPhase( |
| 327 | "reactors.start", | 329 | "reactors.start", |
| 328 | orchestrationReactor.start().pipe(Scope.provide(reactorScope)), | 330 | Effect.gen(function* () { |
| 331 | yield* orchestrationReactor.start().pipe(Scope.provide(reactorScope)); | ||
| 332 | yield* providerSessionReaper.start().pipe(Scope.provide(reactorScope)); | ||
| 333 | }), | ||
| 329 | ); | 334 | ); |
| 330 | 335 | ||
| 331 | const welcomeBase = yield* resolveWelcomeBase; | 336 | const welcomeBase = yield* resolveWelcomeBase; |
apps/server/src/ws.ts+37| ... | @@ -550,8 +550,45 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => | ... | @@ -550,8 +550,45 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 550 | ORCHESTRATION_WS_METHODS.dispatchCommand, | 550 | ORCHESTRATION_WS_METHODS.dispatchCommand, |
| 551 | Effect.gen(function* () { | 551 | Effect.gen(function* () { |
| 552 | const normalizedCommand = yield* normalizeDispatchCommand(command); | 552 | const normalizedCommand = yield* normalizeDispatchCommand(command); |
| 553 | const shouldStopSessionAfterArchive = | ||
| 554 | normalizedCommand.type === "thread.archive" | ||
| 555 | ? yield* projectionSnapshotQuery | ||
| 556 | .getThreadShellById(normalizedCommand.threadId) | ||
| 557 | .pipe( | ||
| 558 | Effect.map( | ||
| 559 | Option.match({ | ||
| 560 | onNone: () => false, | ||
| 561 | onSome: (thread) => | ||
| 562 | thread.session !== null && thread.session.status !== "stopped", | ||
| 563 | }), | ||
| 564 | ), | ||
| 565 | Effect.catch(() => Effect.succeed(false)), | ||
| 566 | ) | ||
| 567 | : false; | ||
| 553 | const result = yield* dispatchNormalizedCommand(normalizedCommand); | 568 | const result = yield* dispatchNormalizedCommand(normalizedCommand); |
| 554 | if (normalizedCommand.type === "thread.archive") { | 569 | if (normalizedCommand.type === "thread.archive") { |
| 570 | if (shouldStopSessionAfterArchive) { | ||
| 571 | yield* Effect.gen(function* () { | ||
| 572 | const stopCommand = yield* normalizeDispatchCommand({ | ||
| 573 | type: "thread.session.stop", | ||
| 574 | commandId: CommandId.make( | ||
| 575 | `session-stop-for-archive:${normalizedCommand.commandId}`, | ||
| 576 | ), | ||
| 577 | threadId: normalizedCommand.threadId, | ||
| 578 | createdAt: new Date().toISOString(), | ||
| 579 | }); | ||
| 580 | |||
| 581 | yield* dispatchNormalizedCommand(stopCommand); | ||
| 582 | }).pipe( | ||
| 583 | Effect.catchCause((cause) => | ||
| 584 | Effect.logWarning("failed to stop provider session during archive", { | ||
| 585 | threadId: normalizedCommand.threadId, | ||
| 586 | cause, | ||
| 587 | }), | ||
| 588 | ), | ||
| 589 | ); | ||
| 590 | } | ||
| 591 | |||
| 555 | yield* terminalManager.close({ threadId: normalizedCommand.threadId }).pipe( | 592 | yield* terminalManager.close({ threadId: normalizedCommand.threadId }).pipe( |
| 556 | Effect.catch((error) => | 593 | Effect.catch((error) => |
| 557 | Effect.logWarning("failed to close thread terminals after archive", { | 594 | Effect.logWarning("failed to close thread terminals after archive", { |
package.json+1| ... | @@ -40,6 +40,7 @@ | ... | @@ -40,6 +40,7 @@ |
| 40 | "typecheck": "turbo run typecheck", | 40 | "typecheck": "turbo run typecheck", |
| 41 | "lint": "oxlint --report-unused-disable-directives", | 41 | "lint": "oxlint --report-unused-disable-directives", |
| 42 | "test": "turbo run test", | 42 | "test": "turbo run test", |
| 43 | "test:process-reaper": "bun run --cwd apps/server test:process-reaper", | ||
| 43 | "test:desktop-smoke": "turbo run smoke-test --filter=@t3tools/desktop", | 44 | "test:desktop-smoke": "turbo run smoke-test --filter=@t3tools/desktop", |
| 44 | "fmt": "oxfmt", | 45 | "fmt": "oxfmt", |
| 45 | "fmt:check": "oxfmt --check", | 46 | "fmt:check": "oxfmt --check", |