authorgravatar for 105945231+crafael23@users.noreply.github.comClaudio Vasquez <105945231+crafael23@users.noreply.github.com> 2026-04-16 17:30:56-06:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-16 16:30:56-07:00
loge0117b271a79f66d0ac793da334991e7a2ccb800
treef3c00351452bb1fe1bbf950436543b97dfefac3f
parent188a40c315bceaeec6237f05ae253941ad6bb4dc
signature Signed by PGP key B5690EEEBB952194

Fix Claude Process leak[MEMORY INTENSIVE], archiving, and stale claude session monitoring. (#2042)

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});
474622
475describe("sendTurn", () => {623describe("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;
451451
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();
454473
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 }
903922
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;
905937
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 }
927961
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 });
13091309
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}
472472
473type PlanStep = { step: string; status: "pending" | "inProgress" | "completed" };473type PlanStep = {
474 step: string;
475 status: "pending" | "inProgress" | "completed";
476};
474477
475function extractPlanStepsFromTodoInput(input: Record<string, unknown>): PlanStep[] | null {478function 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);
977984
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.usedTokens1414 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?.block1604 ...(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 > 02260 ...(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 }
24422479
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);
24812542
...@@ -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 });
25262594
...@@ -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 });
25392609
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.suggestions2795 ...(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});
150151
151const validationManager = new FakeCodexManager();152const 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 );
656656
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 });
299299
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 }
353387
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 }));
135135
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 @@
1import { type ProviderKind, type ThreadId } from "@t3tools/contracts";1import { type ProviderKind, type ThreadId } from "@t3tools/contracts";
2import { Effect, Layer, Option } from "effect";2import { Effect, Layer, Option } from "effect";
33
4import type { ProviderSessionRuntime } from "../../persistence/Services/ProviderSessionRuntime.ts";
4import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts";5import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts";
5import { ProviderSessionDirectoryPersistenceError, ProviderValidationError } from "../Errors.ts";6import { ProviderSessionDirectoryPersistenceError, ProviderValidationError } from "../Errors.ts";
6import {7import {
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";
1113
...@@ -50,6 +52,27 @@ function mergeRuntimePayload(...@@ -50,6 +52,27 @@ function mergeRuntimePayload(
50 return next;52 return next;
51}53}
5254
55function 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
53const makeProviderSessionDirectory = Effect.gen(function* () {76const makeProviderSessionDirectory = Effect.gen(function* () {
54 const repository = yield* ProviderSessionRuntimeRepository;77 const repository = yield* ProviderSessionRuntimeRepository;
5578
...@@ -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 );
147160
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});
156182
apps/server/src/provider/Layers/ProviderSessionReaper.test.ts created+522
...@@ -0,0 +1,522 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts";
3import { Effect, Exit, Layer, ManagedRuntime, Option, Scope, Stream } from "effect";
4import { afterEach, describe, expect, it, vi } from "vitest";
5
6import {
7 OrchestrationEngineService,
8 type OrchestrationEngineShape,
9} from "../../orchestration/Services/OrchestrationEngine.ts";
10import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
11import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts";
12import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts";
13import { ProviderValidationError } from "../Errors.ts";
14import { ProviderSessionReaper } from "../Services/ProviderSessionReaper.ts";
15import { ProviderService, type ProviderServiceShape } from "../Services/ProviderService.ts";
16import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts";
17import { makeProviderSessionReaperLive } from "./ProviderSessionReaper.ts";
18
19const defaultModelSelection = {
20 provider: "codex",
21 model: "gpt-5-codex",
22} as const;
23
24async 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
43const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never;
44
45function 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
100describe("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 @@
1import { Duration, Effect, Layer, Schedule } from "effect";
2
3import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts";
4import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
5import {
6 ProviderSessionReaper,
7 type ProviderSessionReaperShape,
8} from "../Services/ProviderSessionReaper.ts";
9import { ProviderService } from "../Services/ProviderService.ts";
10
11const DEFAULT_INACTIVITY_THRESHOLD_MS = 30 * 60 * 1000;
12const DEFAULT_SWEEP_INTERVAL_MS = 5 * 60 * 1000;
13
14export interface ProviderSessionReaperLiveOptions {
15 readonly inactivityThresholdMs?: number;
16 readonly sweepIntervalMs?: number;
17}
18
19const 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
130export const makeProviderSessionReaperLive = (options?: ProviderSessionReaperLiveOptions) =>
131 Layer.effect(ProviderSessionReaper, makeProviderSessionReaper(options));
132
133export 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}
2424
25export interface ProviderRuntimeBindingWithMetadata extends ProviderRuntimeBinding {
26 readonly lastSeenAt: string;
27}
28
25export type ProviderSessionDirectoryReadError = ProviderSessionDirectoryPersistenceError;29export type ProviderSessionDirectoryReadError = ProviderSessionDirectoryPersistenceError;
2630
27export type ProviderSessionDirectoryWriteError =31export type ProviderSessionDirectoryWriteError =
...@@ -49,6 +53,11 @@ export interface ProviderSessionDirectoryShape {...@@ -49,6 +53,11 @@ export interface ProviderSessionDirectoryShape {
49 ReadonlyArray<ThreadId>,53 ReadonlyArray<ThreadId>,
50 ProviderSessionDirectoryPersistenceError54 ProviderSessionDirectoryPersistenceError
51 >;55 >;
56
57 readonly listBindings: () => Effect.Effect<
58 ReadonlyArray<ProviderRuntimeBindingWithMetadata>,
59 ProviderSessionDirectoryPersistenceError
60 >;
52}61}
5362
54export class ProviderSessionDirectory extends Context.Service<63export class ProviderSessionDirectory extends Context.Service<
apps/server/src/provider/Services/ProviderSessionReaper.ts created+14
...@@ -0,0 +1,14 @@
1import { Context } from "effect";
2import type { Effect, Scope } from "effect";
3
4export 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
11export 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};
168169
170const 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
169const workspaceAndProjectServicesLayer = Layer.mergeAll(196const 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 );
29472974
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();
29522981
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 );
29773031
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 );
29823391
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";
23import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter";23import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter";
24import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry";24import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry";
25import { makeProviderServiceLive } from "./provider/Layers/ProviderService";25import { makeProviderServiceLive } from "./provider/Layers/ProviderService";
26import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper";
26import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery";27import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery";
27import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore";28import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore";
28import { GitCoreLive } from "./git/Layers/GitCore";29import { 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);
136137
138const ProviderSessionDirectoryLayerLive = ProviderSessionDirectoryLive.pipe(
139 Layer.provide(ProviderSessionRuntimeRepositoryLive),
140);
141
137const ProviderLayerLive = Layer.unwrap(142const 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);
165170
...@@ -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);
196201
202const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe(
203 Layer.provideMerge(ProviderLayerLive),
204 Layer.provideMerge(OrchestrationLayerLive),
205);
206
197const RuntimeDependenciesLive = ReactorLayerLive.pipe(207const RuntimeDependenciesLive = ReactorLayerLive.pipe(
198 // Core Services208 // 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";
32import { ServerEnvironment } from "./environment/Services/ServerEnvironment";32import { ServerEnvironment } from "./environment/Services/ServerEnvironment";
33import { AnalyticsService } from "./telemetry/Services/AnalyticsService";33import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
34import { ServerAuth } from "./auth/Services/ServerAuth";34import { ServerAuth } from "./auth/Services/ServerAuth";
35import { ProviderSessionReaper } from "./provider/Services/ProviderSessionReaper";
35import {36import {
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 );
330335
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",