π€ refactor: Effect Phase 11 PR 4a β staged per-service core layers S1βS3 (History β¦ AIService) + remainder projection - #4054
Merged
Conversation
β¦βS3 + remainder projection Peel the head of the core service graph (History β¦ AIService) into staged Layer.effect adapters composed S1 β S2a β S2b β S3; the remainder and all setter/listener wiring keep running imperatively (buildCoreTail) behind a transitional projection layer until 4b. MemoryMeta and WorkspaceMcpOverrides become explicit core-graph inputs (CLI defaults added); CoreLive replaces CoreProjectionLive in both roots.
Member
Author
|
@codex review |
Member
Author
|
@codex security review |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
|
Codex Review: Didn't find any major issues. What shall we delve into next? Reviewed commit: βΉοΈ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with π. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
This comment has been minimized.
This comment has been minimized.
|
Security review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. βΉοΈ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
github-merge-queue
Bot
removed this pull request from the merge queue due to failed status checks
Sep 2, 2026
This was referenced Sep 2, 2026
asm
pushed a commit
to asm/mux
that referenced
this pull request
Sep 2, 2026
β¦ringLive (imperative core body removed) (coder#4057) ## Summary Effect migration Phase 11, PR 4b of 6 β the second half of PR 4 (4a: #4054). The **remainder of the core service graph now builds as staged per-service Layers too**: `MemoryConsolidationLive` Β· `MCPConfigLive` (S4) β `MCPServerManagerLive` (S5) β `WorkspaceLive` (S6) β `TaskLive` (S7) β `WorkspaceTurnManagerLive` (S8), and the former imperative body's setter/listener wiring is replayed, in its original order, by **`CoreWiringLive`** (`Layer.effectDiscard` over a synchronous body β no finalizers, no forks). `CoreLive = CoreWiringLive βΉ S8`; the transitional `buildCoreTail` / `CoreTailProjectionLive` from 4a are deleted, so `coreServices.ts` is now types only (`CoreServicesOptions`, `CoreOptions`, `CoreServices`). Service classes, constructors, facades and every wiring statement are unchanged; the 4a behavioral wiring tests and the PR 3 identity harness pass unchanged against the fully staged graph. Stacked on PR 1 #4049, PR 2 #4050, PR 3 #4051, PR 4a #4054. Plan: `<details>` at the bottom. ## Implementation - **`di/layers/core.ts`** β six more adapter layers with today's argument lists; `CoreWiringLive` yields every collaborator it needs and then runs the former wiring lines verbatim: registration probe β `bindings.memoryService` β `bindings.mcpServerManager` / `streamManager.setMCPServerManager` / `mcpServerManager.setSecretsResolver` β the `workspaceService.set*` block + `bindings.workspaceHeartbeatService/onWorkflowRunStatusChanged/workflowResultContinuationSender` β `workspaceGoalService.setOnActivityChange/setStreamInterrupter` β `taskService.setWorkspaceTurnManager` / `bindings.taskService/workspaceTurnManager` / `workspaceService.setAgentTaskIntegration` β `registerGoalContinuationConsumer(idleDispatcher, β¦)`. Stage comments record where staging is order-parity rather than dependency (`WorkspaceLive` at S6). - **`coreServices.ts`** β `buildCoreTail`, `CoreGraphHead`, `CoreGraphTail` deleted; all imports type-only. - **`coreServicesRoot.test.ts`** β the throwing-body test now spies `createAgentPluginsMcpProvider` inside `MCPConfigLive` (a nested S4 body) instead of `buildCoreTail`; the wiring test additionally pins the remaining edges (`workspaceService`'s MCP manager / goal service / task integration / memory consolidation / MCP overrides collaborators, `taskService`'s turn manager, and the goal service's activity fan-out and stream interrupter β each exercised through a spy). Red-checked: commenting out a single `CoreWiringLive` line (`setAgentTaskIntegration`) fails it. Every other test is unchanged. ## PR 4 notes (4b) ### DAG (re-derived in 4a, unchanged) and stage placement | Service | Constructor dependencies (tags) | Stage | |---|---|---| | MemoryConsolidationService | Config, Memory, MemoryMeta, History, **AI**, Options, SessionUsage | S4 | | MCPConfigService | Options, Config, **AI** (workspaceMetadataProvider) | S4 (true sibling of Consolidation: neither reads the other) | | MCPServerManager | **MCPConfig**, Config, Options, WorkspaceMcpOverrides | S5 | | WorkspaceService | Config, History, AI, InitState, ExtensionMetadata, BackgroundProcess, SessionUsage, Options, StreamManager, SecretsStore, ProvidersConfigStore | **S6 by order-parity, not dependency** β its constructor needs nothing beyond S3; the MCP manager / consolidation collaborators arrive through `CoreWiringLive` setters. Staged after MCPServerManager to keep the former body's construction order (comment in code) | | TaskService | Config, History, AI, **Workspace**, InitState, SessionUsage, WorkspaceGoal, SecretsStore, TerminalAttention | S7 | | WorkspaceTurnManager | Config, History, AI, Workspace, InitState, **Task**, TerminalAttention, StreamManager | S8 | | `CoreWiringLive` | Config, SecretsStore, Options, WorkspaceMcpOverrides + every core tag it wires | after S8 (`Layer.effectDiscard`) | ### I6 constructor side-effect audit (the six moved here; the 4a table covers the head) | Constructor (layer) | Side effects at construction | On declared args only? | Order-sensitive? | |---|---|---|---| | `MemoryConsolidationService` (S4) | sidecar path only | β | no | | `MCPConfigService` (S4) | asserts/fields | β | no | | `MCPServerManager` (S5) | own unref'd `setInterval(cleanupIdleServers)` | β (self) | no | | `WorkspaceService` (S6) | `backgroundProcessManager.on` Γ5, `aiService.on` Γ6, `initStateManager.on`, `extensionMetadata.setTombstoneClearedListener`, module-global `setWorkflowArchiveAdmissionGuard`, starts `recoverBashMonitorStateAfterRestart()` | β β never touches its setter-provided collaborators (`mcpServerManager`, `memoryConsolidationService`, `workspaceGoalService`, `agentTaskIntegration`, β¦) | listener order on `aiService`/`backgroundProcessManager` vs `TaskService` β preserved, Task depends on Workspace | | `TaskService` (S7) | `aiService.on` Γ3, `new AgentPeerMessageBroker(workspaceService)`, `new GitPatchArtifactService(config)` | β | registered after WorkspaceService's listeners, guaranteed by staging | | `WorkspaceTurnManager` (S8) | `new TaskHandleStore(config)` | β | no | Wiring relocation vs `main`/4a: every wiring statement runs in `CoreWiringLive` after **all** constructors, in the original relative order. The constructors that used to run *between* wiring lines (`WorkspaceService` after W3βW5, `TaskService`/`WorkspaceTurnManager` after W6βW15, `IdleDispatcher` after W16βW19) read none of the wired state at construction (table above; `TaskService`'s `getWorkspaceTurnManager()` call sits inside an event listener, `recoverBashMonitorStateAfterRestart()` suspends on I/O before anything could observe the setters, and the whole graph β construction plus wiring β completes inside one synchronous `runSync` build, exactly as the former body completed synchronously). ### Deviations from the plan - Stage placement keeps the plan's S4β¦S8 order even where the DAG would allow siblings (`WorkspaceService` at S6, see above) β construction-order parity over a shorter graph; recorded in the stage comment. - `CoreWiringLive` is a `Layer.effectDiscard` over an `Effect.gen` body whose only yields are service tags (synchronous); it registers no finalizers and forks nothing (I5). - Test change: the throwing-body spy target moved from `buildCoreTail` (deleted) to `createAgentPluginsMcpProvider` inside `MCPConfigLive` β a stronger probe, since the throw now originates in a nested S4 layer body. ### Pre-review audits (plan Β§3) 1. Interruption posture β no new or moved fiber forks (`CoreWiringLive` body is synchronous; `rg 'fork' src/node/services/di/layers/` β none). 2. Uninterruptible teardown β unchanged. 3. No defect escapes β a throw in a nested layer body (S4) surfaces as the synchronous throw from `makeAppRuntime` (pinned by the updated root test); the desktop root's equivalent is unchanged (`serviceContainer.test.ts`). 4. Spy-seam check β no constructor signature changed; the only test-visible change is the spy target above (`buildCoreTail` had one spy, in `coreServicesRoot.test.ts`). 5. Sync-start β unchanged. 6. I6 β table above. 7. Zero-suspension (I3) β `MemoryConsolidationService` is constructed by `MemoryConsolidationLive` with the same arguments; no lookup/await inserted near its funnels (its class is untouched). ### Re-recorded gate numbers (R7/R8, fully staged graph) Same methodology as 4a/PR 3 (sibling worktrees under one scratch dir, shared `node_modules`, interleaved; `origin/main` = `c24d1db10` (4a merged) vs this branch at `a4154cf19`, rebased afterwards onto `fb68404fc` without touching shared code; host load β 150β190, CPU PSI `some avg60` β 33β36 %). **(a) Typecheck** β `make typecheck`'s command, 5 interleaved pairs: main **11.10 s** (10.49β11.86) vs branch **10.79 s** (10.65β11.24) β β2.8 % (noise). `tsgo --extendedDiagnostics` (3 interleaved runs): renderer types 1 929 976 β 1 931 000 (+0.05 %), instantiations 7 779 050 β 7 780 247 (+0.02 %), check time medians 8.30 β 8.52 s; main project types 1 048 460 β 1 049 239 (+0.07 %), instantiations 4 253 308 β 4 254 490 (+0.03 %), check time 3.69 β 3.67 s. Cumulative over PR 3 β 4a β 4b the compiler-work counts moved < 0.2 %; R8 stays closed. **(b) Startup** β `xum server --no-auth`, fresh `XUM_ROOT`, `script -f`, **10 interleaved pairs** (order alternated), SIGTERM β 1 s after `initialize completed`: | metric | origin/main (4a) | branch (4b) | note | |---|---|---|---| | `[startup] AppRuntime built` ms (median, minβmax) | 15 (14β26) | 23 (17β74) | +β8 ms cold on a heavily loaded host (three branch outliers β₯ 50 ms coincided with load spikes; the cluster is 17β25). In-process construction (`new ServiceContainer`, 3 processes Γ 15): cold 21.7/23.8/22.9 β 73.8/49.6/30.7 ms, warm median 1.34/1.63/2.01 β 2.63/1.71/1.51 ms, warm min 0.82β0.99 β 0.94β1.47 ms | | `ServiceContainer.initialize completed { totalMs }` | 89 (83β92) | 85.5 (41β305) | within noise (unchanged code) | | SIGTERM β exit wall, exit code | 166 ms (127β187), 0 Γ10 | 146 ms (144β166), 0 Γ10 | `[shutdown] AppFiberScope closed` β explicit steps β `[shutdown] AppRuntime disposed` in every transcript | Cumulative construction cost of the peel (PR 3 baseline 12 ms β 4a 18 β 4b 23 ms cold median; warm β +0.5β1 ms): the Layer machinery's first-use cost for 19 layers + 8 stages + the wiring layer. Recorded against R7; startup remains dominated by `initialize()` and the renderer. ### Lessons for PR 5 (DesktopLive group layers + DesktopWiringLive + thin ServiceContainer + StreamManager runner param) - Group layers, not per-service, for the 45 desktop services (plan D1): the cold-construction cost above scales with layer count, so `Layer.effectContext` groups with today's construction order inside each are the right granularity there. - `DesktopWiringLive` should follow `CoreWiringLive`'s shape exactly: yield every collaborator first, then the former constructor's statements verbatim in order (`serviceContainer.ts` analytics `aiService.on(...)`/`workspaceService.on(...)` blocks, `setGlobalCoderService/setSshPromptService`, `core.turnRequestBuilderBindings.analyticsService = β¦`). Listener registration order on `aiService` matters between `WorkspaceService`/`TaskService` (core) and the desktop analytics listeners β desktop wiring runs after `CoreLive`, which preserves today's order. - The `ServiceContainer` constructor still reads the core back with `coreServicesFromContext` and then constructs the desktop tail; when it thins to field assignment from `Context.get`, keep the two `aiService`-listener orders and the `BackupService`/`BrowserBridgeTokenManager` positions (PR 3 moved them after the core; harmless, audited). - `StreamManager`'s optional trailing `runner` param: `WorkersLive` gets `EffectRunnerTag`; `StreamManagerLive` (S2b) can pass `yield* EffectRunnerTag` only if `EffectRunnerLive` stays beneath `CoreLive` in both roots β it does (`runtimeSeams` at the base of `AppLive` and `CoreRootLive`). - Test seams to keep: `spyOn(appLayers, "AppLive")` (TestClock injection and the throwing-layer test) and the `coreServicesRoot.test.ts` harness; both survived 4a/4b unchanged. - Host flakes seen during PR 4 (none code-related; all reproduce on pristine `main` here): bun 1.2.15 `Illegal instruction` mid-suite, `tools/workflow_run.test.ts` wedge (3/3 on pristine main in isolation; it also cancelled 4a's first merge-group run β re-enqueue the same head), `bashMonitorWakeReconciler`/`workspaceService` bash-monitor-wake order flakes, `AttachmentService β¦ newest first`, `BackupRepoCache` Γ7, `taskGitPatchEngine` Γ2, `WorkspaceTurnManager` terminal recovery Γ2, `agent_skill_delete`. ## Validation - `make static-check` green (typecheck both projects incl. the `@ts-expect-error` test, lint, fmt, docs). - `bun test src/node/services` (every file; the wedging `backup/` and `tools/workflow_run.test.ts` run separately): 6 466 pass; the 8 failures are the environment baselines above, identical on pristine `origin/main` on this host. `bun test src/cli` + named PR 4 gate (`streamManager*`, `aiService`, `workspaceService*`) + `coreServicesRoot`/`serviceContainer`/`di` 883/883. jest `tests/ipc/{doubleRegister,savedQueries,windowTitle,acp.disconnectCleanup}` 18/18. - Dogfooding (headless Coder host, `XUM_LOG_LEVEL=debug`): **`xum workflow`** echo run: `AppRuntime built` β `ok from pr4b` β `AppFiberScope closed` β `terminateAll()` β `AppRuntime disposed` β exit 0. **`xum server`** graceful quit 10/10 exit 0 (table). **Dev-server sandbox**: `AppRuntime built {ms: 14}`, `initialize completed {totalMs: 382}` (seeded config); via agent-browser added a scratch repo as a project, sent "Reply with exactly the single word: pong" β worktree workspace created, model replied `pong` (screenshot in the workspace chat shows `v0.28.3-nightly.148-20-ga4154cf19`), no `ManagedRuntime disposed`/defect lines; SIGTERM β exit in 173 ms with `[shutdown] AppFiberScope closed {ms: 1}` β¦ `[shutdown] AppRuntime disposed {ms: 1}`. Not exercised headless: Electron `before-quit` (same `ServiceContainer.dispose()`; `tests/e2e` in CI) and the oRPC memory pin/unpin round-trip (`effectBridge.test.ts` + identity tests). ## Risks - **Lowβmedium.** The wiring relocation is the one behavioral surface: every statement is verbatim and in order, all constructors that used to run between wiring lines are audited as not observing them (I6 table), and the 4a behavioral wiring tests pass unchanged. A throwing layer body still surfaces as the synchronous constructor-style throw (tests, now from a nested S4 body). - Startup: +β8 ms cold construction on top of 4a (measured), no `initialize()` change. --- <details> <summary>π Implementation Plan</summary> # Effect migration β Wave 3 / Phase 11: ManagedRuntime + Layer dependency injection ## 0. Summary Replace the two hand-written composition roots (`createCoreServices` + the `ServiceContainer` constructor) with an **Effect `Layer` graph** built once per process by a **`ManagedRuntime`** ("AppRuntime"), while keeping every service class, constructor signature, Promise facade, private method, and test seam compatible. The runtime becomes (a) the owner of the app-lifetime `Scope`, (b) the provider of `"effect/context"` for oRPC Effect-native handlers, and (c) the source of two runtime seams: an **`EffectRunner`** (context-bound, *unsupervised* runner that lets clock-driven workers run on a `TestClock`) and an **`AppFiberScope`** (a runtime-owned, *supervised* scope whose close is awaited by `dispose()` β the slot the streamManager engine core will occupy later). Six stacked, independently mergeable PRs. Product PRs keep existing tests unchanged; only the final test-modernization PR edits tests. Net product LoC β **+420** (per-PR estimates below). Service classes are *not* rewritten β Layers are thin adapters around existing constructors; cycle-breaking setter wiring moves into explicit "wiring layers" that replay today's order. Unlocks (not done here): streamManager ENGINE CORE conversion, `TestClock` for timing suites, app-lifetime scopes. ## 1. Verified current state (evidence) - **Roots.** `src/node/services/coreServices.ts:103-389` (`createCoreServices`: 25 constructions, 12 `turnRequestBuilderBindings` writes, ~14 setters) and `src/node/services/serviceContainer.ts:161-575` (45 more constructions; `aiService.on(...)`/`workspaceService.on(...)` analytics wiring at 474-574; global registrations `setGlobalCoderService/setSshPromptService` at 469-471). `new ServiceContainer(stores)` is called by `headlessEnvironment.ts:111`, `tests/ipc/setup.ts`, `src/cli/server.ts:132`, `src/node/acp/serverConnection.ts:155`, `src/desktop/main.ts:653`; `src/cli/run.ts:661` and `src/cli/workflow.ts:376` call `createCoreServices` directly. β two graph roots (App vs Core), five process entry points, all constructing **synchronously**. - **Startup.** `ServiceContainer.initialize()` (577-642) awaits six `initialize()`s (no try/catch; failure propagates to `main.ts:1255-1265` "Startup Failed" dialog + quit; `server.ts`/ACP log and exit), then sync `start()`s idleCompaction/heartbeat/agentStatus, then two fire-and-forget sweeps. All constructors are synchronous; two have side effects on **declared constructor dependencies** only (`AIService` β `streamManager.setEventSink`, `WorkspaceService` β `backgroundProcessManager.on/aiService.on`). - **Teardown.** `dispose()` (746-779) is explicit and hand-ordered (`backgroundProcessManager.beginShutdown()` MUST be first β it is a latch protecting persisted monitor records; bridges stop before sessions close; `terminateAll` late; `timelineService.flush()` last). `shutdown()` (718-732) is a *second* sequence fired concurrently by a second `before-quit` listener (`main.ts:1321`). `main.ts:1296-1304` races `dispose()` against 5 s then `app.quit()`; `cli/server.ts:227-268` has a 5 s `process.exit(1)` force timer; `tests/ipc` cleanup calls `dispose()` then `shutdown()`; `headlessEnvironment.dispose` never calls `services.dispose()`. - **Existing Effect surface.** 25 files import `effect`. Only `Context.Service` tag: `MemoryMeta` (`src/node/orpc/effectContext.ts:21`). `handlerGen` (`@orpc/experimental-effect`) runs `Effect.runPromiseExit` per request and `Effect.provide`s `opts.context["effect/context"]`. `streamBridge.ts` runs streams on the global runtime. Scope-owning workers: `heartbeatService.ts:134-243`, `idleCompactionService.ts:86-122` (`Scope.makeUnsafe` + `Effect.runSync(Scope.close(..))`, valid only because their fibers suspend solely on the clock), `oauthFlowManager.ts:164`, `streamManager.ts:4767/4054` (already `Effect.runFork(Scope.close(..))` β the async-close precedent). `memoryConsolidationService.ts:667-703, 837-860`: check-and-reserve funnels with zero suspensions before `inFlight.set`/`harvestInFlight.set`. - **effect@4.0.0-rc.112 API (verified in `node_modules/effect/dist`).** `Context.Service<Self, Shape>()("id")` (module `Context`, not `ServiceMap`); `Layer.{succeed,sync,effect,effectContext,effectDiscard,provide,provideMerge,mergeAll,build,buildWithScope}` (no `Layer.scoped`; `Layer.effect` strips `Scope` from R); `ManagedRuntime.make(layer)` β `{ runSync, runSyncExit, runFork, runPromise, runPromiseExit, contextEffect, cachedContext, scope, dispose(), disposeEffect }`; `Effect.{runSyncWith,runForkWith,runPromiseWith,runPromiseExitWith}(context)`; `Effect.context<R>()`; `Effect.serviceOption`; `Scope.{fork,forkUnsafe,close,provide}`; `TestClock` from `effect/testing` (`layer, adjust, setTime, withLive`); `Clock.Clock` is a `Context.Reference` (defaulted; `TestClock.layer()` overrides it). - **ManagedRuntime internals the design relies on** (`ManagedRuntime.js`): `make` creates `scope = Scope.makeUnsafe("parallel")` and `layerScope = Scope.forkUnsafe(scope, "sequential")`; the first `runX` forks a build fiber over `Layer.buildWithMemoMap` β a **fully synchronous layer graph builds synchronously**, so `runtime.runSync(Effect.context())` succeeds and sets `cachedContext`; afterwards every `runX` is `Effect.runβ¦With(cachedContext)` (no extra async boundary). Fibers started through `runtime.runX` are registered in `scope` (`onFiberStart: Fiber.runIn(scope)`). `dispose()` = `Scope.close(scope)` (interrupt registered fibers in parallel β layer finalizers sequentially in reverse), after which any `runtime.runX` dies with `"ManagedRuntime disposed"`. - **Layer composition semantics.** `Layer.mergeAll(A, B)` is *not* a dependency resolver: B's requirements are not satisfied by A's outputs; requirements bubble up. Dependencies are satisfied only via `Layer.provide`/`provideMerge` chains. Siblings in `mergeAll` may build concurrently. - **Test seams that pin signatures** (Explore report): private-method spies (`Config.saveConfig`, `WorkspaceService.retireKernelWorkflowRunReferences/startStartupRecovery/createSession/updateAgentStatus`, `MCPServerManager.startServers`, `AgentPluginInstallService.reconcileJournals`, β¦); module-level export spies (`agentStatusService.generateWorkspaceStatus`, `sshConnectionPool.verifyHostKeyAgainstPolicyEffect`, β¦); direct construction in tests (`Config` 44 files, `HistoryService` 22, `MemoryMetaService` 11, `WorkspaceService` 7, `IdleDispatcher` 6, `StreamManager` 4, `ServiceContainer` 3); partial-mock casts (`InitStateManager` 193, `AIService` 158, `TaskService` 149, `ORPCContext` 62). `effectBridge.test.ts:24-30` builds a partial `ORPCContext` via `buildOrpcEffectContext` + `as unknown as ORPCContext`. - **Timing probes** (TestClock candidates): `heartbeatService.test.ts` 6 real sleeps, `idleCompactionService.test.ts` 2, `retryManager.test.ts` 3 `setSystemTime`, `streamManager.test.ts` 7 (partial-write debounce), `streamBridge.test.ts` 11 (heartbeat ticker), OAuth device-flow suites 14 (non-goal). ## 2. Target architecture ### 2.1 Building blocks (all under `src/node/services/di/`; the *only* directory allowed to import `Layer`/`Context`/`ManagedRuntime`/`TestClock`) | Module | Contents | |---|---| | `tags.ts` | One `Context.Service` tag per service class provided by the graph. Type-only imports of service classes β no runtime import cycles. Ids `"xum/<Name>"`. Naming: class name minus trailing `Service` (`MemoryMeta`, `Workspace`, `History`); classes without that suffix or colliding with an exported name get a `Tag` suffix (`ConfigTag`, `StreamManagerTag`, `IdleDispatcherTag`). Exports the unions `CoreTags` and `AppTags`. | | `effectRunner.ts` | `interface EffectRunner { runSync<A,E>(e: Effect<A,E,never>): A; runSyncExit; runFork; runPromise; runPromiseExit }` β a **context-bound, unsupervised** runner whose methods accept only effects with **no service requirements** (`R = never`; defaulted references like `Clock` do not appear in `R`). That makes "not a service locator" type-enforced: a fiber that needs services must take them as explicit constructor dependencies and, if it must be awaited on shutdown, fork into `AppFiberScope`. `defaultEffectRunner` = the global `Effect.runX` (today's exact behavior). `effectRunnerFromContext(ctx)` = `Effect.runβ¦With(ctx)`. `EffectRunnerTag` + `EffectRunnerLive = Layer.effect(EffectRunnerTag, Effect.map(Effect.context<never>(), effectRunnerFromContext))`, placed at the **base** of the graph so the captured context contains only refs (`Clock`, later `Logger`/`Random`) plus stores. Fibers forked through it are owned by the worker's own `Scope` (explicit `start/stop`), **not** by the ManagedRuntime; `runtime.dispose()` does not interrupt them. Services import only this file from `di/`. | | `appFiberScope.ts` | `AppFiberScopeTag: Scope.Closeable`. `AppFiberScopeLive = Layer.effect(AppFiberScopeTag, Effect.gen(function*(){ const parent = yield* Effect.scope; return yield* Scope.fork(parent, "parallel"); }))` β a child of the runtime's layer scope. Fibers forked into it via `Effect.forkIn(_, appFiberScope)` are interrupted **and awaited** when the scope closes. This is the **supervised** seam for I/O-suspended fibers (engine core, later). `ServiceContainer.dispose()` closes it explicitly and early (Β§5) so interrupted fibers can still use their dependencies during finalization; `runtime.dispose()` later re-closes it idempotently as a backstop. No production occupant in Phase 11; the seam exists with tests. | | `appRuntime.ts` | `makeAppRuntime(layer)`: `ManagedRuntime.make(layer)` + **eager synchronous build** (`runtime.runSync(Effect.context<R>())`; `assert(runtime.cachedContext !== undefined)`); a layer body that suspends is a programming error and throws here β exactly where a throwing constructor throws today, so every entry point's existing catch/dialog/log path is preserved. `disposeAppRuntime(runtime, timeoutMs)` and `closeScopeBounded(scope, timeoutMs)` share one shape: `Effect.uninterruptible` teardown shell around `Effect.interruptible(target.pipe(Effect.timeout(timeoutMs)))` where `target` is `runtime.disposeEffect` resp. `Scope.close(scope, Exit.void)` (never a non-cancellable JS Promise wrapper); `Effect.catchTag("TimeoutError", β¦)` + `Effect.catchDefect` β `log.warn`; run via `Effect.runPromise`; **never rejects**; idempotent (`Scope.close` is idempotent; `disposeEffect` is guarded by a latch). Verify the exact rc `Effect.timeout` error type at implementation time (rc.112: fails with `Cause.TimeoutError`, `_tag: "TimeoutError"`). Module doc comment = the DI contract (Β§2.3, Β§5). | | `layers/stores.ts` | `StoresLive(stores: ConfigStores)` = `Layer.mergeAll` of `Layer.succeed` for `ConfigTag`, `SessionLocatorTag`, `ProvidersConfigStoreTag`, `SecretsStoreTag`, `FileLeaseManagerTag` (true siblings β no inter-dependencies). `StoresFromCoreOptionsLive` reproduces the `opts.x ?? new X(config.rootDir)` defaults of `coreServices.ts:106-112` for the CLI root. | | `layers/core.ts` | `CoreOptionsTag` (today's `CoreServicesOptions` minus stores β carries the *optional* cross-cutting services exactly as today). **PR 3:** `CoreProjectionLive = Layer.effectContext(...)` wrapping the existing `createCoreServices` body and returning a `Context<CoreTags>` (coarse projection, zero behavior change). **PR 4:** peel into per-service `Layer.effect(Tag, Effect.gen(...))` layers composed in **explicit dependency stages** (`Layer.provideMerge` between stages; `Layer.mergeAll` only for true siblings within a stage β every sibling claim below was checked against the constructor argument lists in `coreServices.ts` and must be re-checked in the PR): S1 History Β· InitState Β· Provider Β· BackgroundProcess Β· ExtensionMetadata Β· MemoryMeta Β· TerminalAttention Β· IdleDispatcher Β· WorkspaceMcpOverrides(default) Β· `TurnRequestBuilderBindingsTag` (`Layer.succeed(_, {})`) β S2a SessionUsage Β· Goal Β· Memory β S2b StreamManager (needs SessionUsage) β S3 AIService β S4 Consolidation Β· MCPConfig β S5 MCPServerManager β S6 Workspace β S7 Task β S8 TurnManager β `CoreWiringLive` (`Layer.effectDiscard`, **`Effect.sync` only β no `acquireRelease`**, replays `coreServices.ts:137-166, 209-210, 258-270, 288-325, 349-352, 360-367` in order). | | `layers/desktop.ts` | `CrossCuttingLive` (policy, telemetry, experiments, backup, sessionTiming, analytics, devTools, workspaceMcpOverrides, browserBridgeTokenManager), `CoreOptionsFromDesktopLive` (derives `CoreOptionsTag` from those tags + `extensionMetadataPath`), then **group layers** (`Layer.effectContext` returning a `Context` of several tags, constructed in today's order): `BrowserLive`, `DesktopBridgeLive`, `OauthLive`, `WorkersLive` (idleCompaction, heartbeat, agentStatus, timeline, refine), `TerminalEditorLive`, `MiscDesktopLive`; staged with `provideMerge` where one group needs another. `DesktopWiringLive` (`Effect.sync` only) = setters + `aiService.on/workspaceService.on/memoryConsolidationService.on` wiring + global registrations. | | `layers/app.ts` | `AppLive(stores) = DesktopLive βΉ CoreLive βΉ CoreOptionsFromDesktopLive βΉ CrossCuttingLive βΉ AppFiberScopeLive βΉ EffectRunnerLive βΉ StoresLive(stores)` β read `X βΉ Y` as "X is *provided with* Y, and both stay exposed", i.e. **`X.pipe(Layer.provideMerge(Y))`** (rc.112 signature: `provideMerge(that: provider)(self: consumer)`; the *right-hand* operand is the dependency). Every `βΉ` keeps all tags visible in the final `Context<AppTags>`. | | `testEffectRunner.ts` (test helper, sibling of `testHistoryService.ts`) | `makeTestEffectRunner()` β `{ runner, adjust(duration), setTime(ms), dispose }` over one memoised `ManagedRuntime.make(EffectRunnerLive.pipe(Layer.provideMerge(TestClock.layer())))` (the TestClock is the *provider*; the runner captures it), so the worker under test and `TestClock.adjust` share one `TestClock`. | ### 2.2 Composition roots after Phase 11 ```mermaid flowchart TB Stores["StoresLive(stores)<br/>Config Β· SessionLocator Β· ProvidersConfigStore Β· SecretsStore Β· FileLeaseManager"] Runner["EffectRunnerLive (unsupervised, ref-bound)<br/>+ AppFiberScopeLive (supervised, closed on dispose)"] Cross["CrossCuttingLive (desktop only)<br/>Policy Β· Telemetry Β· Experiments Β· Analytics Β· SessionTiming Β· DevTools Β· WorkspaceMcpOverrides Β· Backup"] Opts["CoreOptionsTag<br/>desktop: derived from CrossCutting Β· CLI: Layer.succeed(opts)"] Core["CoreLive<br/>PR 3: coarse CoreProjectionLive β PR 4: stages S1β¦S8 + CoreWiringLive"] Desk["DesktopLive β group Layers<br/>Browser Β· DesktopBridge Β· OAuth Β· Workers Β· TerminalEditor Β· Misc β DesktopWiringLive"] RT["AppRuntime = ManagedRuntime.make(AppLive)<br/>eager sync build Β· Context<AppTags> = oRPC effect/context Β· dispose() last"] Stores --> Runner --> Cross --> Opts --> Core --> Desk --> RT CLI["CLI root (xum run / xum workflow)<br/>createCoreServices(opts) = makeAppRuntime(CoreLive βΉ StoresFromCoreOptionsLive βΉ AppFiberScopeLive βΉ EffectRunnerLive βΉ succeed(CoreOptionsTag, opts))"] Core -.same Layer definitions.-> CLI ``` `ServiceContainer` keeps its public fields and the synchronous `new ServiceContainer(stores)`: the constructor calls `makeAppRuntime(AppLive(stores))`, stores `this.serviceContext = runtime.runSync(Effect.context<AppTags>())`, and assigns fields via `Context.get(this.serviceContext, Tag)`. `toORPCContext()` returns the same plain fields plus `"effect/context": this.serviceContext`. `initialize()` is untouched. `dispose()` follows Β§5. `createCoreServices(opts)` keeps its signature and return shape plus `runtime` and `appFiberScope` fields; `cli/run.ts:1574-1580` and `cli/workflow.ts:275-320` cleanup lists gain `closeScopeBounded(appFiberScope)` before `session.dispose()` and `disposeAppRuntime(runtime)` as the final step (PR 3). **Staged composition skeleton (PR 4 shape; direction matters):** ```ts // Each stage depends only on stages defined above it. `provideMerge` keeps both sides exposed. const S1 = Layer.mergeAll(HistoryLive, InitStateLive, ProviderLive, /* β¦ true siblings only */); const S2a = Layer.mergeAll(SessionUsageLive, GoalLive, MemoryLive).pipe(Layer.provideMerge(S1)); const S2b = StreamManagerLive.pipe(Layer.provideMerge(S2a)); // StreamManager needs SessionUsage const S3 = AIServiceLive.pipe(Layer.provideMerge(S2b)); // β¦ S4 β¦ S8 likewise β¦ export const CoreLive = CoreWiringLive.pipe(Layer.provideMerge(S8)); // wiring runs after every service exists ``` **oRPC typing.** `OrpcEffectServices` (in `effectContext.ts`) becomes `AppTags`, so `ORPCContext["effect/context"]: Context<AppTags>` is satisfied by the runtime context in production. `buildOrpcEffectContext` stays as the narrow test helper it already is (its only caller, `effectBridge.test.ts:24-30`, deliberately builds a partial context and casts it via `unknown`); no production caller remains after PR 1. ### 2.3 Invariants (the "DI contract"; enforced by tests and the `appRuntime.ts` doc comment) | # | Invariant | Constraint served | |---|---|---| | I1 | **Phase 11 compatibility contract, not permanent law:** layer bodies are synchronous (`Layer.succeed`/`Layer.sync`/`Layer.effect` over sync effects; `acquireRelease` with a sync acquire is fine). `makeAppRuntime` asserts the eager build completed. Future async resource acquisition belongs in `initialize()`/startup effects or an explicit async factory root (`ServiceContainer.create()`), never silently inside a layer. | #2 sync-start, #5 startup parity | | I2 | Services never hold the `ManagedRuntime`. Workers hold an `EffectRunner` (default `defaultEffectRunner`); `EffectRunner.runX` β‘ `Effect.runβ¦With(ctx)` β same sync-start semantics as `Effect.runX`, and still valid after `runtime.dispose()`, so late callbacks cannot hit "ManagedRuntime disposed". Supervision, when needed, is explicit via `AppFiberScope`. | #2, #3 | | I3 | Per-call pipelines (`Effect.runPromise(this.effectsβ¦)` facades) and the `memoryConsolidationService` funnels are untouched. **Audit item:** no DI lookup, runner call, or `await` may be inserted before `inFlight.set` / `harvestInFlight.set`. Only lifecycle forks in workers move to `this.runner.runX`. | #1, #2 | | I4 | Constructors, facades, private methods, module exports unchanged; new constructor parameters are optional, trailing, defaulting to `defaultEffectRunner`. | #1, #6 | | I5 | Teardown order stays explicit in `dispose()`/`shutdown()`. Layer bodies and wiring layers register **no finalizers** in Phase 11 (`Effect.sync` only), so `runtime.dispose()` reorders nothing. The one supervised resource (`AppFiberScope`) is closed explicitly at a fixed position in `dispose()` (Β§5). | #3 | | I6 | Wiring layers replay today's setter/listener order; a constructor may touch only its *declared* dependencies (built earlier by staging). Per-PR audit: grep each moved constructor for calls on setter-provided collaborators β forbidden. Dependency order is expressed only with `provide`/`provideMerge` stages; never rely on `mergeAll` sibling order. | #6 | | I7 | No persisted-data changes; DI is in-process only. | #4 | | I8 | Every process root builds from the same Layer definitions (`CoreLive` shared by App and CLI). Unit harnesses (`createTestHistoryService`, `createTestToolConfig`, `createAgentSessionHarness`, β¦) intentionally bypass Layers. | #7 | ### 2.4 Decisions and alternatives (product-LoC deltas) <details> <summary>D1 β Granularity: coarse core first (PR 3), per-service core stages behind a decision gate (PR 4), group layers for the desktop tail (PR 5)</summary> Honest framing: the three unlocks (engine-core async scope, TestClock, app-lifetime scope) are delivered by `AppRuntime` + `EffectRunner` + `AppFiberScope` and **do not require per-service layers**. Per-service core layers are *migration leverage*: typed requirement sets for the engine-core work, per-service swap in integration tests, explicit dependency stages instead of implicit ordering. - **(A) Per-service everywhere** (~70 layers): +~900/β~700. Desktop tail has hand-tuned teardown that must not become finalizers, so per-service there buys uniformity only. Rejected. - **(B) Recommended:** PR 3 coarse `CoreProjectionLive` (+~120/β~10) delivers the shared root and runtime ownership; PR 4 peels the core into staged per-service layers (+~330/β~290) **only if** PR 3's typecheck/startup budgets hold (gate in Β§3); desktop tail as ~6 group layers (+~170/β~150). Tags for all services either way (~3 LoC each). - **(C) Coarse only:** stop after PR 3 + desktop projection (~+200 total). Cheapest; the engine-core phase would then redo dependency declarations. Remains the fallback if PR 4's gate fails. </details> <details> <summary>D2 β Async init stays an explicit `initialize()`; Layers construct only</summary> Folding `initialize()` into layer construction would make the build asynchronous (breaks I1), change failure semantics (today: fail-fast β dialog/log), and move the six-step order into memoised builds. Deferred; a later phase can turn `initialize()` into `runtime.runPromise(startupEffect)` with per-step `Effect.timeout`. </details> <details> <summary>D3 β Optional cross-cutting services stay optional via `CoreOptionsTag`, not `Effect.serviceOption`</summary> Core layer bodies read `opts.policyService` etc. exactly as today, so CLI (absent) vs desktop (present) behavior is unchanged and no service gains a new `undefined` branch. </details> <details> <summary>D4 β Two seams instead of one: `EffectRunner` (unsupervised, clock-bound) + `AppFiberScope` (supervised)</summary> A single "runtime handle" conflates two needs. Workers need *which clock* (TestClock) and must keep sync `stop()`; the engine core needs *who awaits me on shutdown*. Explicit `Clock` injection per worker was rejected (a `provideService(Clock.Clock, β¦)` at every fork site, and it does not extend to other refs). </details> <details> <summary>D5 β oRPC: `effect/context` = the runtime's `Context`; `handlerGen` unchanged</summary> `handlerGen` already `Effect.provide`s the context per request; providing ~70 entries instead of one is one Map merge per request. The existing `echoAsync`/`echoEffect` probes record the delta as a **diagnostic** in the PR body (no stable benchmark harness exists to make it a hard gate). `effect/wrap` not needed. </details> ## 3. Phasing β six stacked PRs Every PR: `make static-check`; gate suites below; existing tests unchanged (PR 6 is the only PR that edits tests, and only to replace real-timer probes). Before `@codex review`, run the **house pre-review audits**: 1. **Interruption posture** β list every new/moved fiber fork; state what interrupts it and when (unsupervised via `EffectRunner` + worker scope, or supervised via `AppFiberScope`). 2. **Uninterruptible teardown** β teardown effects are `Effect.uninterruptible` end-to-end; bounded waits inside use `Effect.interruptible(Effect.timeout(...))` (house shape from #4038). 3. **No defect escapes** β `disposeAppRuntime`/`closeScopeBounded` and every Promise facade fold defects; `makeAppRuntime` is the one place allowed to throw (constructor semantics). 4. **Spy-seam check** β `rg 'spyOn\(' src/node/services/<touched>.test.ts tests/` per touched class; constructor arity and private-method Promise signatures unchanged (typecheck of tests proves it). 5. **Sync-start check** β a fork through `EffectRunner` runs to its first `sleep` before `runFork` returns (mirrors `heartbeatService.ts:199-202`). 6. **Constructor side-effect audit (I6)** for every constructor moved into a Layer in that PR. 7. **Zero-suspension audit (I3)** whenever `memoryConsolidationService` is in the diff. ### PR 1 β Skeleton: AppRuntime + Stores/MemoryMeta layers + runtime-backed `effect/context` + dispose hook (+~150 LoC) **Scope** - `di/tags.ts` (`ConfigTag`, `SessionLocatorTag`, `ProvidersConfigStoreTag`, `SecretsStoreTag`, `FileLeaseManagerTag`, `MemoryMeta` moved from `orpc/effectContext.ts`, which re-exports it; `AppTags` union). - `di/layers/stores.ts` (`StoresLive`), `di/layers/core.ts` with `MemoryMetaLive = Layer.effect(MemoryMeta, Effect.map(ConfigTag, c => new MemoryMetaService(c.rootDir)))`, `di/layers/app.ts` (`AppLive(stores) = MemoryMetaLive βΉ StoresLive`). - `di/appRuntime.ts` (`makeAppRuntime`, `disposeAppRuntime`); `APP_RUNTIME_DISPOSE_TIMEOUT_MS` in `src/constants/`. - `coreServices.ts`: `CoreServicesOptions.memoryMetaService?` (precedent: `workspaceMcpOverridesService?`). - `serviceContainer.ts`: build runtime first, pass `Context.get(ctx, MemoryMeta)` to `createCoreServices`, `public readonly runtime`, `toORPCContext()["effect/context"] = this.serviceContext`, `dispose()` appends `disposeAppRuntime` behind a `disposed` latch; new `log.debug("[startup] AppRuntime built", { ms })`. - `orpc/effectContext.ts`: `OrpcEffectServices = AppTags`; `buildOrpcEffectContext` retyped/test-helper doc. - `headlessEnvironment.dispose` calls `await services.dispose()` before removing the temp dir (the bench harness currently leaks the container; runtime ownership starts here). **Acceptance** - `di/appRuntime.test.ts`: (a) sync build sets `cachedContext`; (b) a layer with an async body makes `makeAppRuntime` **throw synchronously** (I1 enforced); (c) probe layers' finalizers run in reverse order on dispose; (d) dispose is idempotent and bounded (hung finalizer β `warn`, resolves at the timeout); (e) `runtime.runFork` after the eager build starts synchronously. - `serviceContainer.test.ts`: `Context.get(toORPCContext()["effect/context"], MemoryMeta) === services.memoryMetaService`; `dispose()` closes the runtime; `dispose(); shutdown()` (tests/ipc order) is clean; a throwing layer surfaces as a synchronous throw from `new ServiceContainer(stores)` (same shape as today's constructor throw β existing entry-point catch paths). - `effectBridge.test.ts`, `memoryMeta*.test.ts` unchanged and green; echo-probe overhead recorded in the PR body. - Gate: `bun test src/node/services/di src/node/services/serviceContainer.test.ts src/node/orpc src/node/services/memoryMeta*` Β· `make test-integration` Β· `make static-check`. **Rollback:** `git revert`; classes untouched. ### PR 2 β Runtime seams: `EffectRunner` + `AppFiberScope`; TestClock on idleCompaction/heartbeat/retryManager (+~140 LoC) **Scope** - `di/effectRunner.ts`, `di/appFiberScope.ts`; `AppLive` gains `AppFiberScopeLive βΉ EffectRunnerLive` at the base; `ServiceContainer` exposes `appFiberScope` (used only by `dispose()` in Phase 11) and closes it per Β§5. - `IdleCompactionService`, `HeartbeatService`, `RetryManager`: trailing optional `runner: EffectRunner = defaultEffectRunner`; every lifecycle `Effect.runSync/runFork` in `start/stop/schedule/cancel` becomes `this.runner.runX`. Deadline math (`Date.now()`/injected `now`) unchanged. `ServiceContainer` passes `Context.get(ctx, EffectRunnerTag)` to the two workers; `RetryManager` keeps the default until PR 5 (so `streamManager.ts` is untouched here). - `di/testEffectRunner.ts` helper. **Acceptance** - New TestClock tests (existing real-timer tests untouched β they exercise the `defaultEffectRunner` path, which is production behavior wherever no runner is injected): heartbeat `STARTUP_DELAY_MS` β first tick after `adjust`, one tick per `CHECK_INTERVAL_MS`, no ticks after `stop()`; idleCompaction initial delay + cadence; retryManager fires exactly at `delayMs`, `cancel()` before `adjust` never fires. - Pin runtime facts: `runner.runSync(Scope.close(scope, Exit.void))` completes synchronously for a fiber suspended on a TestClock sleep; `runFork` through the runner reaches its first sleep synchronously; `Effect.context<never>()` inside `EffectRunnerLive` sees the upstream `TestClock` (else the helper provides `Clock.Clock` explicitly β same seam, one line). - `AppFiberScope` contract tests: (i) an **I/O-suspended** fiber (interruptible `Effect.async` that never resolves, with a cancel path) forked with `Effect.forkIn(_, appFiberScope)` is interrupted **and awaited** by `closeScopeBounded(appFiberScope)` β and this happens *before* the explicit teardown steps in `dispose()` (assert ordering against a spy on `desktopBridgeServer.stop`); (ii) a fiber forked via `EffectRunner` is *not* interrupted by either close (documents the asymmetry); (iii) `disposeAppRuntime` afterwards idempotently re-closes the already-closed child scope (no error, no second finalizer run). - If `TestClock.adjust` leaves continuations pending, the helper adds `Effect.yieldNow`/`Fiber.await` β decided by tests. - Gate: `heartbeatService.test.ts`, `idleCompactionService.test.ts`, `retryManager.test.ts`, `serviceContainer.test.ts`, `di/*`, tests/ipc. **Rollback:** revert restores defaults; no call site depends on the new params. ### PR 3 β Shared core root: coarse `CoreProjectionLive` + `createCoreServices` facade + CLI runtime disposal (+~120 / β~10) **Scope** - Tags for the remaining 19 core services; `CoreOptionsTag`; `StoresFromCoreOptionsLive`. - `CoreProjectionLive = Layer.effectContext(Effect.gen(function*(){ const opts = yield* CoreOptionsTag; const stores = yield* β¦; const core = buildCoreGraph({ ...opts, ...stores }); return Context.make(History, core.historyService).pipe(Context.add(...)) }))` where `buildCoreGraph` is today's `createCoreServices` body, unchanged, renamed. - `createCoreServices(opts)` = `makeAppRuntime(CoreProjectionLive βΉ StoresFromCoreOptionsLive βΉ AppFiberScopeLive βΉ EffectRunnerLive βΉ Layer.succeed(CoreOptionsTag, opts))`, returns today's `CoreServices` object read from the context plus `runtime` and `appFiberScope`. `cli/run.ts` and `cli/workflow.ts` cleanup lists append `closeScopeBounded(appFiberScope)` **before** `session.dispose()` and `disposeAppRuntime(runtime)` **after** `backgroundProcessManager.terminateAll()`. - `ServiceContainer` stops calling `createCoreServices`; `AppLive = CoreProjectionLive βΉ CoreOptionsFromDesktopLive βΉ CrossCuttingLive βΉ β¦` (cross-cutting services move into `CrossCuttingLive` now because core options derive from them). Desktop constructions otherwise stay in the constructor. **Acceptance** - Identity test: every `CoreServices` field `===` `Context.get(ctx, Tag)`; `serviceContainer.test.ts` unchanged and green. - **Decision gate for PR 4** recorded in the PR body: `make typecheck` wall time, `[startup] AppRuntime built` ms and `initialize` totals vs `origin/main` baseline from the sandbox (Β§7). Proceed to PR 4 only if typecheck regresses < 10 % and startup within noise; otherwise stop at (C). - Gate: `bun test src/node/services`, `src/cli/*.test.ts` (run/workflow/server/cli), tests/ipc, `make static-check`. **Rollback:** revert restores the imperative call; PR 1/2 unaffected. ### PR 4 β Peel the core into staged per-service Layers + `CoreWiringLive` (+~330 / β~290 β net β +40; split 4a/4b if > ~600 diff lines) **Scope** - Stages S1, S2a, S2b, S3β¦S8 (Β§2.1 + skeleton in Β§2.2) as `Layer.effect` adapters with today's argument lists; `CoreWiringLive` (`Effect.sync` only) replays the wiring lines in order; `CoreLive = CoreWiringLive.pipe(Layer.provideMerge(S8))` replaces `CoreProjectionLive`; `buildCoreGraph` deleted. - Before writing any stage: re-derive the DAG from the constructor argument lists (the plan's stage table was checked once; `StreamManager β SessionUsage` is the kind of edge that turns "siblings" into a stage split) and record it in the PR body. - 4a (S1βS3: leaves through `AIService`) / 4b (S4βS8 + wiring) if needed β 4a alone is mergeable because the remaining services are built by a shrunken projection layer that reads S1βS3 from the context. **Acceptance** - Wiring assertions that are behavioral (a missing wiring line fails them): `turnRequestBuilderBindings` fully populated; goal continuation consumer registered on `idleDispatcher`; `streamManager` MCP manager set; registration probe installed on `extensionMetadata`. - I6 audit table for all 19 constructors in the PR body; missing-provider = compile error (R must be `never` at `makeAppRuntime`) demonstrated by a type-level test (`// @ts-expect-error`). - Gate: as PR 3 plus `streamManager*.test.ts`, `aiService.test.ts`, `workspaceService*.test.ts`. **Rollback:** revert to PR 3's projection. ### PR 5 β `DesktopLive` group layers + `DesktopWiringLive`; thin `ServiceContainer`; `StreamManager` runner param (+~170 / β~150 β net β +20) **Scope** - Tags for the 45 desktop services; six group layers (`Layer.effectContext`, today's construction order inside each; `provideMerge` between groups that depend on each other); `DesktopWiringLive` (`Effect.sync` only) = `serviceContainer.ts:209, 263-265, 271, 288-290, 334-340, 348, 365, 375, 381-382, 434, 438-471, 474-574` in order. - `ServiceContainer` constructor = `makeAppRuntime(AppLive(stores))` + field assignment from the context. `toORPCContext()` unchanged in shape. - `StreamManager`: optional trailing `runner: EffectRunner`; `schedulePartialWrite` fork (`streamManager.ts:1141`) and `RetryManager` construction use it; `Scope.close` stays `Effect.runFork` (existing async-close precedent). `WorkersLive` receives `EffectRunnerTag`. **Acceptance** - All four existing `serviceContainer.test.ts` assertions unchanged; new identity test over `toORPCContext()` fields vs tags; `dispose()`/`shutdown()` call order asserted via spies on the *public* methods already spied today. - I6 audit for the 45 constructors. - Gate: tests/ipc + tests/ui (`make test-integration`), `src/cli/server.test.ts`, `src/cli/cli.test.ts`, `streamManager*.test.ts`, `aiService.test.ts`. ### PR 6 β TestClock adoption sweep + shutdown hardening + contract docs (+~20 LoC product; tests edited) **Scope** - Replace real-sleep cadence probes with `makeTestEffectRunner()` in `heartbeatService.test.ts`, `idleCompactionService.test.ts`, `retryManager.test.ts`, and the partial-write debounce cases of `streamManager.test.ts`; keep **one real-timer smoke test per worker** (guards the `defaultEffectRunner` path). - `cli/server.ts`: `[shutdown]` log lines per step incl. `AppRuntime disposed {ms}`; confirm the whole `dispose()` fits the existing 5 s force-exit budget. - Finalize the contract doc comment in `di/appRuntime.ts` (I1βI8, Β§5). **Acceptance:** converted suites have zero `setTimeout`-based cadence waits (grep in PR body), same assertions; `make test-integration` green; sandbox startup/shutdown evidence (Β§7). ## 4. TestClock story - **Mechanism.** `Effect.sleep`, `Schedule.fixed`, `Effect.timeout`, `Clock.currentTimeMillis` read the `Clock` reference from the running fiber's context. Workers that fork through an `EffectRunner` built under `TestClock.layer()` run on the test clock; `await testRunner.adjust("2 minutes")` advances it. `Date.now()`, `setTimeout`, `setInterval` are unaffected β heartbeat deadline math via injected `now`, `AgentStatusService`'s ref'd `setInterval`, and `backgroundProcessManager` stay on real timers/injected timestamps. - **Benefit now:** `heartbeatService.test.ts` (6), `idleCompactionService.test.ts` (2), `retryManager.test.ts` (3 `setSystemTime` β `adjust`; `Date.now`-based `retryAt` may move to `Clock.currentTimeMillis` only if a test needs both clocks aligned), `streamManager.test.ts` debounce cases (7). - **Deferred:** `streamBridge.test.ts` ticker (11) β needs a context/runner parameter on `subscriptionIterable`; OAuth device-flow polling and `oauthFlowManager.test.ts` (25) β non-goal. - **Stays real:** child-process/PTY/WASM/fs-lock waits (`backgroundProcessManager` 72, `quickjsRuntime` 26, lock sleeps in `workspaceService`/`taskService`), end-to-end suites (tests/ipc, e2e). - **Pinned in PR 2, not assumed:** `adjust` runs due sleeps and their synchronous continuations before resolving (or the helper yields until they do); `Schedule.fixed` anchoring under `TestClock` matches the wall-clock expectations in `heartbeatService.ts:149-155`; sync `Scope.close` of a TestClock-suspended fiber completes synchronously. ## 5. Shutdown protocol 1. **Trigger points unchanged:** `main.ts` `before-quit` (preventDefault β `dispose()` raced with 5 s β `app.quit()`; update-install path fire-and-forget), the second `before-quit` listener's `shutdown()` (unchanged, concurrent), `cli/server.ts` SIGINT/SIGTERM (5 s force exit), ACP `close()`, tests/ipc (`dispose()` then `shutdown()`), headless bench (`dispose()` from PR 1). 2. **`ServiceContainer.dispose()` order:** 1. `backgroundProcessManager.beginShutdown()` β unchanged, first (latch protecting persisted monitor records). 2. **`closeScopeBounded(appFiberScope, APP_FIBER_SCOPE_CLOSE_TIMEOUT_MS)`** β interrupts and awaits supervised fibers *while every dependency they might touch during finalization is still alive*. No occupants in Phase 11; the position is fixed now so the engine-core phase does not have to re-derive it. 3. The existing explicit sequence verbatim (`desktopBridgeServer.stop()` β¦ `terminateAll()` β¦ `timelineService.flush()`). 4. **`disposeAppRuntime(runtime, APP_RUNTIME_DISPOSE_TIMEOUT_MS)`** β closes the runtime scope (interrupts any fiber started via `runtime.runX` β none long-lived in Phase 11; runs layer finalizers β none in Phase 11 by I5). Hung β `warn` at the timeout; never rejects. Budget: 2 s + 2 s inner bounds inside the callers' 5 s outer budgets; the outer race in `main.ts` remains the last line of defense. **Rule for future occupants:** anything forked into `AppFiberScope` must tolerate interruption at any suspension point and must not depend on resources torn down in step 1; anything that needs a Layer finalizer must first prove reverse-construction order is compatible with steps 2β3 (I5). 3. **Latches:** `disposed` makes `dispose()` idempotent (two `before-quit` listeners, tests/ipc dispose+shutdown). `shutdown()` never touches the runtime or `AppFiberScope`. 4. **Late callers:** `EffectRunner` handles keep working after runtime dispose (I2), so a stray `tick()`/`scheduleRetry()` after quit cannot defect. The `ManagedRuntime` is referenced only by `ServiceContainer` and the `createCoreServices` return value. 5. **Worker `stop()` stays synchronous** (`runner.runSync(Scope.close)`) because their fibers suspend only on the clock. The engine core will fork into `AppFiberScope` (step 2.2 awaits it) β the reason both seams exist now. 6. **Crash paths:** unchanged β `uncaughtException`/SIGKILL run no finalizers. Finalizers are best-effort; durable state must remain crash-safe without them (AGENTS.md self-healing rule). Nothing in Phase 11 makes a finalizer the sole guardian of durable state. ## 6. Risk register | # | Risk | L/I | Mitigation | |---|---|---|---| | R1 | A layer body suspends β `runSync` throws at startup | M/H | I1 assert + PR 1 test (b); doc comment; review checklist; entry-point catch paths verified in PR 1 | | R2 | Construction-order side effects differ under staged builds | L/H | I6 audit per moved constructor; explicit `provideMerge` stages; wiring layers replay today's order; tests/ipc as behavioral gate | | R3 | Double teardown (`shutdown()` β₯ `dispose()`; dispose+shutdown in tests) | M/M | `disposed` latch; runtime/AppFiberScope closed only in `dispose()`; PR 1 test | | R4 | Late `runtime.runX` after dispose β defect | M/M | I2: services hold `EffectRunner`, never the ManagedRuntime | | R5 | TestClock semantics differ from assumptions | M/L | PR 2 pins them before any suite converts; per-suite fallback to real timers | | R6 | effect v4 RC churn (`Context`β`ServiceMap`, Layer renames) | M/M | All `Layer/Context/ManagedRuntime/TestClock` imports confined to `di/`; exact pin | | R7 | Startup latency regression (splash) | L/M | `AppRuntime built` ms + `initialize` totals vs baseline in sandbox; PR 3 gate | | R8 | Typecheck slowdown from large requirement unions | L/L | PR 3 gate records `make typecheck` wall time; fallback (C) | | R9 | Per-request `Effect.provide` of a ~70-entry Context | L/L | echo-probe diagnostic in PR 1/5 bodies | | R10 | Spy seams / direct-construction tests break | L/H | I4; optional trailing params; audit 4; typecheck of tests | | R11 | CLI roots forget to dispose runtime/scope | M/L | PR 3 wires both cleanups; `src/cli/*.test.ts` assert the cleanup steps exist | | R12 | Someone forks long-lived I/O work via `EffectRunner` expecting dispose to await it | M/M | Doc on `EffectRunner` ("unsupervised"); PR 2 asymmetry test; review audit 1 | **Rollback:** PRs are stacked; revert in reverse order (6β1). Service classes are never modified except for optional trailing params, so any revert restores the previous composition root wholesale with no data or API implications. ## 7. Dogfooding (per PR; evidence attached to the PR body) **Environment (headless Coder host, no `DISPLAY`):** ```bash XUM_LOG_LEVEL=debug DEV_SERVER_SANDBOX_ARGS="--clean-projects" make dev-server-sandbox # background bash task; prints URL + XUM_ROOT ``` - **Startup correctness:** `<XUM_ROOT>/logs/*.log` shows, in order: `Loading services...`, `[startup] AppRuntime built {ms}`, `[startup] ServiceContainer.initialize starting`, six step durations, `[startup] ServiceContainer.initialize completed {totalMs, stepDurationsMs}`. Paste baseline (`origin/main`) vs branch numbers. - **Startup-never-crash parity (once, locally, not committed):** inject a throwing scratch layer β `xum server` exits non-zero with the existing logged error and **no** unhandled-rejection trace; for desktop, confirm by code path (`loadServices()` rejects β `main.ts:1255` dialog) and via `src/cli/server.test.ts`/ACP tests. - **UI smoke (agent-browser):** `open <url>` β `snapshot -i` β add a scratch git repo as a project β create a workspace β send one message β `screenshot` the loaded app and the response; `attach_file` both. **Video:** start `agent-browser record` before the flow and stop it with a hard timeout (`timeout 30 agent-browser record stop`); if stopping hangs (known), attach the truncated WebM plus the screenshots and say so. - **oRPC Effect path:** pin/unpin a memory entry (rides `handlerGen` + runtime `effect/context`); screenshot before/after; grep logs for `ManagedRuntime disposed`/defect lines (expect none). - **Graceful quit:** record the terminal with `script -q /tmp/<workspace>-shutdown.log` (or `agent-tty` if present), `kill -TERM <pid>` β expect `[shutdown]` lines, `AppRuntime disposed {ms}`, exit 0, no force-exit message; attach the typescript. Exercise the timeout branch once with a scratch hung finalizer β `warn` + timely exit. - **Electron (best effort):** with `Xvfb`, `make dev` + agent-browser via CDP (electron skill): screenshot splash β main window, quit via menu, confirm exit < 5 s; otherwise state that the Electron path is covered by `tests/e2e` in CI and the shared `dispose()` path exercised by `server.ts`. **Gate suites per PR** (plus `make static-check` always): | PR | Must pass | |---|---| | 1 | `src/node/services/di/*`, `serviceContainer.test.ts`, `src/node/orpc/*`, `memoryMeta*`, `make test-integration` | | 2 | + `heartbeatService.test.ts`, `idleCompactionService.test.ts`, `retryManager.test.ts` | | 3 | + `bun test src/node/services`, `src/cli/*.test.ts`; record PR 4 gate numbers | | 4 | + `streamManager*.test.ts`, `aiService.test.ts`, `workspaceService*.test.ts` | | 5 | + tests/ui via `make test-integration`, `src/cli/server.test.ts`, `src/cli/cli.test.ts` | | 6 | converted suites + full `make test-integration` + sandbox startup/shutdown evidence | ## 8. Non-goals (explicit) - streamManager ENGINE CORE conversion (first `AppFiberScope` occupant; separate phase). - `Schema` at persistence boundaries; OAuth refresh/device-flow workers; `AgentStatusService` `setInterval` β Effect. - `initialize()` as a Layer/startup effect (D2); per-service optional tags (D3); `streamBridge` on the runtime; layer finalizers for existing `dispose()` steps. - Any change to persisted data, IPC wire shapes, or oRPC handler bodies beyond the `effect/context` source. ## 9. Assumptions stated - `Effect.context<never>()` inside `EffectRunnerLive` returns the enclosing build context including an upstream `TestClock` entry (PR 2 test; fallback: provide `Clock.Clock` explicitly in the helper). - `Scope.fork(parent)` inside a `Layer.effect` body yields a child closed by the runtime's layer scope on `dispose()` (PR 2 `AppFiberScope` test). - Layer bodies never need to observe sibling construction order; all ordering that matters is expressed as `provide`/`provideMerge` stages or wiring-layer statement order. - `EffectRunner`'s `R = never` constraint is sufficient for every lifecycle fork in the three Phase 11 workers and `StreamManager.schedulePartialWrite` (they only use `Effect.sleep`/`Schedule`/`Effect.sync`/`Effect.tryPromise` β no service tags). Verified by typecheck in PR 2/5. - The desktop tail's teardown remains explicit unless a later RFC proves reverse-construction order compatible; this plan does not attempt it. </details> --- _Generated with `xum` β’ Model: `anthropic:claude-fable-5-1` β’ Thinking: `xhigh` β’ Cost: `$40.69`_ <!-- mux-attribution: model=anthropic:claude-fable-5-1 thinking=xhigh costs=40.69 -->
asm
pushed a commit
to asm/mux
that referenced
this pull request
Sep 2, 2026
β¦WiringLive; thin ServiceContainer; StreamManager runner param (coder#4061) ## Summary Effect migration Phase 11, PR 5 of 6. **The desktop-only tail of the service graph now builds as Effect Layers too**: six `Layer.effectContext` **group layers** (`BrowserLive` Β· `DesktopBridgeLive` Β· `TerminalEditorLive` Β· `MiscDesktopLive` β `OauthLive` Β· `WorkersLive`) construct the ~40 remaining desktop services with their existing argument lists, `DesktopWiringLive` replays the former `ServiceContainer` constructor's setter / listener / global-registration statements **verbatim and in order** after `CoreLive`, and the `ServiceContainer` constructor shrinks to `makeAppRuntime(AppLive(stores))` plus field assignment from the built context (`toORPCContext()` unchanged in shape; `initialize()`/`dispose()`/`shutdown()` untouched, Β§5 order fixed). `StreamManager` gains an optional trailing `runner: EffectRunner`: its partial-write debounce forks through it and `AgentSession` hands the same runner to its `RetryManager`, so both sleep on the app runtime's `Clock` (a `TestClock` in tests). Stacked on PR 1 #4049, PR 2 #4050, PR 3 #4051, PR 4a #4054, PR 4b #4057. Plan: `<details>` at the bottom (Β§2.1/Β§2.2, Β§2.3 invariants, Β§3 "PR 5", Β§5, Β§7). ## Implementation - **`di/tags.ts`** β 47 new `Context.Service` tags (type-only imports; Β§2.1 naming; `Tag` suffix where the bare name is not a `*Service` class or would shadow β `WindowTag`, `QuickJSRuntimeFactoryTag`, `DesktopSessionManagerTag`, β¦), grouped as `BrowserTags | DesktopBridgeTags | TerminalEditorTags | MiscDesktopTags | OauthTags | WorkerTags = DesktopTags`; `AppTags = CoreRootTags | CrossCuttingTags | DesktopTags`. - **`di/layers/desktop.ts`** β six `Layer.effectContext` group layers (each yields its inputs, constructs several services in the constructor's original order, returns a `Context`; the `R` annotation on each group *is* its declared dependency set); `DesktopWiringLive` (`Layer.effectDiscard`, yields every collaborator first, then the wiring statements); composition below. - **`di/layers/app.ts`** β `AppLive = DesktopLive βΉ CoreLive βΉ CoreOptionsFromDesktopLive βΉ CrossCuttingLive βΉ MemoryMetaLive βΉ runtimeSeams`. **`core.ts`** β `StreamManagerLive` passes `yield* EffectRunnerTag` (5th ctor arg); `CoreInputTags` gains `EffectRunnerTag`. - **`serviceContainer.ts`** β constructor = build + 66 `this.x = get(Tag)` lines; service imports type-only; the never-read private `ptyService` field is gone (the PTY lives in the graph under `PTY` for `TerminalService`). `initialize()`, `toORPCContext()`, `shutdown()`, `dispose()` byte-identical. - **`streamManager.ts`** β `constructor(β¦, eventSink = () => undefined, runner: EffectRunner = defaultEffectRunner)`, `public readonly effectRunner`; `schedulePartialWrite`'s `runSync(forkIn)`/`runFork` and `interruptPartialWriteFiber`'s `runFork(Fiber.interrupt)` go through it; both `Scope.close` sites stay `Effect.runFork` (async-close precedent). **`agentSession.ts`/`retryManager.ts`** β `AgentSessionStreamManager` gains `readonly effectRunner?: EffectRunner`; `new RetryManager(β¦, this.streamManager.effectRunner)` (undefined β RetryManager's default, so doubles and the `aiService` fallback are unchanged). - **Tests** β `serviceContainer.test.ts`: exhaustive `Record<keyof Omit<ORPCContext, "headers" | "effect/context" | "effect/wrap">, Tag>` identity test (a new ORPC field without a tag fails to compile), a desktop-wiring behavioral test (bindings, every `set*` collaborator, idle-compaction outcome forwarding, SSH-prompt global registration observed via `isInteractiveHostKeyApprovalAvailable()`, a timing listener), and a `dispose()`/`shutdown()` order test via spies on the public methods already spied today; the original assertions are unchanged. `streamManager.test.ts`: the debounce fires on the injected runner's `TestClock` (red-checked against the global-runtime fork). ## PR 5 notes ### Group DAG (re-derived from the constructor argument lists) | Group | Services, in the former constructor's order | Declared requirements (`R`) | |---|---|---| | `BrowserLive` | BrowserBridgeTokenManager β AgentBrowserSessionDiscovery β BrowserControl β BrowserSessionStateHub β BrowserBridgeServer | Config | | `DesktopBridgeLive` | DesktopSessionManager β DesktopTokenManager β DesktopBridgeServer | Config, Experiments, Workspace | | `TerminalEditorLive` | PTY β Terminal β Editor β Tokenizer β Instructions | Config, SecretsStore, Workspace, SessionUsage, AI, Provider | | `MiscDesktopLive` | QuickJSRuntimeFactory, SshPrompt, **Window**, Backup, AgentPluginInstall, Project (needs SshPrompt), Update, Server, MenuEvent, Voice, Coder (singleton), ServerAuth, WorkspaceLifecycleHooks, WorktreeArchiveSnapshot | Config, SecretsStore, ProvidersConfigStore, Experiments, Policy, Provider, MCPServerManager, WorkspaceMcpOverrides | | `OauthLive` | McpOauth β MuxGatewayOauth β MuxGovernorOauth β CodexOauth β CoderOauth β CopilotOauth | Config, ProvidersConfigStore, FileLeaseManager, MCPConfig, Provider, Policy, Telemetry, **Window** | | `WorkersLive` | IdleCompaction β Heartbeat β Timeline β Refine (needs Timeline) β AgentStatus (needs Tokenizer, Window) | Config, **EffectRunner**, Experiments, History, ExtensionMetadata, Workspace, Task, IdleDispatcher, Memory, MemoryMeta, AI, SessionUsage, **Tokenizer**, **Window** | | `DesktopWiringLive` | the former constructor's 12 wiring blocks, verbatim, in order β incl. #4043's `backupService.setProjectService/setMemoryNotifier` after the `projectService.set*` lines (rebased) β runs after `CoreLive`, so core listeners still precede desktop ones | Config, CrossCuttingTags, CoreTags, DesktopTags | ``` DesktopBase = Layer.mergeAll(MiscDesktopLive, BrowserLive, DesktopBridgeLive, TerminalEditorLive) // true siblings DesktopUpper = Layer.mergeAll(OauthLive, WorkersLive).pipe(Layer.provideMerge(DesktopBase)) // both need Base DesktopLive = DesktopWiringLive.pipe(Layer.provideMerge(DesktopUpper)) AppLive = DesktopLive βΉ CoreLive βΉ CoreOptionsFromDesktopLive βΉ CrossCuttingLive βΉ MemoryMetaLive βΉ (AppFiberScope βΉ EffectRunner βΉ Stores) ``` "True siblings" was checked constructor by constructor (I6 table): no base-group constructor takes or calls another desktop service, so their relative build order is a don't-care. The two upper groups' edges (`WindowService`, `TokenizerService`) are the only cross-group dependencies and are expressed with `provideMerge`, never with `mergeAll` argument order. ### I6 constructor side-effect audit (38 constructions moved here; full 38-row table with file:line cites in the first PR comment) | Group | Constructors (args exactly as the former constructor passed them) | Beyond capturing args | Order that matters | |---|---|---|---| | Browser | `BrowserBridgeTokenManager()` Β· `AgentBrowserSessionDiscoveryService({resolveWorkspaceCandidatePathsFn})` Β· `BrowserControlService({discovery, resolveSessionEnvFn})` Β· `BrowserSessionStateHub({control})` Β· `BrowserBridgeServer({discovery, tokenManager, stateHub})` | token manager: own unref'd cleanup `setInterval` (as before); bridge server: unattached `WebSocketServer({noServer})` | intra-group arg order only | | DesktopBridge | `DesktopSessionManager({config, experimentsService, workspaceService})` Β· `DesktopTokenManager()` Β· `DesktopBridgeServer({sessionManager, tokenManager})` | token manager: own unref'd cleanup `setInterval` (as before) | intra-group arg order; core `Workspace` (staging) | | TerminalEditor | `PTYService()` Β· `TerminalService(config, pty, secretsStore)` Β· `EditorService(config, workspaceService)` Β· `TokenizerService(sessionUsage, ai, provider)` Β· `InstructionsService(config, ai, tokenizer)` | none (the tokenizer *worker* is created at `workerPool.ts` import time, not by the ctor β see Observations) | intra-group arg order | | Misc | `QuickJSRuntimeFactory()` Β· `SshPromptService()` Β· `WindowService()` Β· `BackupService(config, {gitRepo, payload})` Β· `AgentPluginInstallService(config, {isEnabled, mcpServerManager, workspaceMcpOverridesService})` Β· `ProjectService(config, sshPrompt, secretsStore)` Β· `UpdateService(config)` Β· `ServerService()` Β· `MenuEventService()` Β· `VoiceService(config, provider, policy, providersStore)` Β· `coderService` Β· `ServerAuthService(config)` Β· `WorkspaceLifecycleHooks()` Β· `WorktreeArchiveSnapshotService(config)` | `AgentPluginInstallService`: un-awaited startup journal reconcile + module-level discovery gate (as before, from its own args); `UpdateService`: `config.getUpdateChannel()` + un-awaited `initialize()` (no-op outside Electron) | `Project` after `SshPrompt` (same group, in order); `AgentPluginInstall` after core (staging) | | OAuth | `McpOauthService(config, mcpConfig, window, telemetry)` Β· `MuxGatewayOauthService(providersStore, provider, window)` Β· `MuxGovernorOauthService(config, window, policy)` Β· `CodexOauthService(providersStore, provider, window)` Β· `CoderOauthService(providersStore, fileLeaseManager, provider, window, policy)` Β· `CopilotOauthService(provider, window)` | **`CoderOauthService` subscribes `providerService.onConfigChanged`** (`coderOauthService.ts:373`) β a declared *core* dependency; `AIService`'s own subscription (S3) still precedes it because every desktop group builds above `CoreLive`; no other desktop ctor subscribes to `providerService` | after Misc (`WindowService`) via `OauthLive βΉ DesktopBase` | | Workers | `IdleCompactionService(config, history, extensionMetadata, executeIdleCompaction, runner)` Β· `HeartbeatService(config, extensionMetadata, workspace, task, idleDispatcher, runner)` Β· `TimelineService(config, history, experiments)` Β· `RefineService(config, memory, memoryMeta, history, ai, experiments, {timeline, sessionUsage, emitChatMessage, acquireTurnExclusion})` Β· `AgentStatusService(config, history, tokenizer, extensionMetadata, workspace, window, ai, {sessionUsage, requestAnalyticsIngest})` | none (scopes/fibers/intervals start in `start()`; `subscribeToWorkspace` is a wiring statement) | `Refine` after `Timeline` (same group); after Misc (`Window`) + TerminalEditor (`Tokenizer`) via `WorkersLive βΉ DesktopBase` | No moved constructor reads a setter-provided collaborator or registers listeners on `workspaceService`/`aiService`/`taskService`/`memoryConsolidationService`/`mcpServerManager` (only `CoderOauthService` β `providerService`, above). Wiring statements that used to sit *between* constructions now run after all of them; none of the constructors that followed them read the wired state, so the observable order of effects is unchanged. ### Split decision Raw product diff is +1149 / β517 (8 files), above the plan's ~600-line heuristic; I evaluated a 5a/5b split along group boundaries and kept one PR: the surface is mechanical relocation (~330 wiring + ~250 constructor-call lines moved verbatim, 184 lines of tags β `git diff --color-moved=dimmed-zebra origin/main -- src/node/services/serviceContainer.ts src/node/services/di/layers/desktop.ts` dims them), a mergeable 5a would need a throwaway hybrid constructor, and the wiring move would still land whole in one half. ### Deviations from the plan / observations - **`RetryManager` site.** The plan places the runner hand-off in `streamManager.ts`; the constructor is in `agentSession.ts` β reached via the optional `AgentSessionStreamManager.effectRunner` field. - **Tokenizer worker starts ~1 s later in `xum server`/ACP (not desktop).** `workerPool.ts` creates the tokenizer `Worker` at import time; `main` reached it via `serviceContainer.ts`'s early `TokenizerService` import (~0.9 s after spawn, before `effect`/`di/*` loaded), now via `core.ts` β `aiService` β `historyService` β `tokenizer` (~1.9 s). Total startup is unchanged (spawn β `initialize completed` β 2.55 s on both) and the desktop is unaffected (`desktop/main.ts` imports `tokenizer` first), but a SIGTERM *during* the worker's β19 s encoding load waits for its current module evaluation on both trees (so a "1 s after init" probe read 0.6 s vs 1.6 s β strace: the gap sits between `exit(0)` and the worker thread's exit, not in `dispose()`, which is 70β120 ms on both). Steady-state shutdown is unchanged (table). Left as is: pre-existing import-time worker creation, unrelated to the composition root; an explicit warm-up call site is a follow-up candidate, not a bug. - `DesktopWiringLive` is a `Layer.effectDiscard` over an `Effect.gen` body whose only yields are service tags (synchronous); no finalizers, no forks (I5) β same shape as `CoreWiringLive`. ### Pre-review audits (plan Β§3) 1. **Interruption posture** β moved forks: `StreamManager.schedulePartialWrite` (`runner.runSync(forkIn(β¦, resourceScope))` / whitebox `runner.runFork`) and `interruptPartialWriteFiber` (`runner.runFork(Fiber.interrupt)`) β unsupervised through the runner exactly as through the global runtime; interrupted by the stream's resource-scope close and by re-arm, unchanged. `RetryManager` forks through the injected runner; cancelled by `cancel()`/`dispose()` as before. No forks in `di/layers/`. 2. **Uninterruptible teardown** β `dispose()`/`shutdown()` bodies byte-identical; the Β§5 order is now asserted. 3. **No defect escapes** β no new Promise facades; `makeAppRuntime` stays the one throw site (throwing-layer test passes through the deeper graph). 4. **Spy-seam check** β `rg 'spyOn\(' src/node/services/serviceContainer.test.ts tests/ipc tests/ui`: every target is a public method on an instance the container still exposes β intercepted, since each field *is* the context instance (identity test). Arity: only `StreamManager` gained a trailing optional param; `AgentSessionStreamManager` gained an optional readonly field. Typecheck of every test proves it. 5. **Sync-start** β the new debounce test pins that `partialWriteFiber` is armed synchronously and fires on `TestClock.adjust`. 6. **I6** β table above. 7. **I3** β `memoryConsolidationService.ts` not in the diff. ### Re-recorded gate numbers (R7/R8) Sibling worktrees under one scratch dir with shared `node_modules`, interleaved runs; `origin/main` = `1c81235c1` (4b) vs this branch. Host: 96 cores, CPU PSI `some avg60` β 39β41 %. | metric | origin/main (4b) | branch (PR 5) | note | |---|---|---|---| | `tsgo --noEmit` wall, 3 interleaved pairs (median, minβmax) | 12.01 s (11.47β14.04) | 11.94 s (11.82β14.71) | flat | | `tsgo --extendedDiagnostics` (renderer) | types 1 930 432 Β· check 10.50 s | types 1 934 836 (+0.23 %) Β· check 9.43 s | noise | | `new ServiceContainer(stores)` in-process, 3 runs Γ 15: **cold** (first) median | 27 ms (24β29) | **35 ms** (35β44) | **+β8 ms cold** β Layer first-use for 7 more layers + 3 composition nodes (trend 12 β 18 β 23 β 27 β 35 ms across PR 3/4a/4b/main/PR 5; `main`'s 27 includes the imperative desktop constructor) | | β¦ **warm** median (min) | 1.87 ms (1.11) | 2.18 ms (1.65) | +β0.3 ms | | `[startup] AppRuntime built` in `xum server` (10 runs) | 11 ms (core only) | 16 ms (whole graph) | not comparable (main excludes the desktop ctor) | | spawn β `AppRuntime built` / β `initialize completed` (3 pairs) | 2.35 s / 2.55 s | 2.32 s / 2.55 s | unchanged | | `ServiceContainer.initialize completed { totalMs }` (10 runs) | 245 (215β336) | 243 (215β279) | unchanged code | | SIGTERM β exit, steady state (25 s after init; 5 pairs) | 151 ms (134β185), exit 0 Γ5 | 169 ms (149β179), exit 0 Γ5 | noise; `[shutdown] AppFiberScope closed` β explicit steps β `[shutdown] AppRuntime disposed` in every transcript | A chained (`provideMerge`-only) composition of the same six groups costs the same (30β42 / 2.1β3.0 ms): the +8 ms is Effect first-use, not sibling concurrency. ### Lessons for PR 6 (TestClock sweep + shutdown hardening + DI contract docs) - `StreamManager` takes a runner now: the partial-write debounce cases in `streamManager.test.ts` can move to `makeTestEffectRunner()` (5th ctor arg; the new test is the template); `RetryManager` gets its runner from `streamManager.effectRunner`, so `agentSession` harness tests can inject a TestClock through a stream-manager double. - The tokenizer worker is created at import time (`workerPool.ts`); `[shutdown]` timing probes must wait for its β19 s load or they measure its module-evaluation tail β PR 6's per-step `[shutdown]` lines will expose the `AppRuntime disposed` β `process.exit` gap. - `Record<keyof Omit<ORPCContext, β¦>, Tag>` is the ORPC exhaustiveness guard (exclude `effect/wrap` with `headers`/`effect/context`). Six group layers + three composition nodes cost +8 ms cold / +0.3 ms warm β record the per-layer first-use cost in the contract doc. ## Validation - `make static-check` green. `bun test` gate (`streamManager*`, `aiService`, `serviceContainer`, `coreServicesRoot`, `di/*`, `retryManager`, `heartbeat`, `idleCompaction`, `cli/server`, `cli/cli`) 375/375; all 26 `agentSession*` suites 279/279; `bun test src/node/services src/cli src/node/orpc src/node/acp` 7057 pass / 15 fail β the known host baselines (taskGitPatchEngine Γ2, WorkspaceTurnManager Γ2, agent_skill_delete, BackupRepoCache Γ9) + one `attachmentService.completedReports` flake that passes in isolation on both trees. - `TEST_INTEGRATION=1 bun x jest tests` (tests/ipc + tests/ui): 669 pass / 77 fail / 49 skipped β all environment baselines: provider-backed suites (`403 Forbidden` from the AI bridge / missing xAI key), SSH/Docker rows, four `src/**/__tests__` bun:test files jest picks up, and `terminal.test.ts` (1) Β· `sendModeDropdown.test.ts` (1) Β· `reportRelocation.test.ts` (1), which fail identically on **pristine `origin/main`** (re-run in the foreground in the sibling worktree). CI is the lane for the provider suites. - **Dogfooding** (headless Coder host, `XUM_LOG_LEVEL=debug DEV_SERVER_SANDBOX_ARGS=--clean-projects make dev-server-sandbox`): log order `AppRuntime built { ms: 24 }` β `initialize starting` β six step durations β `initialize completed { totalMs: 233 }`; no `ManagedRuntime disposed`/defect lines. agent-browser: loaded the app (`v0.28.3-nightly.148-28-g909dadd90`), added a scratch git repo as a project, sent "Reply with exactly the single word: pong" β worktree workspace created, model replied `pong`, Stats tab populated (screenshot). **oRPC Effect path:** memory experiment enabled, `memory.save` β `setPinned true` β `list` (`pinned: true`) β `setPinned false` over `/orpc` (all `handlerGen` + runtime `effect/context`), then pinned/unpinned from the Memory tab; `memory-meta.json` flipped `pinned` true β false (screenshot). **Graceful quit:** SIGTERM β `[shutdown] AppFiberScope closed { ms: 1 }` β `AgentStatusService stopped` β `terminateAll()` β `[analytics-worker] Shutting down, closing DuckDB` β `[shutdown] AppRuntime disposed { ms: 7 }` β nodemon `clean exit`, 191 ms, exit 0; plus the 5 steady-state pairs in the table (exit 0 Γ10). Not exercised headless: Electron `before-quit` (same `dispose()`; `tests/e2e` in CI).   https://github.com/user-attachments/assets/0d3e4c76-82d2-4344-80ae-b1dba493ecaf ## Risks - **Lowβmedium.** The one behavioral surface is the wiring relocation: every statement is verbatim and in order, the constructors that used to run between wiring lines are audited as not observing them (I6), the `tests/ipc` behavioral gate matches pristine `main`, and the desktop wiring test pins each collaborator edge. `dispose()`/`shutdown()` are unchanged and their order is asserted. - Startup: +β8 ms cold construction; `initialize()` unchanged; tokenizer-worker import-order shift in `xum server`/ACP (observation above) β no functional change. --- <details> <summary>π Implementation Plan</summary> # Effect migration β Wave 3 / Phase 11: ManagedRuntime + Layer dependency injection ## 0. Summary Replace the two hand-written composition roots (`createCoreServices` + the `ServiceContainer` constructor) with an **Effect `Layer` graph** built once per process by a **`ManagedRuntime`** ("AppRuntime"), while keeping every service class, constructor signature, Promise facade, private method, and test seam compatible. The runtime becomes (a) the owner of the app-lifetime `Scope`, (b) the provider of `"effect/context"` for oRPC Effect-native handlers, and (c) the source of two runtime seams: an **`EffectRunner`** (context-bound, *unsupervised* runner that lets clock-driven workers run on a `TestClock`) and an **`AppFiberScope`** (a runtime-owned, *supervised* scope whose close is awaited by `dispose()` β the slot the streamManager engine core will occupy later). Six stacked, independently mergeable PRs. Product PRs keep existing tests unchanged; only the final test-modernization PR edits tests. Net product LoC β **+420** (per-PR estimates below). Service classes are *not* rewritten β Layers are thin adapters around existing constructors; cycle-breaking setter wiring moves into explicit "wiring layers" that replay today's order. Unlocks (not done here): streamManager ENGINE CORE conversion, `TestClock` for timing suites, app-lifetime scopes. ## 1. Verified current state (evidence) - **Roots.** `src/node/services/coreServices.ts:103-389` (`createCoreServices`: 25 constructions, 12 `turnRequestBuilderBindings` writes, ~14 setters) and `src/node/services/serviceContainer.ts:161-575` (45 more constructions; `aiService.on(...)`/`workspaceService.on(...)` analytics wiring at 474-574; global registrations `setGlobalCoderService/setSshPromptService` at 469-471). `new ServiceContainer(stores)` is called by `headlessEnvironment.ts:111`, `tests/ipc/setup.ts`, `src/cli/server.ts:132`, `src/node/acp/serverConnection.ts:155`, `src/desktop/main.ts:653`; `src/cli/run.ts:661` and `src/cli/workflow.ts:376` call `createCoreServices` directly. β two graph roots (App vs Core), five process entry points, all constructing **synchronously**. - **Startup.** `ServiceContainer.initialize()` (577-642) awaits six `initialize()`s (no try/catch; failure propagates to `main.ts:1255-1265` "Startup Failed" dialog + quit; `server.ts`/ACP log and exit), then sync `start()`s idleCompaction/heartbeat/agentStatus, then two fire-and-forget sweeps. All constructors are synchronous; two have side effects on **declared constructor dependencies** only (`AIService` β `streamManager.setEventSink`, `WorkspaceService` β `backgroundProcessManager.on/aiService.on`). - **Teardown.** `dispose()` (746-779) is explicit and hand-ordered (`backgroundProcessManager.beginShutdown()` MUST be first β it is a latch protecting persisted monitor records; bridges stop before sessions close; `terminateAll` late; `timelineService.flush()` last). `shutdown()` (718-732) is a *second* sequence fired concurrently by a second `before-quit` listener (`main.ts:1321`). `main.ts:1296-1304` races `dispose()` against 5 s then `app.quit()`; `cli/server.ts:227-268` has a 5 s `process.exit(1)` force timer; `tests/ipc` cleanup calls `dispose()` then `shutdown()`; `headlessEnvironment.dispose` never calls `services.dispose()`. - **Existing Effect surface.** 25 files import `effect`. Only `Context.Service` tag: `MemoryMeta` (`src/node/orpc/effectContext.ts:21`). `handlerGen` (`@orpc/experimental-effect`) runs `Effect.runPromiseExit` per request and `Effect.provide`s `opts.context["effect/context"]`. `streamBridge.ts` runs streams on the global runtime. Scope-owning workers: `heartbeatService.ts:134-243`, `idleCompactionService.ts:86-122` (`Scope.makeUnsafe` + `Effect.runSync(Scope.close(..))`, valid only because their fibers suspend solely on the clock), `oauthFlowManager.ts:164`, `streamManager.ts:4767/4054` (already `Effect.runFork(Scope.close(..))` β the async-close precedent). `memoryConsolidationService.ts:667-703, 837-860`: check-and-reserve funnels with zero suspensions before `inFlight.set`/`harvestInFlight.set`. - **effect@4.0.0-rc.112 API (verified in `node_modules/effect/dist`).** `Context.Service<Self, Shape>()("id")` (module `Context`, not `ServiceMap`); `Layer.{succeed,sync,effect,effectContext,effectDiscard,provide,provideMerge,mergeAll,build,buildWithScope}` (no `Layer.scoped`; `Layer.effect` strips `Scope` from R); `ManagedRuntime.make(layer)` β `{ runSync, runSyncExit, runFork, runPromise, runPromiseExit, contextEffect, cachedContext, scope, dispose(), disposeEffect }`; `Effect.{runSyncWith,runForkWith,runPromiseWith,runPromiseExitWith}(context)`; `Effect.context<R>()`; `Effect.serviceOption`; `Scope.{fork,forkUnsafe,close,provide}`; `TestClock` from `effect/testing` (`layer, adjust, setTime, withLive`); `Clock.Clock` is a `Context.Reference` (defaulted; `TestClock.layer()` overrides it). - **ManagedRuntime internals the design relies on** (`ManagedRuntime.js`): `make` creates `scope = Scope.makeUnsafe("parallel")` and `layerScope = Scope.forkUnsafe(scope, "sequential")`; the first `runX` forks a build fiber over `Layer.buildWithMemoMap` β a **fully synchronous layer graph builds synchronously**, so `runtime.runSync(Effect.context())` succeeds and sets `cachedContext`; afterwards every `runX` is `Effect.runβ¦With(cachedContext)` (no extra async boundary). Fibers started through `runtime.runX` are registered in `scope` (`onFiberStart: Fiber.runIn(scope)`). `dispose()` = `Scope.close(scope)` (interrupt registered fibers in parallel β layer finalizers sequentially in reverse), after which any `runtime.runX` dies with `"ManagedRuntime disposed"`. - **Layer composition semantics.** `Layer.mergeAll(A, B)` is *not* a dependency resolver: B's requirements are not satisfied by A's outputs; requirements bubble up. Dependencies are satisfied only via `Layer.provide`/`provideMerge` chains. Siblings in `mergeAll` may build concurrently. - **Test seams that pin signatures** (Explore report): private-method spies (`Config.saveConfig`, `WorkspaceService.retireKernelWorkflowRunReferences/startStartupRecovery/createSession/updateAgentStatus`, `MCPServerManager.startServers`, `AgentPluginInstallService.reconcileJournals`, β¦); module-level export spies (`agentStatusService.generateWorkspaceStatus`, `sshConnectionPool.verifyHostKeyAgainstPolicyEffect`, β¦); direct construction in tests (`Config` 44 files, `HistoryService` 22, `MemoryMetaService` 11, `WorkspaceService` 7, `IdleDispatcher` 6, `StreamManager` 4, `ServiceContainer` 3); partial-mock casts (`InitStateManager` 193, `AIService` 158, `TaskService` 149, `ORPCContext` 62). `effectBridge.test.ts:24-30` builds a partial `ORPCContext` via `buildOrpcEffectContext` + `as unknown as ORPCContext`. - **Timing probes** (TestClock candidates): `heartbeatService.test.ts` 6 real sleeps, `idleCompactionService.test.ts` 2, `retryManager.test.ts` 3 `setSystemTime`, `streamManager.test.ts` 7 (partial-write debounce), `streamBridge.test.ts` 11 (heartbeat ticker), OAuth device-flow suites 14 (non-goal). ## 2. Target architecture ### 2.1 Building blocks (all under `src/node/services/di/`; the *only* directory allowed to import `Layer`/`Context`/`ManagedRuntime`/`TestClock`) | Module | Contents | |---|---| | `tags.ts` | One `Context.Service` tag per service class provided by the graph. Type-only imports of service classes β no runtime import cycles. Ids `"xum/<Name>"`. Naming: class name minus trailing `Service` (`MemoryMeta`, `Workspace`, `History`); classes without that suffix or colliding with an exported name get a `Tag` suffix (`ConfigTag`, `StreamManagerTag`, `IdleDispatcherTag`). Exports the unions `CoreTags` and `AppTags`. | | `effectRunner.ts` | `interface EffectRunner { runSync<A,E>(e: Effect<A,E,never>): A; runSyncExit; runFork; runPromise; runPromiseExit }` β a **context-bound, unsupervised** runner whose methods accept only effects with **no service requirements** (`R = never`; defaulted references like `Clock` do not appear in `R`). That makes "not a service locator" type-enforced: a fiber that needs services must take them as explicit constructor dependencies and, if it must be awaited on shutdown, fork into `AppFiberScope`. `defaultEffectRunner` = the global `Effect.runX` (today's exact behavior). `effectRunnerFromContext(ctx)` = `Effect.runβ¦With(ctx)`. `EffectRunnerTag` + `EffectRunnerLive = Layer.effect(EffectRunnerTag, Effect.map(Effect.context<never>(), effectRunnerFromContext))`, placed at the **base** of the graph so the captured context contains only refs (`Clock`, later `Logger`/`Random`) plus stores. Fibers forked through it are owned by the worker's own `Scope` (explicit `start/stop`), **not** by the ManagedRuntime; `runtime.dispose()` does not interrupt them. Services import only this file from `di/`. | | `appFiberScope.ts` | `AppFiberScopeTag: Scope.Closeable`. `AppFiberScopeLive = Layer.effect(AppFiberScopeTag, Effect.gen(function*(){ const parent = yield* Effect.scope; return yield* Scope.fork(parent, "parallel"); }))` β a child of the runtime's layer scope. Fibers forked into it via `Effect.forkIn(_, appFiberScope)` are interrupted **and awaited** when the scope closes. This is the **supervised** seam for I/O-suspended fibers (engine core, later). `ServiceContainer.dispose()` closes it explicitly and early (Β§5) so interrupted fibers can still use their dependencies during finalization; `runtime.dispose()` later re-closes it idempotently as a backstop. No production occupant in Phase 11; the seam exists with tests. | | `appRuntime.ts` | `makeAppRuntime(layer)`: `ManagedRuntime.make(layer)` + **eager synchronous build** (`runtime.runSync(Effect.context<R>())`; `assert(runtime.cachedContext !== undefined)`); a layer body that suspends is a programming error and throws here β exactly where a throwing constructor throws today, so every entry point's existing catch/dialog/log path is preserved. `disposeAppRuntime(runtime, timeoutMs)` and `closeScopeBounded(scope, timeoutMs)` share one shape: `Effect.uninterruptible` teardown shell around `Effect.interruptible(target.pipe(Effect.timeout(timeoutMs)))` where `target` is `runtime.disposeEffect` resp. `Scope.close(scope, Exit.void)` (never a non-cancellable JS Promise wrapper); `Effect.catchTag("TimeoutError", β¦)` + `Effect.catchDefect` β `log.warn`; run via `Effect.runPromise`; **never rejects**; idempotent (`Scope.close` is idempotent; `disposeEffect` is guarded by a latch). Verify the exact rc `Effect.timeout` error type at implementation time (rc.112: fails with `Cause.TimeoutError`, `_tag: "TimeoutError"`). Module doc comment = the DI contract (Β§2.3, Β§5). | | `layers/stores.ts` | `StoresLive(stores: ConfigStores)` = `Layer.mergeAll` of `Layer.succeed` for `ConfigTag`, `SessionLocatorTag`, `ProvidersConfigStoreTag`, `SecretsStoreTag`, `FileLeaseManagerTag` (true siblings β no inter-dependencies). `StoresFromCoreOptionsLive` reproduces the `opts.x ?? new X(config.rootDir)` defaults of `coreServices.ts:106-112` for the CLI root. | | `layers/core.ts` | `CoreOptionsTag` (today's `CoreServicesOptions` minus stores β carries the *optional* cross-cutting services exactly as today). **PR 3:** `CoreProjectionLive = Layer.effectContext(...)` wrapping the existing `createCoreServices` body and returning a `Context<CoreTags>` (coarse projection, zero behavior change). **PR 4:** peel into per-service `Layer.effect(Tag, Effect.gen(...))` layers composed in **explicit dependency stages** (`Layer.provideMerge` between stages; `Layer.mergeAll` only for true siblings within a stage β every sibling claim below was checked against the constructor argument lists in `coreServices.ts` and must be re-checked in the PR): S1 History Β· InitState Β· Provider Β· BackgroundProcess Β· ExtensionMetadata Β· MemoryMeta Β· TerminalAttention Β· IdleDispatcher Β· WorkspaceMcpOverrides(default) Β· `TurnRequestBuilderBindingsTag` (`Layer.succeed(_, {})`) β S2a SessionUsage Β· Goal Β· Memory β S2b StreamManager (needs SessionUsage) β S3 AIService β S4 Consolidation Β· MCPConfig β S5 MCPServerManager β S6 Workspace β S7 Task β S8 TurnManager β `CoreWiringLive` (`Layer.effectDiscard`, **`Effect.sync` only β no `acquireRelease`**, replays `coreServices.ts:137-166, 209-210, 258-270, 288-325, 349-352, 360-367` in order). | | `layers/desktop.ts` | `CrossCuttingLive` (policy, telemetry, experiments, backup, sessionTiming, analytics, devTools, workspaceMcpOverrides, browserBridgeTokenManager), `CoreOptionsFromDesktopLive` (derives `CoreOptionsTag` from those tags + `extensionMetadataPath`), then **group layers** (`Layer.effectContext` returning a `Context` of several tags, constructed in today's order): `BrowserLive`, `DesktopBridgeLive`, `OauthLive`, `WorkersLive` (idleCompaction, heartbeat, agentStatus, timeline, refine), `TerminalEditorLive`, `MiscDesktopLive`; staged with `provideMerge` where one group needs another. `DesktopWiringLive` (`Effect.sync` only) = setters + `aiService.on/workspaceService.on/memoryConsolidationService.on` wiring + global registrations. | | `layers/app.ts` | `AppLive(stores) = DesktopLive βΉ CoreLive βΉ CoreOptionsFromDesktopLive βΉ CrossCuttingLive βΉ AppFiberScopeLive βΉ EffectRunnerLive βΉ StoresLive(stores)` β read `X βΉ Y` as "X is *provided with* Y, and both stay exposed", i.e. **`X.pipe(Layer.provideMerge(Y))`** (rc.112 signature: `provideMerge(that: provider)(self: consumer)`; the *right-hand* operand is the dependency). Every `βΉ` keeps all tags visible in the final `Context<AppTags>`. | | `testEffectRunner.ts` (test helper, sibling of `testHistoryService.ts`) | `makeTestEffectRunner()` β `{ runner, adjust(duration), setTime(ms), dispose }` over one memoised `ManagedRuntime.make(EffectRunnerLive.pipe(Layer.provideMerge(TestClock.layer())))` (the TestClock is the *provider*; the runner captures it), so the worker under test and `TestClock.adjust` share one `TestClock`. | ### 2.2 Composition roots after Phase 11 ```mermaid flowchart TB Stores["StoresLive(stores)<br/>Config Β· SessionLocator Β· ProvidersConfigStore Β· SecretsStore Β· FileLeaseManager"] Runner["EffectRunnerLive (unsupervised, ref-bound)<br/>+ AppFiberScopeLive (supervised, closed on dispose)"] Cross["CrossCuttingLive (desktop only)<br/>Policy Β· Telemetry Β· Experiments Β· Analytics Β· SessionTiming Β· DevTools Β· WorkspaceMcpOverrides Β· Backup"] Opts["CoreOptionsTag<br/>desktop: derived from CrossCutting Β· CLI: Layer.succeed(opts)"] Core["CoreLive<br/>PR 3: coarse CoreProjectionLive β PR 4: stages S1β¦S8 + CoreWiringLive"] Desk["DesktopLive β group Layers<br/>Browser Β· DesktopBridge Β· OAuth Β· Workers Β· TerminalEditor Β· Misc β DesktopWiringLive"] RT["AppRuntime = ManagedRuntime.make(AppLive)<br/>eager sync build Β· Context<AppTags> = oRPC effect/context Β· dispose() last"] Stores --> Runner --> Cross --> Opts --> Core --> Desk --> RT CLI["CLI root (xum run / xum workflow)<br/>createCoreServices(opts) = makeAppRuntime(CoreLive βΉ StoresFromCoreOptionsLive βΉ AppFiberScopeLive βΉ EffectRunnerLive βΉ succeed(CoreOptionsTag, opts))"] Core -.same Layer definitions.-> CLI ``` `ServiceContainer` keeps its public fields and the synchronous `new ServiceContainer(stores)`: the constructor calls `makeAppRuntime(AppLive(stores))`, stores `this.serviceContext = runtime.runSync(Effect.context<AppTags>())`, and assigns fields via `Context.get(this.serviceContext, Tag)`. `toORPCContext()` returns the same plain fields plus `"effect/context": this.serviceContext`. `initialize()` is untouched. `dispose()` follows Β§5. `createCoreServices(opts)` keeps its signature and return shape plus `runtime` and `appFiberScope` fields; `cli/run.ts:1574-1580` and `cli/workflow.ts:275-320` cleanup lists gain `closeScopeBounded(appFiberScope)` before `session.dispose()` and `disposeAppRuntime(runtime)` as the final step (PR 3). **Staged composition skeleton (PR 4 shape; direction matters):** ```ts // Each stage depends only on stages defined above it. `provideMerge` keeps both sides exposed. const S1 = Layer.mergeAll(HistoryLive, InitStateLive, ProviderLive, /* β¦ true siblings only */); const S2a = Layer.mergeAll(SessionUsageLive, GoalLive, MemoryLive).pipe(Layer.provideMerge(S1)); const S2b = StreamManagerLive.pipe(Layer.provideMerge(S2a)); // StreamManager needs SessionUsage const S3 = AIServiceLive.pipe(Layer.provideMerge(S2b)); // β¦ S4 β¦ S8 likewise β¦ export const CoreLive = CoreWiringLive.pipe(Layer.provideMerge(S8)); // wiring runs after every service exists ``` **oRPC typing.** `OrpcEffectServices` (in `effectContext.ts`) becomes `AppTags`, so `ORPCContext["effect/context"]: Context<AppTags>` is satisfied by the runtime context in production. `buildOrpcEffectContext` stays as the narrow test helper it already is (its only caller, `effectBridge.test.ts:24-30`, deliberately builds a partial context and casts it via `unknown`); no production caller remains after PR 1. ### 2.3 Invariants (the "DI contract"; enforced by tests and the `appRuntime.ts` doc comment) | # | Invariant | Constraint served | |---|---|---| | I1 | **Phase 11 compatibility contract, not permanent law:** layer bodies are synchronous (`Layer.succeed`/`Layer.sync`/`Layer.effect` over sync effects; `acquireRelease` with a sync acquire is fine). `makeAppRuntime` asserts the eager build completed. Future async resource acquisition belongs in `initialize()`/startup effects or an explicit async factory root (`ServiceContainer.create()`), never silently inside a layer. | #2 sync-start, #5 startup parity | | I2 | Services never hold the `ManagedRuntime`. Workers hold an `EffectRunner` (default `defaultEffectRunner`); `EffectRunner.runX` β‘ `Effect.runβ¦With(ctx)` β same sync-start semantics as `Effect.runX`, and still valid after `runtime.dispose()`, so late callbacks cannot hit "ManagedRuntime disposed". Supervision, when needed, is explicit via `AppFiberScope`. | #2, #3 | | I3 | Per-call pipelines (`Effect.runPromise(this.effectsβ¦)` facades) and the `memoryConsolidationService` funnels are untouched. **Audit item:** no DI lookup, runner call, or `await` may be inserted before `inFlight.set` / `harvestInFlight.set`. Only lifecycle forks in workers move to `this.runner.runX`. | #1, #2 | | I4 | Constructors, facades, private methods, module exports unchanged; new constructor parameters are optional, trailing, defaulting to `defaultEffectRunner`. | #1, #6 | | I5 | Teardown order stays explicit in `dispose()`/`shutdown()`. Layer bodies and wiring layers register **no finalizers** in Phase 11 (`Effect.sync` only), so `runtime.dispose()` reorders nothing. The one supervised resource (`AppFiberScope`) is closed explicitly at a fixed position in `dispose()` (Β§5). | #3 | | I6 | Wiring layers replay today's setter/listener order; a constructor may touch only its *declared* dependencies (built earlier by staging). Per-PR audit: grep each moved constructor for calls on setter-provided collaborators β forbidden. Dependency order is expressed only with `provide`/`provideMerge` stages; never rely on `mergeAll` sibling order. | #6 | | I7 | No persisted-data changes; DI is in-process only. | #4 | | I8 | Every process root builds from the same Layer definitions (`CoreLive` shared by App and CLI). Unit harnesses (`createTestHistoryService`, `createTestToolConfig`, `createAgentSessionHarness`, β¦) intentionally bypass Layers. | #7 | ### 2.4 Decisions and alternatives (product-LoC deltas) <details> <summary>D1 β Granularity: coarse core first (PR 3), per-service core stages behind a decision gate (PR 4), group layers for the desktop tail (PR 5)</summary> Honest framing: the three unlocks (engine-core async scope, TestClock, app-lifetime scope) are delivered by `AppRuntime` + `EffectRunner` + `AppFiberScope` and **do not require per-service layers**. Per-service core layers are *migration leverage*: typed requirement sets for the engine-core work, per-service swap in integration tests, explicit dependency stages instead of implicit ordering. - **(A) Per-service everywhere** (~70 layers): +~900/β~700. Desktop tail has hand-tuned teardown that must not become finalizers, so per-service there buys uniformity only. Rejected. - **(B) Recommended:** PR 3 coarse `CoreProjectionLive` (+~120/β~10) delivers the shared root and runtime ownership; PR 4 peels the core into staged per-service layers (+~330/β~290) **only if** PR 3's typecheck/startup budgets hold (gate in Β§3); desktop tail as ~6 group layers (+~170/β~150). Tags for all services either way (~3 LoC each). - **(C) Coarse only:** stop after PR 3 + desktop projection (~+200 total). Cheapest; the engine-core phase would then redo dependency declarations. Remains the fallback if PR 4's gate fails. </details> <details> <summary>D2 β Async init stays an explicit `initialize()`; Layers construct only</summary> Folding `initialize()` into layer construction would make the build asynchronous (breaks I1), change failure semantics (today: fail-fast β dialog/log), and move the six-step order into memoised builds. Deferred; a later phase can turn `initialize()` into `runtime.runPromise(startupEffect)` with per-step `Effect.timeout`. </details> <details> <summary>D3 β Optional cross-cutting services stay optional via `CoreOptionsTag`, not `Effect.serviceOption`</summary> Core layer bodies read `opts.policyService` etc. exactly as today, so CLI (absent) vs desktop (present) behavior is unchanged and no service gains a new `undefined` branch. </details> <details> <summary>D4 β Two seams instead of one: `EffectRunner` (unsupervised, clock-bound) + `AppFiberScope` (supervised)</summary> A single "runtime handle" conflates two needs. Workers need *which clock* (TestClock) and must keep sync `stop()`; the engine core needs *who awaits me on shutdown*. Explicit `Clock` injection per worker was rejected (a `provideService(Clock.Clock, β¦)` at every fork site, and it does not extend to other refs). </details> <details> <summary>D5 β oRPC: `effect/context` = the runtime's `Context`; `handlerGen` unchanged</summary> `handlerGen` already `Effect.provide`s the context per request; providing ~70 entries instead of one is one Map merge per request. The existing `echoAsync`/`echoEffect` probes record the delta as a **diagnostic** in the PR body (no stable benchmark harness exists to make it a hard gate). `effect/wrap` not needed. </details> ## 3. Phasing β six stacked PRs Every PR: `make static-check`; gate suites below; existing tests unchanged (PR 6 is the only PR that edits tests, and only to replace real-timer probes). Before `@codex review`, run the **house pre-review audits**: 1. **Interruption posture** β list every new/moved fiber fork; state what interrupts it and when (unsupervised via `EffectRunner` + worker scope, or supervised via `AppFiberScope`). 2. **Uninterruptible teardown** β teardown effects are `Effect.uninterruptible` end-to-end; bounded waits inside use `Effect.interruptible(Effect.timeout(...))` (house shape from #4038). 3. **No defect escapes** β `disposeAppRuntime`/`closeScopeBounded` and every Promise facade fold defects; `makeAppRuntime` is the one place allowed to throw (constructor semantics). 4. **Spy-seam check** β `rg 'spyOn\(' src/node/services/<touched>.test.ts tests/` per touched class; constructor arity and private-method Promise signatures unchanged (typecheck of tests proves it). 5. **Sync-start check** β a fork through `EffectRunner` runs to its first `sleep` before `runFork` returns (mirrors `heartbeatService.ts:199-202`). 6. **Constructor side-effect audit (I6)** for every constructor moved into a Layer in that PR. 7. **Zero-suspension audit (I3)** whenever `memoryConsolidationService` is in the diff. ### PR 1 β Skeleton: AppRuntime + Stores/MemoryMeta layers + runtime-backed `effect/context` + dispose hook (+~150 LoC) **Scope** - `di/tags.ts` (`ConfigTag`, `SessionLocatorTag`, `ProvidersConfigStoreTag`, `SecretsStoreTag`, `FileLeaseManagerTag`, `MemoryMeta` moved from `orpc/effectContext.ts`, which re-exports it; `AppTags` union). - `di/layers/stores.ts` (`StoresLive`), `di/layers/core.ts` with `MemoryMetaLive = Layer.effect(MemoryMeta, Effect.map(ConfigTag, c => new MemoryMetaService(c.rootDir)))`, `di/layers/app.ts` (`AppLive(stores) = MemoryMetaLive βΉ StoresLive`). - `di/appRuntime.ts` (`makeAppRuntime`, `disposeAppRuntime`); `APP_RUNTIME_DISPOSE_TIMEOUT_MS` in `src/constants/`. - `coreServices.ts`: `CoreServicesOptions.memoryMetaService?` (precedent: `workspaceMcpOverridesService?`). - `serviceContainer.ts`: build runtime first, pass `Context.get(ctx, MemoryMeta)` to `createCoreServices`, `public readonly runtime`, `toORPCContext()["effect/context"] = this.serviceContext`, `dispose()` appends `disposeAppRuntime` behind a `disposed` latch; new `log.debug("[startup] AppRuntime built", { ms })`. - `orpc/effectContext.ts`: `OrpcEffectServices = AppTags`; `buildOrpcEffectContext` retyped/test-helper doc. - `headlessEnvironment.dispose` calls `await services.dispose()` before removing the temp dir (the bench harness currently leaks the container; runtime ownership starts here). **Acceptance** - `di/appRuntime.test.ts`: (a) sync build sets `cachedContext`; (b) a layer with an async body makes `makeAppRuntime` **throw synchronously** (I1 enforced); (c) probe layers' finalizers run in reverse order on dispose; (d) dispose is idempotent and bounded (hung finalizer β `warn`, resolves at the timeout); (e) `runtime.runFork` after the eager build starts synchronously. - `serviceContainer.test.ts`: `Context.get(toORPCContext()["effect/context"], MemoryMeta) === services.memoryMetaService`; `dispose()` closes the runtime; `dispose(); shutdown()` (tests/ipc order) is clean; a throwing layer surfaces as a synchronous throw from `new ServiceContainer(stores)` (same shape as today's constructor throw β existing entry-point catch paths). - `effectBridge.test.ts`, `memoryMeta*.test.ts` unchanged and green; echo-probe overhead recorded in the PR body. - Gate: `bun test src/node/services/di src/node/services/serviceContainer.test.ts src/node/orpc src/node/services/memoryMeta*` Β· `make test-integration` Β· `make static-check`. **Rollback:** `git revert`; classes untouched. ### PR 2 β Runtime seams: `EffectRunner` + `AppFiberScope`; TestClock on idleCompaction/heartbeat/retryManager (+~140 LoC) **Scope** - `di/effectRunner.ts`, `di/appFiberScope.ts`; `AppLive` gains `AppFiberScopeLive βΉ EffectRunnerLive` at the base; `ServiceContainer` exposes `appFiberScope` (used only by `dispose()` in Phase 11) and closes it per Β§5. - `IdleCompactionService`, `HeartbeatService`, `RetryManager`: trailing optional `runner: EffectRunner = defaultEffectRunner`; every lifecycle `Effect.runSync/runFork` in `start/stop/schedule/cancel` becomes `this.runner.runX`. Deadline math (`Date.now()`/injected `now`) unchanged. `ServiceContainer` passes `Context.get(ctx, EffectRunnerTag)` to the two workers; `RetryManager` keeps the default until PR 5 (so `streamManager.ts` is untouched here). - `di/testEffectRunner.ts` helper. **Acceptance** - New TestClock tests (existing real-timer tests untouched β they exercise the `defaultEffectRunner` path, which is production behavior wherever no runner is injected): heartbeat `STARTUP_DELAY_MS` β first tick after `adjust`, one tick per `CHECK_INTERVAL_MS`, no ticks after `stop()`; idleCompaction initial delay + cadence; retryManager fires exactly at `delayMs`, `cancel()` before `adjust` never fires. - Pin runtime facts: `runner.runSync(Scope.close(scope, Exit.void))` completes synchronously for a fiber suspended on a TestClock sleep; `runFork` through the runner reaches its first sleep synchronously; `Effect.context<never>()` inside `EffectRunnerLive` sees the upstream `TestClock` (else the helper provides `Clock.Clock` explicitly β same seam, one line). - `AppFiberScope` contract tests: (i) an **I/O-suspended** fiber (interruptible `Effect.async` that never resolves, with a cancel path) forked with `Effect.forkIn(_, appFiberScope)` is interrupted **and awaited** by `closeScopeBounded(appFiberScope)` β and this happens *before* the explicit teardown steps in `dispose()` (assert ordering against a spy on `desktopBridgeServer.stop`); (ii) a fiber forked via `EffectRunner` is *not* interrupted by either close (documents the asymmetry); (iii) `disposeAppRuntime` afterwards idempotently re-closes the already-closed child scope (no error, no second finalizer run). - If `TestClock.adjust` leaves continuations pending, the helper adds `Effect.yieldNow`/`Fiber.await` β decided by tests. - Gate: `heartbeatService.test.ts`, `idleCompactionService.test.ts`, `retryManager.test.ts`, `serviceContainer.test.ts`, `di/*`, tests/ipc. **Rollback:** revert restores defaults; no call site depends on the new params. ### PR 3 β Shared core root: coarse `CoreProjectionLive` + `createCoreServices` facade + CLI runtime disposal (+~120 / β~10) **Scope** - Tags for the remaining 19 core services; `CoreOptionsTag`; `StoresFromCoreOptionsLive`. - `CoreProjectionLive = Layer.effectContext(Effect.gen(function*(){ const opts = yield* CoreOptionsTag; const stores = yield* β¦; const core = buildCoreGraph({ ...opts, ...stores }); return Context.make(History, core.historyService).pipe(Context.add(...)) }))` where `buildCoreGraph` is today's `createCoreServices` body, unchanged, renamed. - `createCoreServices(opts)` = `makeAppRuntime(CoreProjectionLive βΉ StoresFromCoreOptionsLive βΉ AppFiberScopeLive βΉ EffectRunnerLive βΉ Layer.succeed(CoreOptionsTag, opts))`, returns today's `CoreServices` object read from the context plus `runtime` and `appFiberScope`. `cli/run.ts` and `cli/workflow.ts` cleanup lists append `closeScopeBounded(appFiberScope)` **before** `session.dispose()` and `disposeAppRuntime(runtime)` **after** `backgroundProcessManager.terminateAll()`. - `ServiceContainer` stops calling `createCoreServices`; `AppLive = CoreProjectionLive βΉ CoreOptionsFromDesktopLive βΉ CrossCuttingLive βΉ β¦` (cross-cutting services move into `CrossCuttingLive` now because core options derive from them). Desktop constructions otherwise stay in the constructor. **Acceptance** - Identity test: every `CoreServices` field `===` `Context.get(ctx, Tag)`; `serviceContainer.test.ts` unchanged and green. - **Decision gate for PR 4** recorded in the PR body: `make typecheck` wall time, `[startup] AppRuntime built` ms and `initialize` totals vs `origin/main` baseline from the sandbox (Β§7). Proceed to PR 4 only if typecheck regresses < 10 % and startup within noise; otherwise stop at (C). - Gate: `bun test src/node/services`, `src/cli/*.test.ts` (run/workflow/server/cli), tests/ipc, `make static-check`. **Rollback:** revert restores the imperative call; PR 1/2 unaffected. ### PR 4 β Peel the core into staged per-service Layers + `CoreWiringLive` (+~330 / β~290 β net β +40; split 4a/4b if > ~600 diff lines) **Scope** - Stages S1, S2a, S2b, S3β¦S8 (Β§2.1 + skeleton in Β§2.2) as `Layer.effect` adapters with today's argument lists; `CoreWiringLive` (`Effect.sync` only) replays the wiring lines in order; `CoreLive = CoreWiringLive.pipe(Layer.provideMerge(S8))` replaces `CoreProjectionLive`; `buildCoreGraph` deleted. - Before writing any stage: re-derive the DAG from the constructor argument lists (the plan's stage table was checked once; `StreamManager β SessionUsage` is the kind of edge that turns "siblings" into a stage split) and record it in the PR body. - 4a (S1βS3: leaves through `AIService`) / 4b (S4βS8 + wiring) if needed β 4a alone is mergeable because the remaining services are built by a shrunken projection layer that reads S1βS3 from the context. **Acceptance** - Wiring assertions that are behavioral (a missing wiring line fails them): `turnRequestBuilderBindings` fully populated; goal continuation consumer registered on `idleDispatcher`; `streamManager` MCP manager set; registration probe installed on `extensionMetadata`. - I6 audit table for all 19 constructors in the PR body; missing-provider = compile error (R must be `never` at `makeAppRuntime`) demonstrated by a type-level test (`// @ts-expect-error`). - Gate: as PR 3 plus `streamManager*.test.ts`, `aiService.test.ts`, `workspaceService*.test.ts`. **Rollback:** revert to PR 3's projection. ### PR 5 β `DesktopLive` group layers + `DesktopWiringLive`; thin `ServiceContainer`; `StreamManager` runner param (+~170 / β~150 β net β +20) **Scope** - Tags for the 45 desktop services; six group layers (`Layer.effectContext`, today's construction order inside each; `provideMerge` between groups that depend on each other); `DesktopWiringLive` (`Effect.sync` only) = `serviceContainer.ts:209, 263-265, 271, 288-290, 334-340, 348, 365, 375, 381-382, 434, 438-471, 474-574` in order. - `ServiceContainer` constructor = `makeAppRuntime(AppLive(stores))` + field assignment from the context. `toORPCContext()` unchanged in shape. - `StreamManager`: optional trailing `runner: EffectRunner`; `schedulePartialWrite` fork (`streamManager.ts:1141`) and `RetryManager` construction use it; `Scope.close` stays `Effect.runFork` (existing async-close precedent). `WorkersLive` receives `EffectRunnerTag`. **Acceptance** - All four existing `serviceContainer.test.ts` assertions unchanged; new identity test over `toORPCContext()` fields vs tags; `dispose()`/`shutdown()` call order asserted via spies on the *public* methods already spied today. - I6 audit for the 45 constructors. - Gate: tests/ipc + tests/ui (`make test-integration`), `src/cli/server.test.ts`, `src/cli/cli.test.ts`, `streamManager*.test.ts`, `aiService.test.ts`. ### PR 6 β TestClock adoption sweep + shutdown hardening + contract docs (+~20 LoC product; tests edited) **Scope** - Replace real-sleep cadence probes with `makeTestEffectRunner()` in `heartbeatService.test.ts`, `idleCompactionService.test.ts`, `retryManager.test.ts`, and the partial-write debounce cases of `streamManager.test.ts`; keep **one real-timer smoke test per worker** (guards the `defaultEffectRunner` path). - `cli/server.ts`: `[shutdown]` log lines per step incl. `AppRuntime disposed {ms}`; confirm the whole `dispose()` fits the existing 5 s force-exit budget. - Finalize the contract doc comment in `di/appRuntime.ts` (I1βI8, Β§5). **Acceptance:** converted suites have zero `setTimeout`-based cadence waits (grep in PR body), same assertions; `make test-integration` green; sandbox startup/shutdown evidence (Β§7). ## 4. TestClock story - **Mechanism.** `Effect.sleep`, `Schedule.fixed`, `Effect.timeout`, `Clock.currentTimeMillis` read the `Clock` reference from the running fiber's context. Workers that fork through an `EffectRunner` built under `TestClock.layer()` run on the test clock; `await testRunner.adjust("2 minutes")` advances it. `Date.now()`, `setTimeout`, `setInterval` are unaffected β heartbeat deadline math via injected `now`, `AgentStatusService`'s ref'd `setInterval`, and `backgroundProcessManager` stay on real timers/injected timestamps. - **Benefit now:** `heartbeatService.test.ts` (6), `idleCompactionService.test.ts` (2), `retryManager.test.ts` (3 `setSystemTime` β `adjust`; `Date.now`-based `retryAt` may move to `Clock.currentTimeMillis` only if a test needs both clocks aligned), `streamManager.test.ts` debounce cases (7). - **Deferred:** `streamBridge.test.ts` ticker (11) β needs a context/runner parameter on `subscriptionIterable`; OAuth device-flow polling and `oauthFlowManager.test.ts` (25) β non-goal. - **Stays real:** child-process/PTY/WASM/fs-lock waits (`backgroundProcessManager` 72, `quickjsRuntime` 26, lock sleeps in `workspaceService`/`taskService`), end-to-end suites (tests/ipc, e2e). - **Pinned in PR 2, not assumed:** `adjust` runs due sleeps and their synchronous continuations before resolving (or the helper yields until they do); `Schedule.fixed` anchoring under `TestClock` matches the wall-clock expectations in `heartbeatService.ts:149-155`; sync `Scope.close` of a TestClock-suspended fiber completes synchronously. ## 5. Shutdown protocol 1. **Trigger points unchanged:** `main.ts` `before-quit` (preventDefault β `dispose()` raced with 5 s β `app.quit()`; update-install path fire-and-forget), the second `before-quit` listener's `shutdown()` (unchanged, concurrent), `cli/server.ts` SIGINT/SIGTERM (5 s force exit), ACP `close()`, tests/ipc (`dispose()` then `shutdown()`), headless bench (`dispose()` from PR 1). 2. **`ServiceContainer.dispose()` order:** 1. `backgroundProcessManager.beginShutdown()` β unchanged, first (latch protecting persisted monitor records). 2. **`closeScopeBounded(appFiberScope, APP_FIBER_SCOPE_CLOSE_TIMEOUT_MS)`** β interrupts and awaits supervised fibers *while every dependency they might touch during finalization is still alive*. No occupants in Phase 11; the position is fixed now so the engine-core phase does not have to re-derive it. 3. The existing explicit sequence verbatim (`desktopBridgeServer.stop()` β¦ `terminateAll()` β¦ `timelineService.flush()`). 4. **`disposeAppRuntime(runtime, APP_RUNTIME_DISPOSE_TIMEOUT_MS)`** β closes the runtime scope (interrupts any fiber started via `runtime.runX` β none long-lived in Phase 11; runs layer finalizers β none in Phase 11 by I5). Hung β `warn` at the timeout; never rejects. Budget: 2 s + 2 s inner bounds inside the callers' 5 s outer budgets; the outer race in `main.ts` remains the last line of defense. **Rule for future occupants:** anything forked into `AppFiberScope` must tolerate interruption at any suspension point and must not depend on resources torn down in step 1; anything that needs a Layer finalizer must first prove reverse-construction order is compatible with steps 2β3 (I5). 3. **Latches:** `disposed` makes `dispose()` idempotent (two `before-quit` listeners, tests/ipc dispose+shutdown). `shutdown()` never touches the runtime or `AppFiberScope`. 4. **Late callers:** `EffectRunner` handles keep working after runtime dispose (I2), so a stray `tick()`/`scheduleRetry()` after quit cannot defect. The `ManagedRuntime` is referenced only by `ServiceContainer` and the `createCoreServices` return value. 5. **Worker `stop()` stays synchronous** (`runner.runSync(Scope.close)`) because their fibers suspend only on the clock. The engine core will fork into `AppFiberScope` (step 2.2 awaits it) β the reason both seams exist now. 6. **Crash paths:** unchanged β `uncaughtException`/SIGKILL run no finalizers. Finalizers are best-effort; durable state must remain crash-safe without them (AGENTS.md self-healing rule). Nothing in Phase 11 makes a finalizer the sole guardian of durable state. ## 6. Risk register | # | Risk | L/I | Mitigation | |---|---|---|---| | R1 | A layer body suspends β `runSync` throws at startup | M/H | I1 assert + PR 1 test (b); doc comment; review checklist; entry-point catch paths verified in PR 1 | | R2 | Construction-order side effects differ under staged builds | L/H | I6 audit per moved constructor; explicit `provideMerge` stages; wiring layers replay today's order; tests/ipc as behavioral gate | | R3 | Double teardown (`shutdown()` β₯ `dispose()`; dispose+shutdown in tests) | M/M | `disposed` latch; runtime/AppFiberScope closed only in `dispose()`; PR 1 test | | R4 | Late `runtime.runX` after dispose β defect | M/M | I2: services hold `EffectRunner`, never the ManagedRuntime | | R5 | TestClock semantics differ from assumptions | M/L | PR 2 pins them before any suite converts; per-suite fallback to real timers | | R6 | effect v4 RC churn (`Context`β`ServiceMap`, Layer renames) | M/M | All `Layer/Context/ManagedRuntime/TestClock` imports confined to `di/`; exact pin | | R7 | Startup latency regression (splash) | L/M | `AppRuntime built` ms + `initialize` totals vs baseline in sandbox; PR 3 gate | | R8 | Typecheck slowdown from large requirement unions | L/L | PR 3 gate records `make typecheck` wall time; fallback (C) | | R9 | Per-request `Effect.provide` of a ~70-entry Context | L/L | echo-probe diagnostic in PR 1/5 bodies | | R10 | Spy seams / direct-construction tests break | L/H | I4; optional trailing params; audit 4; typecheck of tests | | R11 | CLI roots forget to dispose runtime/scope | M/L | PR 3 wires both cleanups; `src/cli/*.test.ts` assert the cleanup steps exist | | R12 | Someone forks long-lived I/O work via `EffectRunner` expecting dispose to await it | M/M | Doc on `EffectRunner` ("unsupervised"); PR 2 asymmetry test; review audit 1 | **Rollback:** PRs are stacked; revert in reverse order (6β1). Service classes are never modified except for optional trailing params, so any revert restores the previous composition root wholesale with no data or API implications. ## 7. Dogfooding (per PR; evidence attached to the PR body) **Environment (headless Coder host, no `DISPLAY`):** ```bash XUM_LOG_LEVEL=debug DEV_SERVER_SANDBOX_ARGS="--clean-projects" make dev-server-sandbox # background bash task; prints URL + XUM_ROOT ``` - **Startup correctness:** `<XUM_ROOT>/logs/*.log` shows, in order: `Loading services...`, `[startup] AppRuntime built {ms}`, `[startup] ServiceContainer.initialize starting`, six step durations, `[startup] ServiceContainer.initialize completed {totalMs, stepDurationsMs}`. Paste baseline (`origin/main`) vs branch numbers. - **Startup-never-crash parity (once, locally, not committed):** inject a throwing scratch layer β `xum server` exits non-zero with the existing logged error and **no** unhandled-rejection trace; for desktop, confirm by code path (`loadServices()` rejects β `main.ts:1255` dialog) and via `src/cli/server.test.ts`/ACP tests. - **UI smoke (agent-browser):** `open <url>` β `snapshot -i` β add a scratch git repo as a project β create a workspace β send one message β `screenshot` the loaded app and the response; `attach_file` both. **Video:** start `agent-browser record` before the flow and stop it with a hard timeout (`timeout 30 agent-browser record stop`); if stopping hangs (known), attach the truncated WebM plus the screenshots and say so. - **oRPC Effect path:** pin/unpin a memory entry (rides `handlerGen` + runtime `effect/context`); screenshot before/after; grep logs for `ManagedRuntime disposed`/defect lines (expect none). - **Graceful quit:** record the terminal with `script -q /tmp/<workspace>-shutdown.log` (or `agent-tty` if present), `kill -TERM <pid>` β expect `[shutdown]` lines, `AppRuntime disposed {ms}`, exit 0, no force-exit message; attach the typescript. Exercise the timeout branch once with a scratch hung finalizer β `warn` + timely exit. - **Electron (best effort):** with `Xvfb`, `make dev` + agent-browser via CDP (electron skill): screenshot splash β main window, quit via menu, confirm exit < 5 s; otherwise state that the Electron path is covered by `tests/e2e` in CI and the shared `dispose()` path exercised by `server.ts`. **Gate suites per PR** (plus `make static-check` always): | PR | Must pass | |---|---| | 1 | `src/node/services/di/*`, `serviceContainer.test.ts`, `src/node/orpc/*`, `memoryMeta*`, `make test-integration` | | 2 | + `heartbeatService.test.ts`, `idleCompactionService.test.ts`, `retryManager.test.ts` | | 3 | + `bun test src/node/services`, `src/cli/*.test.ts`; record PR 4 gate numbers | | 4 | + `streamManager*.test.ts`, `aiService.test.ts`, `workspaceService*.test.ts` | | 5 | + tests/ui via `make test-integration`, `src/cli/server.test.ts`, `src/cli/cli.test.ts` | | 6 | converted suites + full `make test-integration` + sandbox startup/shutdown evidence | ## 8. Non-goals (explicit) - streamManager ENGINE CORE conversion (first `AppFiberScope` occupant; separate phase). - `Schema` at persistence boundaries; OAuth refresh/device-flow workers; `AgentStatusService` `setInterval` β Effect. - `initialize()` as a Layer/startup effect (D2); per-service optional tags (D3); `streamBridge` on the runtime; layer finalizers for existing `dispose()` steps. - Any change to persisted data, IPC wire shapes, or oRPC handler bodies beyond the `effect/context` source. ## 9. Assumptions stated - `Effect.context<never>()` inside `EffectRunnerLive` returns the enclosing build context including an upstream `TestClock` entry (PR 2 test; fallback: provide `Clock.Clock` explicitly in the helper). - `Scope.fork(parent)` inside a `Layer.effect` body yields a child closed by the runtime's layer scope on `dispose()` (PR 2 `AppFiberScope` test). - Layer bodies never need to observe sibling construction order; all ordering that matters is expressed as `provide`/`provideMerge` stages or wiring-layer statement order. - `EffectRunner`'s `R = never` constraint is sufficient for every lifecycle fork in the three Phase 11 workers and `StreamManager.schedulePartialWrite` (they only use `Effect.sleep`/`Schedule`/`Effect.sync`/`Effect.tryPromise` β no service tags). Verified by typecheck in PR 2/5. - The desktop tail's teardown remains explicit unless a later RFC proves reverse-construction order compatible; this plan does not attempt it. </details> --- _Generated with `xum` β¦
asm
pushed a commit
to asm/mux
that referenced
this pull request
Sep 2, 2026
β¦n] timing, DI contract docs (coder#4062) ## Summary Effect migration Phase 11, PR 6 of 6 β the phase's closing PR, and the only one allowed to edit existing tests. **(1) TestClock sweep:** the real-timer probes that actually waited on a worker's clock now run on a `TestClock` through the workers' injected `EffectRunner` (`makeTestEffectRunner()`), with exactly one default-runner smoke per worker guarding the production (real-clock) path. **(2) Shutdown hardening:** every step of `ServiceContainer.dispose()`, the `xum server` signal handler and the CLI roots' cleanup lists now writes a `[shutdown] <step> {ms}` debug line (new `shutdownStep` helper, no suspension added between synchronous steps), which made the long-standing "SIGTERM ~6 s after startup exits ~11 s later" gap measurable β and root-caused it to a spot **outside** `dispose()` (below). **(3) DI contract:** the `di/appRuntime.ts` module comment is now the durable contract β invariants I1βI8, the two-seam asymmetry, the Β§5 shutdown order, the rule for future `AppFiberScope` occupants, the recorded Layer-machinery costs, and the R6 firewall. Stacked on PR 1 #4049, PR 2 #4050, PR 3 #4051, PR 4a #4054, PR 4b #4057, PR 5 #4061 (all on `main`). Plan: `<details>` at the bottom (Β§3 "PR 6", Β§4 TestClock story, Β§5 shutdown protocol, Β§2.3 invariants, Β§7 dogfooding). ## Implementation - **`retryManager.test.ts`** β the hand-rolled `setTimeout`/`clearTimeout` spy harness (`runNextTimer()`, `scheduledTimers`) is gone; every timing case drives the backoff with `clock.adjust(...)` on a `makeTestEffectRunner()` passed as the 4th ctor arg. "Timer pending / no timer pending" assertions became `isRetryPending` plus a negative `adjust` far past any backoff (a still-armed retry would fire). PR 2's separate `retryManager.testClock.test.ts` is folded in (its three cases are now the main suite's "exactly at the backoff delay", "cancel clears pending retry timer" and "reschedulesβ¦" cases) and deleted. One default-runner smoke remains: it intercepts the real `setTimeout` registration once to prove the default runner's sleep lands on Effect's default clock with the backoff delay and fires `onRetry` β without a 2 s wall-clock wait. `setSystemTime` stays: it only pins `Date.now()` for the `scheduledAt` equality and never drove timing. - **`streamManager.test.ts`** β "interrupts a pending debounced partial write when the stream ends" (the one real cadence wait: `sleep(throttleMs + 200)` β 720 ms) now injects a TestClock runner (5th ctor arg, PR 5's template) and proves the negative with `adjust(2 Γ throttle)` β 47 ms. A new default-runner smoke ("a debounced partial write arms a real setTimeout through the default runner", β 7 ms; intercepts the real timer registration like the RetryManager smoke, so there is no wall-clock window to overrun) replaces the real-clock coverage it took away. - **`idleCompactionService.test.ts`** β gains the missing default-runner smoke (`start()` β nothing sweeps within 20 ms of a 60 s initial delay β synchronous `stop()`); its `testClock` sibling's doc comment claimed the real-timer suite covered the default runner, but that suite never called `start()`. **`heartbeatService.test.ts`** β unchanged except a comment marking "startup does not fire heartbeats immediately" as the retained smoke (see "What the plan's counts meant"). - **`shutdownStep.ts`** (new) β `shutdownStep(name, run)`: writes `[shutdown] <name> starting` before `run()`, times it, and writes `[shutdown] <name> {ms}` on completion, all at debug level β so a hung step (an awaited disposer that never settles *or* a blocking synchronous call) is named by the last line before silence. Overloads: a thenable-returning step (thenable check, not `instanceof Promise`, so a cross-realm promise is still awaited) is awaited and logged via `.finally`; a synchronous step is logged before returning with **no Promise created**, so wrapping one adds no suspension point and adjacent synchronous teardown statements still run on the same tick (audit 2). Errors propagate unchanged. The `Promise` overload is declared first because `Promise<void>` is assignable to `void`; `@typescript-eslint/no-misused-promises` guards the other direction. `shutdownStep.test.ts` pins the sync-no-Promise / thenable-awaited / error-propagation contract. - **`serviceContainer.ts`** β `disposeOnce()` wraps each of its 21 explicit steps; `closeScopeBounded`/`disposeAppRuntime` keep their own lines; `[shutdown] ServiceContainer.dispose starting/completed {totalMs}` bracket the sequence. Order byte-identical (asserted by the PR 5 order test). **`cli/server.ts`** β `terminalService.closeAllSessions` and `serverService.stopServer` are timed and a final `[shutdown] exiting {totalMs}` is the last JS-side line before `process.exit(0)`. **`cli/runCleanup.ts`** β the loop times each step; **`cli/workflow.ts`** β `disposeWorkflowResources` now builds the same kind of step list and runs it through `runBestEffortCleanup` (same containment as its former eight `try/catch` blocks; warn wording is now `xum workflow: cleanup step failed: <step>`). - **`di/appRuntime.ts`** β module doc comment rewritten as the contract (details in "PR 6 notes"). ## PR 6 notes ### Suite wall time, before β after (bun test, 3 runs each, same loaded host: 96 cores, load β 145, CPU PSI some avg60 β 33 %) | suite | before (wall, minβmax) | after | tests | note | |---|---|---|---|---| | `streamManager.test.ts` | 2.47β2.59 s | **1.69β1.78 s** | 122 β 123 | the 720 ms real wait ("interrupts a pending debounced partial writeβ¦") is now 47 ms on virtual time; +7 ms default-runner smoke | | `retryManager.test.ts` (+ deleted `retryManager.testClock.test.ts`) | 0.31β0.43 s (+ β0.3 s for the separate file) | 0.35β0.42 s | 14 + 3 β 15 | already fake-timer; the value is one harness (`TestClock`) instead of two, and one file instead of two | | `idleCompactionService.test.ts` | 0.69β0.73 s | 0.72β0.78 s | 19 β 20 | +20 ms default-runner smoke that did not exist | | `heartbeatService.test.ts` | 1.78β1.93 s | 1.76β1.77 s | 74 | untouched (comment only) | Grep in the converted files (acceptance): `streamManager.test.ts` no longer waits `setTimeout(β¦, throttleMs + β¦)`; `retryManager.test.ts` has no `spyOn(globalThis, "setTimeout")` outside the single smoke and no `runNextTimer`. ### What the plan's probe counts meant on inspection (deviations) The plan's Β§1 counts (heartbeat 6 Β· idleCompaction 2 Β· retryManager 3 Β· streamManager 7) came from a `setTimeout`/`setSystemTime`/fixture grep. Reading each site: - **heartbeat "6"** β one is the clock-cadence probe ("startup does not fire heartbeats immediately", 100 ms) and is exactly the default-runner smoke to keep; the other five are `waitForCondition` polls and 20 ms settles on **Promise chains** (`tick()`β`resyncFromConfig`βqueue) plus one 300 ms mock dispatch delay whose assertion is `Date.now()` deadline math β plan Β§4 "stays real: injected timestamps". None waits on the scheduler fiber's clock, so a TestClock changes nothing there. Left as is. - **idleCompaction "2"** β both are Promise-settlement waits; the real-timer suite never called `start()`, so there was **no** default-runner smoke to keep β added one. - **retryManager "3"** β the `setSystemTime` calls pin `Date.now()` for `scheduledAt`; the actual timer surrogate was the global `setTimeout` spy harness, which is what the TestClock replaces. `setSystemTime` stays (no test needs the two clocks aligned, so `scheduledAt` stays on `Date.now()`). - **streamManager "7"** β seven fixtures set `lastPartialWriteTime` inside the throttle window, but they call `attachWorkflowRunToToolCall`/`appendPartAndEmit(β¦, false)`, which flush **immediately** and never wait on the debounce. The single real cadence wait was the 720 ms scope-interrupt probe β converted. - `agentSession*` harness tests (optional in the plan): their sleeps wait on `waitForStartupAutoRetryRerunWindow` (plain `setTimeout` inside `agentSession.ts`) and Promise settlement, not on `RetryManager`'s clock β not convertible through a stream-manager double; skipped. - **Red check** on the converted scope-interrupt probe: with the `forkIn(resourceScope)` branch replaced by a plain `runFork`, both the original 720 ms version and the TestClock version still pass β the stream-end path also calls `interruptPartialWriteFiber` directly, so the scope close is a second guard. Same discriminating power before and after (recorded, not changed: the test's purpose is "no late write", which it does prove). ### The "SIGTERM ~6 s after startup exits ~11 s later" gap β root cause and disposition Per-step lines make it unambiguous. `xum server` (`node dist/cli/index.js server`, temp `XUM_ROOT`, `XUM_LOG_LEVEL=debug`, under `script -q -e -f`), 5 runs each: | SIGTERM sent | JS teardown (`Shutting down server...` β `[shutdown] exiting`) | SIGTERM β process gone | exit code | |---|---|---|---| | 6 s after `initialize completed` (β 8.4 s after spawn) | 64β67 ms (`dispose` 61β64 ms) | **10.57 / 10.89 / 10.83 / 10.64 / 10.75 s** | 0 Γ5 | | 30 s after `initialize completed` | 79β85 ms (`dispose` 76β82 ms) | **171 / 161 / 183 / 150 / 168 ms** | 0 Γ5 | In every 6 s run the last JS-side line (`[shutdown] exiting { totalMs: 67 }`) is printed β 70 ms after SIGTERM; the process then lingers β 10.7 s **after `process.exit(0)`**. Nothing inside `dispose()` (every step 0β16 ms; `AppRuntime disposed` 6β16 ms), nothing in `serverService.stopServer()` (1 ms β PR 2's guess was wrong; PR 5's strace was right). Cause: `workerPool.ts` creates the tokenizer `Worker` at import time (β 1.9 s after spawn in `xum server`); the worker evaluates `ai-tokenizer/encoding` (31 MB of encodings), which takes **β 18 s** on this host (`require("ai-tokenizer/encoding")`: 17.9 / 18.1 / 18.1 s standalone). `process.exit()` terminates worker threads via V8 `TerminateExecution`, which cannot interrupt a parse/compile in progress, so the main thread joins the worker until its current module finishes. A controlled repro (`new Worker(tokenizer.worker.js)` + `process.exit` at *t*; unref'd, no other work): | `process.exit` at | process gone at | wait | |---|---|---| | 1 s | 3.1 s | 2.1 s | | 4 s | 5.0 s | 1.0 s | | **7 s** | **17.3 s** | **10.3 s** | | 10 s | 17.5 s | 7.4 s | | 13 s | 17.6 s | 4.5 s | | 16 s | 17.1 s | 1.0 s | i.e. one β 10 s uninterruptible window from β 7 s to β 17 s into the worker's load (a single huge encoding module). SIGTERM 6 s after init lands β 6.5 s into that load β β 10.7 s wait; at 30 s the worker is long done β 150β180 ms. **Disposition:** not a leak inside `dispose()`'s scope (the plan's fix criterion), pre-existing on `main` (PR 2/5 saw the same numbers), and not DI-related β recorded as a follow-up, not fixed here. Follow-up options: create the tokenizer worker lazily on first `run()` (an idle `xum server`/ACP never pays), or split the worker's encoding import per model (`encoding[model.encoding]` is already selected per call) so the uninterruptible window is one encoding, not all of them. The desktop is unaffected in practice (it imports the tokenizer first and shuts down long after startup). Two smaller observations from the transcripts (both pre-existing, both left alone): (a) a one-time 25β45 ms gap right after `[shutdown] AppFiberScope closed` is `source-map-support` (registered by `cli/server.ts`) mapping Effect-internal frames the first time the log helper captures a stack **inside a fiber** β verified standalone: first in-fiber `new Error().stack` 47.8 ms with source maps vs 0.3 ms after / 0.3 ms without; not teardown work. (b) In the CLI roots' lists `appFiberScope.close`/`appRuntime.dispose` are timed by `runBestEffortCleanup` *and* log their own `β¦ closed`/`β¦ disposed` line (`boundedTeardown`); kept uniform rather than special-casing two steps β the outer line adds the Promise-settle time. Whole-`dispose()` latch and both bounded teardowns: unchanged and re-asserted (`serviceContainer.test.ts` "shares one teardown across concurrent dispose() calls", `appRuntime.test.ts` timeout/never-rejects cases, the PR 5 order test). ### Pre-review audits (plan Β§3 preamble) 1. **Interruption posture** β unchanged: no new fibers or forks in product code; `shutdownStep` creates none. The TestClock suites fork the same effects through a `TestClock`-bound runner. 2. **Uninterruptible teardown** β `boundedTeardown` untouched. In `disposeOnce()` synchronous steps are timed without a Promise (no new suspension point); async steps get one `.finally` microtask after an await that already existed. Order asserted unchanged. 3. **No defect escapes** β `shutdownStep` rethrows after logging (containment unchanged: `disposeOnce` propagates as before, `runBestEffortCleanup` contains as before); `log.debug` cannot throw (`safePipeLog` catches). Both bounded teardowns still never reject. 4. **Spy-seam check** β `rg 'spyOn\(' src/node/services/serviceContainer.test.ts tests/`: the same public methods are spied (`desktopBridgeServer.stop`, `desktopSessionManager.closeAll`, `browserBridgeServer.stop`, `analyticsService.dispose`, `timelineService.flush`, `telemetryService.shutdown`) and `shutdownStep` calls them on the instance, so every spy intercepts (56/56 in the container + CLI suites). No constructor arity changed; the converted tests use the existing optional trailing runner params. 5. **Sync-start** β still pinned by the converted suites (`isRetryPending` true and `partialWriteFiber` defined synchronously after the scheduling call, before any `adjust`) and directly by `di/effectRunner.test.ts`. 6. / 7. N/A (no constructor moved; `memoryConsolidationService` not in the diff). ### Phase 11 completion state - **The DI graph now owns:** every service in the process (5 stores β `EffectRunner`/`AppFiberScope` β `MemoryMeta` β 8 cross-cutting β 19 core layers in 8 stages + `CoreWiringLive` β 6 desktop group layers + `DesktopWiringLive`), built once per process by one `ManagedRuntime` (`AppLive` for desktop/`xum server`/ACP/tests-ipc; `CoreRootLive` for `xum run`/`xum workflow`); the oRPC `effect/context`; the two runtime seams (`EffectRunner` in the three clock-driven workers and `StreamManager`/`RetryManager`; `AppFiberScope` with its fixed dispose slot and bounded close); startup/shutdown observability (`[startup]`/`[shutdown]` lines). Product LoC for the whole phase (pre-PR 1 β this branch, non-test): `di/` +2327 (tags 389, layers 1571, runtime/seams/helper β 370), composition roots +360/β851, workers/stream/CLI/misc +237/β89 β net β +1.98k, well above the plan's β +420 estimate (the per-service tags and the layer adapters that restate every constructor call are the bulk; each PR body recorded its actual diff). Service classes untouched except optional trailing runner params. - **Still imperative (explicit non-goals, D2/Β§8):** `ServiceContainer.initialize()` (six awaited `initialize()`s + three `start()`s β a future `runtime.runPromise(startupEffect)` with per-step `Effect.timeout`); `streamBridge.ts` streams on the global runtime (needs a runner/context parameter on `subscriptionIterable`, which would also let `streamBridge.test.ts`'s 11 ticker waits move to a TestClock); the hand-ordered `dispose()`/`shutdown()` steps (layer finalizers would require proving reverse-construction order compatible β I5); `AgentStatusService`'s ref'd `setInterval`; OAuth device-flow polling. - **First `AppFiberScope` occupant (next phase):** the streamManager engine core β fork the per-stream engine fiber into `AppFiberScope` so `dispose()` step 2 interrupts and awaits in-flight streams while `historyService`/`sessionUsage` are still alive; the position, bound (`APP_FIBER_SCOPE_CLOSE_TIMEOUT_MS`), asymmetry tests and occupant rules are in place, so that phase does not have to re-derive shutdown. ## Validation - `make static-check` green (typecheck both projects, prettier, eslint, docs). - Converted/touched suites: `retryManager` 15/15, `streamManager` 123/123, `idleCompactionService` 20/20 + `testClock` 2/2, `heartbeatService` 74/74 + `testClock` 2/2, `serviceContainer` + `runCleanup` + `workflow` + `server` + `cli` 56/56, `di/*`. - Full local gate on this host: `bun test src` **13 900 pass / 12 fail / 8 skip** (834 files, 1045 s) β the 12 are exactly the known host baselines (taskGitPatchEngine Γ2, gitNoHooksEnv Γ3, WorkspaceTurnManager Γ2, agent_skill_delete, BackupRepoCache, WorkspaceFooterBar load flake Γ3), none in files this PR touches. `TEST_INTEGRATION=1 bun x jest tests`: numbers appended when it finishes (provider-backed suites 403 from the AI bridge here; CI is authoritative). CI round 1: `Test / Integration` failed only on `tests/ipc/providers/anthropicCacheStrategy.test.ts` ("Expected cache creation but got 0 tokens" β a live-provider cache-token assertion unrelated to this diff). - **Dogfooding** (headless Coder host): the two `xum server` SIGTERM matrices above (exit 0 Γ10, every `[shutdown]` line present in every transcript); `xum workflow` echo run from the branch β `AppRuntime built` β `ok from pr6` β `[shutdown] backgroundProcessManager.beginShutdown` β `AppFiberScope closed` β `session.dispose` β β¦ β `terminateAll` β `AppRuntime disposed` (the order `workflow.test.ts` pins), exit 0; **dev-server sandbox** (`XUM_LOG_LEVEL=debug DEV_SERVER_SANDBOX_ARGS=--clean-projects make dev-server-sandbox`): `AppRuntime built { ms: 21 }` β `initialize completed`; via agent-browser loaded the app (`v0.28.3-nightly.148-29-gac4fc78c2`), added a scratch git repo as a project, created a worktree workspace, sent "Reply with exactly the single word: pong" β `pong`, Stats tab populated (screenshot below); then SIGTERM to the sandbox backend with the live workspace β full `[shutdown]` sequence incl. `[analytics-worker] Shutting down, closing DuckDB`, `ServiceContainer.dispose completed { totalMs: 82 }`, process gone in 173 ms. Not exercisable here: Electron quit (no `DISPLAY`; covered by `tests/e2e` in CI and the shared `dispose()` path above), provider-backed integration suites (AI bridge 403s β CI lane). ## Risks Low. Product behavior changes are limited to debug-level log lines and the `xum workflow` cleanup list going through the same best-effort runner as `xum run` (same containment; warn wording generalized). The teardown order is unchanged and asserted; the timing helper adds no suspension between synchronous steps. Test changes replace timer surrogates with the runner seam that PRs 2/5 already made production behavior, and each worker keeps one real-clock smoke.  --- <details> <summary>π Implementation Plan</summary> # Effect migration β Wave 3 / Phase 11: ManagedRuntime + Layer dependency injection ## 0. Summary Replace the two hand-written composition roots (`createCoreServices` + the `ServiceContainer` constructor) with an **Effect `Layer` graph** built once per process by a **`ManagedRuntime`** ("AppRuntime"), while keeping every service class, constructor signature, Promise facade, private method, and test seam compatible. The runtime becomes (a) the owner of the app-lifetime `Scope`, (b) the provider of `"effect/context"` for oRPC Effect-native handlers, and (c) the source of two runtime seams: an **`EffectRunner`** (context-bound, *unsupervised* runner that lets clock-driven workers run on a `TestClock`) and an **`AppFiberScope`** (a runtime-owned, *supervised* scope whose close is awaited by `dispose()` β the slot the streamManager engine core will occupy later). Six stacked, independently mergeable PRs. Product PRs keep existing tests unchanged; only the final test-modernization PR edits tests. Net product LoC β **+420** (per-PR estimates below). Service classes are *not* rewritten β Layers are thin adapters around existing constructors; cycle-breaking setter wiring moves into explicit "wiring layers" that replay today's order. Unlocks (not done here): streamManager ENGINE CORE conversion, `TestClock` for timing suites, app-lifetime scopes. ## 1. Verified current state (evidence) - **Roots.** `src/node/services/coreServices.ts:103-389` (`createCoreServices`: 25 constructions, 12 `turnRequestBuilderBindings` writes, ~14 setters) and `src/node/services/serviceContainer.ts:161-575` (45 more constructions; `aiService.on(...)`/`workspaceService.on(...)` analytics wiring at 474-574; global registrations `setGlobalCoderService/setSshPromptService` at 469-471). `new ServiceContainer(stores)` is called by `headlessEnvironment.ts:111`, `tests/ipc/setup.ts`, `src/cli/server.ts:132`, `src/node/acp/serverConnection.ts:155`, `src/desktop/main.ts:653`; `src/cli/run.ts:661` and `src/cli/workflow.ts:376` call `createCoreServices` directly. β two graph roots (App vs Core), five process entry points, all constructing **synchronously**. - **Startup.** `ServiceContainer.initialize()` (577-642) awaits six `initialize()`s (no try/catch; failure propagates to `main.ts:1255-1265` "Startup Failed" dialog + quit; `server.ts`/ACP log and exit), then sync `start()`s idleCompaction/heartbeat/agentStatus, then two fire-and-forget sweeps. All constructors are synchronous; two have side effects on **declared constructor dependencies** only (`AIService` β `streamManager.setEventSink`, `WorkspaceService` β `backgroundProcessManager.on/aiService.on`). - **Teardown.** `dispose()` (746-779) is explicit and hand-ordered (`backgroundProcessManager.beginShutdown()` MUST be first β it is a latch protecting persisted monitor records; bridges stop before sessions close; `terminateAll` late; `timelineService.flush()` last). `shutdown()` (718-732) is a *second* sequence fired concurrently by a second `before-quit` listener (`main.ts:1321`). `main.ts:1296-1304` races `dispose()` against 5 s then `app.quit()`; `cli/server.ts:227-268` has a 5 s `process.exit(1)` force timer; `tests/ipc` cleanup calls `dispose()` then `shutdown()`; `headlessEnvironment.dispose` never calls `services.dispose()`. - **Existing Effect surface.** 25 files import `effect`. Only `Context.Service` tag: `MemoryMeta` (`src/node/orpc/effectContext.ts:21`). `handlerGen` (`@orpc/experimental-effect`) runs `Effect.runPromiseExit` per request and `Effect.provide`s `opts.context["effect/context"]`. `streamBridge.ts` runs streams on the global runtime. Scope-owning workers: `heartbeatService.ts:134-243`, `idleCompactionService.ts:86-122` (`Scope.makeUnsafe` + `Effect.runSync(Scope.close(..))`, valid only because their fibers suspend solely on the clock), `oauthFlowManager.ts:164`, `streamManager.ts:4767/4054` (already `Effect.runFork(Scope.close(..))` β the async-close precedent). `memoryConsolidationService.ts:667-703, 837-860`: check-and-reserve funnels with zero suspensions before `inFlight.set`/`harvestInFlight.set`. - **effect@4.0.0-rc.112 API (verified in `node_modules/effect/dist`).** `Context.Service<Self, Shape>()("id")` (module `Context`, not `ServiceMap`); `Layer.{succeed,sync,effect,effectContext,effectDiscard,provide,provideMerge,mergeAll,build,buildWithScope}` (no `Layer.scoped`; `Layer.effect` strips `Scope` from R); `ManagedRuntime.make(layer)` β `{ runSync, runSyncExit, runFork, runPromise, runPromiseExit, contextEffect, cachedContext, scope, dispose(), disposeEffect }`; `Effect.{runSyncWith,runForkWith,runPromiseWith,runPromiseExitWith}(context)`; `Effect.context<R>()`; `Effect.serviceOption`; `Scope.{fork,forkUnsafe,close,provide}`; `TestClock` from `effect/testing` (`layer, adjust, setTime, withLive`); `Clock.Clock` is a `Context.Reference` (defaulted; `TestClock.layer()` overrides it). - **ManagedRuntime internals the design relies on** (`ManagedRuntime.js`): `make` creates `scope = Scope.makeUnsafe("parallel")` and `layerScope = Scope.forkUnsafe(scope, "sequential")`; the first `runX` forks a build fiber over `Layer.buildWithMemoMap` β a **fully synchronous layer graph builds synchronously**, so `runtime.runSync(Effect.context())` succeeds and sets `cachedContext`; afterwards every `runX` is `Effect.runβ¦With(cachedContext)` (no extra async boundary). Fibers started through `runtime.runX` are registered in `scope` (`onFiberStart: Fiber.runIn(scope)`). `dispose()` = `Scope.close(scope)` (interrupt registered fibers in parallel β layer finalizers sequentially in reverse), after which any `runtime.runX` dies with `"ManagedRuntime disposed"`. - **Layer composition semantics.** `Layer.mergeAll(A, B)` is *not* a dependency resolver: B's requirements are not satisfied by A's outputs; requirements bubble up. Dependencies are satisfied only via `Layer.provide`/`provideMerge` chains. Siblings in `mergeAll` may build concurrently. - **Test seams that pin signatures** (Explore report): private-method spies (`Config.saveConfig`, `WorkspaceService.retireKernelWorkflowRunReferences/startStartupRecovery/createSession/updateAgentStatus`, `MCPServerManager.startServers`, `AgentPluginInstallService.reconcileJournals`, β¦); module-level export spies (`agentStatusService.generateWorkspaceStatus`, `sshConnectionPool.verifyHostKeyAgainstPolicyEffect`, β¦); direct construction in tests (`Config` 44 files, `HistoryService` 22, `MemoryMetaService` 11, `WorkspaceService` 7, `IdleDispatcher` 6, `StreamManager` 4, `ServiceContainer` 3); partial-mock casts (`InitStateManager` 193, `AIService` 158, `TaskService` 149, `ORPCContext` 62). `effectBridge.test.ts:24-30` builds a partial `ORPCContext` via `buildOrpcEffectContext` + `as unknown as ORPCContext`. - **Timing probes** (TestClock candidates): `heartbeatService.test.ts` 6 real sleeps, `idleCompactionService.test.ts` 2, `retryManager.test.ts` 3 `setSystemTime`, `streamManager.test.ts` 7 (partial-write debounce), `streamBridge.test.ts` 11 (heartbeat ticker), OAuth device-flow suites 14 (non-goal). ## 2. Target architecture ### 2.1 Building blocks (all under `src/node/services/di/`; the *only* directory allowed to import `Layer`/`Context`/`ManagedRuntime`/`TestClock`) | Module | Contents | |---|---| | `tags.ts` | One `Context.Service` tag per service class provided by the graph. Type-only imports of service classes β no runtime import cycles. Ids `"xum/<Name>"`. Naming: class name minus trailing `Service` (`MemoryMeta`, `Workspace`, `History`); classes without that suffix or colliding with an exported name get a `Tag` suffix (`ConfigTag`, `StreamManagerTag`, `IdleDispatcherTag`). Exports the unions `CoreTags` and `AppTags`. | | `effectRunner.ts` | `interface EffectRunner { runSync<A,E>(e: Effect<A,E,never>): A; runSyncExit; runFork; runPromise; runPromiseExit }` β a **context-bound, unsupervised** runner whose methods accept only effects with **no service requirements** (`R = never`; defaulted references like `Clock` do not appear in `R`). That makes "not a service locator" type-enforced: a fiber that needs services must take them as explicit constructor dependencies and, if it must be awaited on shutdown, fork into `AppFiberScope`. `defaultEffectRunner` = the global `Effect.runX` (today's exact behavior). `effectRunnerFromContext(ctx)` = `Effect.runβ¦With(ctx)`. `EffectRunnerTag` + `EffectRunnerLive = Layer.effect(EffectRunnerTag, Effect.map(Effect.context<never>(), effectRunnerFromContext))`, placed at the **base** of the graph so the captured context contains only refs (`Clock`, later `Logger`/`Random`) plus stores. Fibers forked through it are owned by the worker's own `Scope` (explicit `start/stop`), **not** by the ManagedRuntime; `runtime.dispose()` does not interrupt them. Services import only this file from `di/`. | | `appFiberScope.ts` | `AppFiberScopeTag: Scope.Closeable`. `AppFiberScopeLive = Layer.effect(AppFiberScopeTag, Effect.gen(function*(){ const parent = yield* Effect.scope; return yield* Scope.fork(parent, "parallel"); }))` β a child of the runtime's layer scope. Fibers forked into it via `Effect.forkIn(_, appFiberScope)` are interrupted **and awaited** when the scope closes. This is the **supervised** seam for I/O-suspended fibers (engine core, later). `ServiceContainer.dispose()` closes it explicitly and early (Β§5) so interrupted fibers can still use their dependencies during finalization; `runtime.dispose()` later re-closes it idempotently as a backstop. No production occupant in Phase 11; the seam exists with tests. | | `appRuntime.ts` | `makeAppRuntime(layer)`: `ManagedRuntime.make(layer)` + **eager synchronous build** (`runtime.runSync(Effect.context<R>())`; `assert(runtime.cachedContext !== undefined)`); a layer body that suspends is a programming error and throws here β exactly where a throwing constructor throws today, so every entry point's existing catch/dialog/log path is preserved. `disposeAppRuntime(runtime, timeoutMs)` and `closeScopeBounded(scope, timeoutMs)` share one shape: `Effect.uninterruptible` teardown shell around `Effect.interruptible(target.pipe(Effect.timeout(timeoutMs)))` where `target` is `runtime.disposeEffect` resp. `Scope.close(scope, Exit.void)` (never a non-cancellable JS Promise wrapper); `Effect.catchTag("TimeoutError", β¦)` + `Effect.catchDefect` β `log.warn`; run via `Effect.runPromise`; **never rejects**; idempotent (`Scope.close` is idempotent; `disposeEffect` is guarded by a latch). Verify the exact rc `Effect.timeout` error type at implementation time (rc.112: fails with `Cause.TimeoutError`, `_tag: "TimeoutError"`). Module doc comment = the DI contract (Β§2.3, Β§5). | | `layers/stores.ts` | `StoresLive(stores: ConfigStores)` = `Layer.mergeAll` of `Layer.succeed` for `ConfigTag`, `SessionLocatorTag`, `ProvidersConfigStoreTag`, `SecretsStoreTag`, `FileLeaseManagerTag` (true siblings β no inter-dependencies). `StoresFromCoreOptionsLive` reproduces the `opts.x ?? new X(config.rootDir)` defaults of `coreServices.ts:106-112` for the CLI root. | | `layers/core.ts` | `CoreOptionsTag` (today's `CoreServicesOptions` minus stores β carries the *optional* cross-cutting services exactly as today). **PR 3:** `CoreProjectionLive = Layer.effectContext(...)` wrapping the existing `createCoreServices` body and returning a `Context<CoreTags>` (coarse projection, zero behavior change). **PR 4:** peel into per-service `Layer.effect(Tag, Effect.gen(...))` layers composed in **explicit dependency stages** (`Layer.provideMerge` between stages; `Layer.mergeAll` only for true siblings within a stage β every sibling claim below was checked against the constructor argument lists in `coreServices.ts` and must be re-checked in the PR): S1 History Β· InitState Β· Provider Β· BackgroundProcess Β· ExtensionMetadata Β· MemoryMeta Β· TerminalAttention Β· IdleDispatcher Β· WorkspaceMcpOverrides(default) Β· `TurnRequestBuilderBindingsTag` (`Layer.succeed(_, {})`) β S2a SessionUsage Β· Goal Β· Memory β S2b StreamManager (needs SessionUsage) β S3 AIService β S4 Consolidation Β· MCPConfig β S5 MCPServerManager β S6 Workspace β S7 Task β S8 TurnManager β `CoreWiringLive` (`Layer.effectDiscard`, **`Effect.sync` only β no `acquireRelease`**, replays `coreServices.ts:137-166, 209-210, 258-270, 288-325, 349-352, 360-367` in order). | | `layers/desktop.ts` | `CrossCuttingLive` (policy, telemetry, experiments, backup, sessionTiming, analytics, devTools, workspaceMcpOverrides, browserBridgeTokenManager), `CoreOptionsFromDesktopLive` (derives `CoreOptionsTag` from those tags + `extensionMetadataPath`), then **group layers** (`Layer.effectContext` returning a `Context` of several tags, constructed in today's order): `BrowserLive`, `DesktopBridgeLive`, `OauthLive`, `WorkersLive` (idleCompaction, heartbeat, agentStatus, timeline, refine), `TerminalEditorLive`, `MiscDesktopLive`; staged with `provideMerge` where one group needs another. `DesktopWiringLive` (`Effect.sync` only) = setters + `aiService.on/workspaceService.on/memoryConsolidationService.on` wiring + global registrations. | | `layers/app.ts` | `AppLive(stores) = DesktopLive βΉ CoreLive βΉ CoreOptionsFromDesktopLive βΉ CrossCuttingLive βΉ AppFiberScopeLive βΉ EffectRunnerLive βΉ StoresLive(stores)` β read `X βΉ Y` as "X is *provided with* Y, and both stay exposed", i.e. **`X.pipe(Layer.provideMerge(Y))`** (rc.112 signature: `provideMerge(that: provider)(self: consumer)`; the *right-hand* operand is the dependency). Every `βΉ` keeps all tags visible in the final `Context<AppTags>`. | | `testEffectRunner.ts` (test helper, sibling of `testHistoryService.ts`) | `makeTestEffectRunner()` β `{ runner, adjust(duration), setTime(ms), dispose }` over one memoised `ManagedRuntime.make(EffectRunnerLive.pipe(Layer.provideMerge(TestClock.layer())))` (the TestClock is the *provider*; the runner captures it), so the worker under test and `TestClock.adjust` share one `TestClock`. | ### 2.2 Composition roots after Phase 11 ```mermaid flowchart TB Stores["StoresLive(stores)<br/>Config Β· SessionLocator Β· ProvidersConfigStore Β· SecretsStore Β· FileLeaseManager"] Runner["EffectRunnerLive (unsupervised, ref-bound)<br/>+ AppFiberScopeLive (supervised, closed on dispose)"] Cross["CrossCuttingLive (desktop only)<br/>Policy Β· Telemetry Β· Experiments Β· Analytics Β· SessionTiming Β· DevTools Β· WorkspaceMcpOverrides Β· Backup"] Opts["CoreOptionsTag<br/>desktop: derived from CrossCutting Β· CLI: Layer.succeed(opts)"] Core["CoreLive<br/>PR 3: coarse CoreProjectionLive β PR 4: stages S1β¦S8 + CoreWiringLive"] Desk["DesktopLive β group Layers<br/>Browser Β· DesktopBridge Β· OAuth Β· Workers Β· TerminalEditor Β· Misc β DesktopWiringLive"] RT["AppRuntime = ManagedRuntime.make(AppLive)<br/>eager sync build Β· Context<AppTags> = oRPC effect/context Β· dispose() last"] Stores --> Runner --> Cross --> Opts --> Core --> Desk --> RT CLI["CLI root (xum run / xum workflow)<br/>createCoreServices(opts) = makeAppRuntime(CoreLive βΉ StoresFromCoreOptionsLive βΉ AppFiberScopeLive βΉ EffectRunnerLive βΉ succeed(CoreOptionsTag, opts))"] Core -.same Layer definitions.-> CLI ``` `ServiceContainer` keeps its public fields and the synchronous `new ServiceContainer(stores)`: the constructor calls `makeAppRuntime(AppLive(stores))`, stores `this.serviceContext = runtime.runSync(Effect.context<AppTags>())`, and assigns fields via `Context.get(this.serviceContext, Tag)`. `toORPCContext()` returns the same plain fields plus `"effect/context": this.serviceContext`. `initialize()` is untouched. `dispose()` follows Β§5. `createCoreServices(opts)` keeps its signature and return shape plus `runtime` and `appFiberScope` fields; `cli/run.ts:1574-1580` and `cli/workflow.ts:275-320` cleanup lists gain `closeScopeBounded(appFiberScope)` before `session.dispose()` and `disposeAppRuntime(runtime)` as the final step (PR 3). **Staged composition skeleton (PR 4 shape; direction matters):** ```ts // Each stage depends only on stages defined above it. `provideMerge` keeps both sides exposed. const S1 = Layer.mergeAll(HistoryLive, InitStateLive, ProviderLive, /* β¦ true siblings only */); const S2a = Layer.mergeAll(SessionUsageLive, GoalLive, MemoryLive).pipe(Layer.provideMerge(S1)); const S2b = StreamManagerLive.pipe(Layer.provideMerge(S2a)); // StreamManager needs SessionUsage const S3 = AIServiceLive.pipe(Layer.provideMerge(S2b)); // β¦ S4 β¦ S8 likewise β¦ export const CoreLive = CoreWiringLive.pipe(Layer.provideMerge(S8)); // wiring runs after every service exists ``` **oRPC typing.** `OrpcEffectServices` (in `effectContext.ts`) becomes `AppTags`, so `ORPCContext["effect/context"]: Context<AppTags>` is satisfied by the runtime context in production. `buildOrpcEffectContext` stays as the narrow test helper it already is (its only caller, `effectBridge.test.ts:24-30`, deliberately builds a partial context and casts it via `unknown`); no production caller remains after PR 1. ### 2.3 Invariants (the "DI contract"; enforced by tests and the `appRuntime.ts` doc comment) | # | Invariant | Constraint served | |---|---|---| | I1 | **Phase 11 compatibility contract, not permanent law:** layer bodies are synchronous (`Layer.succeed`/`Layer.sync`/`Layer.effect` over sync effects; `acquireRelease` with a sync acquire is fine). `makeAppRuntime` asserts the eager build completed. Future async resource acquisition belongs in `initialize()`/startup effects or an explicit async factory root (`ServiceContainer.create()`), never silently inside a layer. | #2 sync-start, #5 startup parity | | I2 | Services never hold the `ManagedRuntime`. Workers hold an `EffectRunner` (default `defaultEffectRunner`); `EffectRunner.runX` β‘ `Effect.runβ¦With(ctx)` β same sync-start semantics as `Effect.runX`, and still valid after `runtime.dispose()`, so late callbacks cannot hit "ManagedRuntime disposed". Supervision, when needed, is explicit via `AppFiberScope`. | #2, #3 | | I3 | Per-call pipelines (`Effect.runPromise(this.effectsβ¦)` facades) and the `memoryConsolidationService` funnels are untouched. **Audit item:** no DI lookup, runner call, or `await` may be inserted before `inFlight.set` / `harvestInFlight.set`. Only lifecycle forks in workers move to `this.runner.runX`. | #1, #2 | | I4 | Constructors, facades, private methods, module exports unchanged; new constructor parameters are optional, trailing, defaulting to `defaultEffectRunner`. | #1, #6 | | I5 | Teardown order stays explicit in `dispose()`/`shutdown()`. Layer bodies and wiring layers register **no finalizers** in Phase 11 (`Effect.sync` only), so `runtime.dispose()` reorders nothing. The one supervised resource (`AppFiberScope`) is closed explicitly at a fixed position in `dispose()` (Β§5). | #3 | | I6 | Wiring layers replay today's setter/listener order; a constructor may touch only its *declared* dependencies (built earlier by staging). Per-PR audit: grep each moved constructor for calls on setter-provided collaborators β forbidden. Dependency order is expressed only with `provide`/`provideMerge` stages; never rely on `mergeAll` sibling order. | #6 | | I7 | No persisted-data changes; DI is in-process only. | #4 | | I8 | Every process root builds from the same Layer definitions (`CoreLive` shared by App and CLI). Unit harnesses (`createTestHistoryService`, `createTestToolConfig`, `createAgentSessionHarness`, β¦) intentionally bypass Layers. | #7 | ### 2.4 Decisions and alternatives (product-LoC deltas) <details> <summary>D1 β Granularity: coarse core first (PR 3), per-service core stages behind a decision gate (PR 4), group layers for the desktop tail (PR 5)</summary> Honest framing: the three unlocks (engine-core async scope, TestClock, app-lifetime scope) are delivered by `AppRuntime` + `EffectRunner` + `AppFiberScope` and **do not require per-service layers**. Per-service core layers are *migration leverage*: typed requirement sets for the engine-core work, per-service swap in integration tests, explicit dependency stages instead of implicit ordering. - **(A) Per-service everywhere** (~70 layers): +~900/β~700. Desktop tail has hand-tuned teardown that must not become finalizers, so per-service there buys uniformity only. Rejected. - **(B) Recommended:** PR 3 coarse `CoreProjectionLive` (+~120/β~10) delivers the shared root and runtime ownership; PR 4 peels the core into staged per-service layers (+~330/β~290) **only if** PR 3's typecheck/startup budgets hold (gate in Β§3); desktop tail as ~6 group layers (+~170/β~150). Tags for all services either way (~3 LoC each). - **(C) Coarse only:** stop after PR 3 + desktop projection (~+200 total). Cheapest; the engine-core phase would then redo dependency declarations. Remains the fallback if PR 4's gate fails. </details> <details> <summary>D2 β Async init stays an explicit `initialize()`; Layers construct only</summary> Folding `initialize()` into layer construction would make the build asynchronous (breaks I1), change failure semantics (today: fail-fast β dialog/log), and move the six-step order into memoised builds. Deferred; a later phase can turn `initialize()` into `runtime.runPromise(startupEffect)` with per-step `Effect.timeout`. </details> <details> <summary>D3 β Optional cross-cutting services stay optional via `CoreOptionsTag`, not `Effect.serviceOption`</summary> Core layer bodies read `opts.policyService` etc. exactly as today, so CLI (absent) vs desktop (present) behavior is unchanged and no service gains a new `undefined` branch. </details> <details> <summary>D4 β Two seams instead of one: `EffectRunner` (unsupervised, clock-bound) + `AppFiberScope` (supervised)</summary> A single "runtime handle" conflates two needs. Workers need *which clock* (TestClock) and must keep sync `stop()`; the engine core needs *who awaits me on shutdown*. Explicit `Clock` injection per worker was rejected (a `provideService(Clock.Clock, β¦)` at every fork site, and it does not extend to other refs). </details> <details> <summary>D5 β oRPC: `effect/context` = the runtime's `Context`; `handlerGen` unchanged</summary> `handlerGen` already `Effect.provide`s the context per request; providing ~70 entries instead of one is one Map merge per request. The existing `echoAsync`/`echoEffect` probes record the delta as a **diagnostic** in the PR body (no stable benchmark harness exists to make it a hard gate). `effect/wrap` not needed. </details> ## 3. Phasing β six stacked PRs Every PR: `make static-check`; gate suites below; existing tests unchanged (PR 6 is the only PR that edits tests, and only to replace real-timer probes). Before `@codex review`, run the **house pre-review audits**: 1. **Interruption posture** β list every new/moved fiber fork; state what interrupts it and when (unsupervised via `EffectRunner` + worker scope, or supervised via `AppFiberScope`). 2. **Uninterruptible teardown** β teardown effects are `Effect.uninterruptible` end-to-end; bounded waits inside use `Effect.interruptible(Effect.timeout(...))` (house shape from #4038). 3. **No defect escapes** β `disposeAppRuntime`/`closeScopeBounded` and every Promise facade fold defects; `makeAppRuntime` is the one place allowed to throw (constructor semantics). 4. **Spy-seam check** β `rg 'spyOn\(' src/node/services/<touched>.test.ts tests/` per touched class; constructor arity and private-method Promise signatures unchanged (typecheck of tests proves it). 5. **Sync-start check** β a fork through `EffectRunner` runs to its first `sleep` before `runFork` returns (mirrors `heartbeatService.ts:199-202`). 6. **Constructor side-effect audit (I6)** for every constructor moved into a Layer in that PR. 7. **Zero-suspension audit (I3)** whenever `memoryConsolidationService` is in the diff. ### PR 1 β Skeleton: AppRuntime + Stores/MemoryMeta layers + runtime-backed `effect/context` + dispose hook (+~150 LoC) **Scope** - `di/tags.ts` (`ConfigTag`, `SessionLocatorTag`, `ProvidersConfigStoreTag`, `SecretsStoreTag`, `FileLeaseManagerTag`, `MemoryMeta` moved from `orpc/effectContext.ts`, which re-exports it; `AppTags` union). - `di/layers/stores.ts` (`StoresLive`), `di/layers/core.ts` with `MemoryMetaLive = Layer.effect(MemoryMeta, Effect.map(ConfigTag, c => new MemoryMetaService(c.rootDir)))`, `di/layers/app.ts` (`AppLive(stores) = MemoryMetaLive βΉ StoresLive`). - `di/appRuntime.ts` (`makeAppRuntime`, `disposeAppRuntime`); `APP_RUNTIME_DISPOSE_TIMEOUT_MS` in `src/constants/`. - `coreServices.ts`: `CoreServicesOptions.memoryMetaService?` (precedent: `workspaceMcpOverridesService?`). - `serviceContainer.ts`: build runtime first, pass `Context.get(ctx, MemoryMeta)` to `createCoreServices`, `public readonly runtime`, `toORPCContext()["effect/context"] = this.serviceContext`, `dispose()` appends `disposeAppRuntime` behind a `disposed` latch; new `log.debug("[startup] AppRuntime built", { ms })`. - `orpc/effectContext.ts`: `OrpcEffectServices = AppTags`; `buildOrpcEffectContext` retyped/test-helper doc. - `headlessEnvironment.dispose` calls `await services.dispose()` before removing the temp dir (the bench harness currently leaks the container; runtime ownership starts here). **Acceptance** - `di/appRuntime.test.ts`: (a) sync build sets `cachedContext`; (b) a layer with an async body makes `makeAppRuntime` **throw synchronously** (I1 enforced); (c) probe layers' finalizers run in reverse order on dispose; (d) dispose is idempotent and bounded (hung finalizer β `warn`, resolves at the timeout); (e) `runtime.runFork` after the eager build starts synchronously. - `serviceContainer.test.ts`: `Context.get(toORPCContext()["effect/context"], MemoryMeta) === services.memoryMetaService`; `dispose()` closes the runtime; `dispose(); shutdown()` (tests/ipc order) is clean; a throwing layer surfaces as a synchronous throw from `new ServiceContainer(stores)` (same shape as today's constructor throw β existing entry-point catch paths). - `effectBridge.test.ts`, `memoryMeta*.test.ts` unchanged and green; echo-probe overhead recorded in the PR body. - Gate: `bun test src/node/services/di src/node/services/serviceContainer.test.ts src/node/orpc src/node/services/memoryMeta*` Β· `make test-integration` Β· `make static-check`. **Rollback:** `git revert`; classes untouched. ### PR 2 β Runtime seams: `EffectRunner` + `AppFiberScope`; TestClock on idleCompaction/heartbeat/retryManager (+~140 LoC) **Scope** - `di/effectRunner.ts`, `di/appFiberScope.ts`; `AppLive` gains `AppFiberScopeLive βΉ EffectRunnerLive` at the base; `ServiceContainer` exposes `appFiberScope` (used only by `dispose()` in Phase 11) and closes it per Β§5. - `IdleCompactionService`, `HeartbeatService`, `RetryManager`: trailing optional `runner: EffectRunner = defaultEffectRunner`; every lifecycle `Effect.runSync/runFork` in `start/stop/schedule/cancel` becomes `this.runner.runX`. Deadline math (`Date.now()`/injected `now`) unchanged. `ServiceContainer` passes `Context.get(ctx, EffectRunnerTag)` to the two workers; `RetryManager` keeps the default until PR 5 (so `streamManager.ts` is untouched here). - `di/testEffectRunner.ts` helper. **Acceptance** - New TestClock tests (existing real-timer tests untouched β they exercise the `defaultEffectRunner` path, which is production behavior wherever no runner is injected): heartbeat `STARTUP_DELAY_MS` β first tick after `adjust`, one tick per `CHECK_INTERVAL_MS`, no ticks after `stop()`; idleCompaction initial delay + cadence; retryManager fires exactly at `delayMs`, `cancel()` before `adjust` never fires. - Pin runtime facts: `runner.runSync(Scope.close(scope, Exit.void))` completes synchronously for a fiber suspended on a TestClock sleep; `runFork` through the runner reaches its first sleep synchronously; `Effect.context<never>()` inside `EffectRunnerLive` sees the upstream `TestClock` (else the helper provides `Clock.Clock` explicitly β same seam, one line). - `AppFiberScope` contract tests: (i) an **I/O-suspended** fiber (interruptible `Effect.async` that never resolves, with a cancel path) forked with `Effect.forkIn(_, appFiberScope)` is interrupted **and awaited** by `closeScopeBounded(appFiberScope)` β and this happens *before* the explicit teardown steps in `dispose()` (assert ordering against a spy on `desktopBridgeServer.stop`); (ii) a fiber forked via `EffectRunner` is *not* interrupted by either close (documents the asymmetry); (iii) `disposeAppRuntime` afterwards idempotently re-closes the already-closed child scope (no error, no second finalizer run). - If `TestClock.adjust` leaves continuations pending, the helper adds `Effect.yieldNow`/`Fiber.await` β decided by tests. - Gate: `heartbeatService.test.ts`, `idleCompactionService.test.ts`, `retryManager.test.ts`, `serviceContainer.test.ts`, `di/*`, tests/ipc. **Rollback:** revert restores defaults; no call site depends on the new params. ### PR 3 β Shared core root: coarse `CoreProjectionLive` + `createCoreServices` facade + CLI runtime disposal (+~120 / β~10) **Scope** - Tags for the remaining 19 core services; `CoreOptionsTag`; `StoresFromCoreOptionsLive`. - `CoreProjectionLive = Layer.effectContext(Effect.gen(function*(){ const opts = yield* CoreOptionsTag; const stores = yield* β¦; const core = buildCoreGraph({ ...opts, ...stores }); return Context.make(History, core.historyService).pipe(Context.add(...)) }))` where `buildCoreGraph` is today's `createCoreServices` body, unchanged, renamed. - `createCoreServices(opts)` = `makeAppRuntime(CoreProjectionLive βΉ StoresFromCoreOptionsLive βΉ AppFiberScopeLive βΉ EffectRunnerLive βΉ Layer.succeed(CoreOptionsTag, opts))`, returns today's `CoreServices` object read from the context plus `runtime` and `appFiberScope`. `cli/run.ts` and `cli/workflow.ts` cleanup lists append `closeScopeBounded(appFiberScope)` **before** `session.dispose()` and `disposeAppRuntime(runtime)` **after** `backgroundProcessManager.terminateAll()`. - `ServiceContainer` stops calling `createCoreServices`; `AppLive = CoreProjectionLive βΉ CoreOptionsFromDesktopLive βΉ CrossCuttingLive βΉ β¦` (cross-cutting services move into `CrossCuttingLive` now because core options derive from them). Desktop constructions otherwise stay in the constructor. **Acceptance** - Identity test: every `CoreServices` field `===` `Context.get(ctx, Tag)`; `serviceContainer.test.ts` unchanged and green. - **Decision gate for PR 4** recorded in the PR body: `make typecheck` wall time, `[startup] AppRuntime built` ms and `initialize` totals vs `origin/main` baseline from the sandbox (Β§7). Proceed to PR 4 only if typecheck regresses < 10 % and startup within noise; otherwise stop at (C). - Gate: `bun test src/node/services`, `src/cli/*.test.ts` (run/workflow/server/cli), tests/ipc, `make static-check`. **Rollback:** revert restores the imperative call; PR 1/2 unaffected. ### PR 4 β Peel the core into staged per-service Layers + `CoreWiringLive` (+~330 / β~290 β net β +40; split 4a/4b if > ~600 diff lines) **Scope** - Stages S1, S2a, S2b, S3β¦S8 (Β§2.1 + skeleton in Β§2.2) as `Layer.effect` adapters with today's argument lists; `CoreWiringLive` (`Effect.sync` only) replays the wiring lines in order; `CoreLive = CoreWiringLive.pipe(Layer.provideMerge(S8))` replaces `CoreProjectionLive`; `buildCoreGraph` deleted. - Before writing any stage: re-derive the DAG from the constructor argument lists (the plan's stage table was checked once; `StreamManager β SessionUsage` is the kind of edge that turns "siblings" into a stage split) and record it in the PR body. - 4a (S1βS3: leaves through `AIService`) / 4b (S4βS8 + wiring) if needed β 4a alone is mergeable because the remaining services are built by a shrunken projection layer that reads S1βS3 from the context. **Acceptance** - Wiring assertions that are behavioral (a missing wiring line fails them): `turnRequestBuilderBindings` fully populated; goal continuation consumer registered on `idleDispatcher`; `streamManager` MCP manager set; registration probe installed on `extensionMetadata`. - I6 audit table for all 19 constructors in the PR body; missing-provider = compile error (R must be `never` at `makeAppRuntime`) demonstrated by a type-level test (`// @ts-expect-error`). - Gate: as PR 3 plus `streamManager*.test.ts`, `aiService.test.ts`, `workspaceService*.test.ts`. **Rollback:** revert to PR 3's projection. ### PR 5 β `DesktopLive` group layers + `DesktopWiringLive`; thin `ServiceContainer`; `StreamManager` runner param (+~170 / β~150 β net β +20) **Scope** - Tags for the 45 desktop services; six group layers (`Layer.effectContext`, today's construction order inside each; `provideMerge` between groups that depend on each other); `DesktopWiringLive` (`Effect.sync` only) = `serviceContainer.ts:209, 263-265, 271, 288-290, 334-340, 348, 365, 375, 381-382, 434, 438-471, 474-574` in order. - `ServiceContainer` constructor = `makeAppRuntime(AppLive(stores))` + field assignment from the context. `toORPCContext()` unchanged in shape. - `StreamManager`: optional trailing `runner: EffectRunner`; `schedulePartialWrite` fork (`streamManager.ts:1141`) and `RetryManager` construction use it; `Scope.close` stays `Effect.runFork` (existing async-close precedent). `WorkersLive` receives `EffectRunnerTag`. **Acceptance** - All four existing `serviceContainer.test.ts` assertions unchanged; new identity test over `toORPCContext()` fields vs tags; `dispose()`/`shutdown()` call order asserted via spies on the *public* methods already spied today. - I6 audit for the 45 constructors. - Gate: tests/ipc + tests/ui (`make test-integration`), `src/cli/server.test.ts`, `src/cli/cli.test.ts`, `streamManager*.test.ts`, `aiService.test.ts`. ### PR 6 β TestClock adoption sweep + shutdown hardening + contract docs (+~20 LoC product; tests edited) **Scope** - Replace real-sleep cadence probes with `makeTestEffectRunner()` in `heartbeatService.test.ts`, `idleCompactionService.test.ts`, `retryManager.test.ts`, and the partial-write debounce cases of `streamManager.test.ts`; keep **one real-timer smoke test per worker** (guards the `defaultEffectRunner` path). - `cli/server.ts`: `[shutdown]` log lines per step incl. `AppRuntime disposed {ms}`; confirm the whole `dispose()` fits the existing 5 s force-exit budget. - Finalize the contract doc comment in `di/appRuntime.ts` (I1βI8, Β§5). **Acceptance:** converted suites have zero `setTimeout`-based cadence waits (grep in PR body), same assertions; `make test-integration` green; sandbox startup/shutdown evidence (Β§7). ## 4. TestClock story - **Mechanism.** `Effect.sleep`, `Schedule.fixed`, `Effect.timeout`, `Clock.currentTimeMillis` read the `Clock` reference from the running fiber's context. Workers that fork through an `EffectRunner` built under `TestClock.layer()` run on the test clock; `await testRunner.adjust("2 minutes")` advances it. `Date.now()`, `setTimeout`, `setInterval` are unaffected β heartbeat deadline math via injected `now`, `AgentStatusService`'s ref'd `setInterval`, and `backgroundProcessManager` stay on real timers/injected timestamps. - **Benefit now:** `heartbeatService.test.ts` (6), `idleCompactionService.test.ts` (2), `retryManager.test.ts` (3 `setSystemTime` β `adjust`; `Date.now`-based `retryAt` may move to `Clock.currentTimeMillis` only if a test needs both clocks aligned), `streamManager.test.ts` debounce cases (7). - **Deferred:** `streamBridge.test.ts` ticker (11) β needs a context/runner parameter on `subscriptionIterable`; OAuth device-flow polling and `oauthFlowManager.test.ts` (25) β non-goal. - **Stays real:** child-process/PTY/WASM/fs-lock waits (`backgroundProcessManager` 72, `quickjsRuntime` 26, lock sleeps in `workspaceService`/`taskService`), end-to-end suites (tests/ipc, e2e). - **Pinned in PR 2, not assumed:** `adjust` runs due sleeps and their synchronous continuations before resolving (or the helper yields until they do); `Schedule.fixed` anchoring under `TestClock` matches the wall-clock expectations in `heartbeatService.ts:149-155`; sync `Scope.close` of a TestClock-suspended fiber completes synchronously. ## 5. Shutdown protocol 1. **Trigger points unchanged:** `main.ts` `before-quit` (preventDefault β `dispose()` raced with 5 s β `app.quit()`; update-install path fire-and-forget), the second `before-quit` listener's `shutdown()` (unchanged, concurrent), `cli/server.ts` SIGINT/SIGTERM (5 s force exit), ACP `close()`, tests/ipc (`dispose()` then `shutdown()`), headless bench (`dispose()` from PR 1). 2. **`ServiceContainer.dispose()` order:** 1. `backgroundProcessManager.beginShutdown()` β unchanged, first (latch protecting persisted monitor records). 2. **`closeScopeBounded(appFiberScope, APP_FIBER_SCOPE_CLOSE_TIMEOUT_MS)`** β interrupts and awaits supervised fibers *while every dependency they might touch during finalization is still alive*. No occupants in Phase 11; the position is fixed now so the engine-core phase does not have to re-derive it. 3. The existing explicit sequence verbatim (`desktopBridgeServer.stop()` β¦ `terminateAll()` β¦ `timelineService.flush()`). 4. **`disposeAppRuntime(runtime, APP_RUNTIME_DISPOSE_TIMEOUT_MS)`** β closes the runtime scope (interrupts any fiber started via `runtime.runX` β none long-lived in Phase 11; runs layer finalizers β none in Phase 11 by I5). Hung β `warn` at the timeout; never rejects. Budget: 2 s + 2 s inner bounds inside the callers' 5 s outer budgets; the outer race in `main.ts` remains the last line of defense. **Rule for future occupants:** anything forked into `AppFiberScope` must tolerate interruption at any suspension point and must not depend on resources torn down in step 1; anything that needs a Layer finalizer must first prove reverse-construction order is compatible with steps 2β3 (I5). 3. **Latches:** `disposed` makes `dispose()` idempotent (two `before-quit` listeners, tests/ipc dispose+shutdown). `shutdown()` never touches the runtime or `AppFiberScope`. 4. **Late callers:** `EffectRunner` handles keep working after runtime dispose (I2), so a stray `tick()`/`scheduleRetry()` after quit cannot defect. The `ManagedRuntime` is referenced only by `ServiceContainer` and the `createCoreServices` return value. 5. **Worker `stop()` stays synchronous** (`runner.runSync(Scope.close)`) because their fibers suspend only on the clock. The engine core will fork into `AppFiberScope` (step 2.2 awaits it) β the reason both seams exist now. 6. **Crash paths:** unchanged β `uncaughtException`/SIGKILL run no finalizers. Finalizers are best-effort; durable state must remain crash-safe without them (AGENTS.md self-healing rule). Nothing in Phase 11 makes a finalizer the sole guardian of durable state. ## 6. Risk register | # | Risk | L/I | Mitigation | |---|---|---|---| | R1 | A layer body suspends β `runSync` throws at startup | M/H | I1 assert + PR 1 test (b); doc comment; review checklist; entry-point catch paths verified in PR 1 | | R2 | Construction-order side effects differ under staged builds | L/H | I6 audit per moved constructor; explicit `provideMerge` stages; wiring layers replay today's order; tests/ipc as behavioral gate | | R3 | Double teardown (`shutdown()` β₯ `dispose()`; dispose+shutdown in tests) | M/M | `disposed` latch; runtime/AppFiberScope closed only in `dispose()`; PR 1 test | | R4 | Late `runtime.runX` after dispose β defect | M/M | I2: services hold `EffectRunner`, never the ManagedRuntime | | R5 | TestClock semantics differ from assumptions | M/L | PR 2 pins them before any suite converts; per-suite fallback to real timers | | R6 | effect v4 RC churn (`Context`β`ServiceMap`, Layer renames) | M/M | All `Layer/Context/ManagedRuntime/TestClock` imports confined to `di/`; exact pin | | R7 | Startup latency regression (splash) | L/M | `AppRuntime built` ms + `initialize` totals vs baseline in sandbox; PR 3 gate | | R8 | Typecheck slowdown from large requirement unions | L/L | PR 3 gate records `make typecheck` wall time; fallback (C) | | R9 | Per-request `Effect.provide` of a ~70-entry Context | L/L | echo-probe diagnostic in PR 1/5 bodies | | R10 | Spy seams / direct-construction tests break | L/H | I4; optional trailing params; audit 4; typecheck of tests | | R11 | CLI roots forget to dispose runtime/scope | M/L | PR 3 wires both cleanups; `src/cli/*.test.ts` assert the cleanup steps exist | | R12 | Someone forks long-lived I/O work via `EffectRunner` expecting dispose to await it | M/M | Doc on `EffectRunner` ("unsupervised"); PR 2 asymmetry test; review audit 1 | **Rollback:** PRs are stacked; revert in reverse order (6β1). Service classes are never modified except for optional trailing params, so any revert restores the previous composition root wholesale with no data or API implications. ## 7. Dogfooding (per PR; evidence attached to the PR body) **Environment (headless Coder host, no `DISPLAY`):** ```bash XUM_LOG_LEVEL=debug DEV_SERVER_SANDBOX_ARGS="--clean-projects" make dev-server-sandbox # background bash task; prints URL + XUM_ROOT ``` - **Startup correctness:** `<XUM_ROOT>/logs/*.log` shows, in order: `Loading services...`, `[startup] AppRuntime built {ms}`, `[startup] ServiceContainer.initialize starting`, six step durations, `[startup] ServiceContainer.initialize completed {totalMs, stepDurationsMs}`. Paste baseline (`origin/main`) vs branch numbers. - **Startup-never-crash parity (once, locally, not committed):** inject a throwing scratch layer β `xum server` exits non-zero with the existing logged error and **no** unhandled-rejection trace; for desktop, confirm by code path (`loadServices()` rejects β `main.ts:1255` dialog) and via `src/cli/server.test.ts`/ACP tests. - **UI smoke (agent-browser):** `open <url>` β `snapshot -i` β add a scratch git repo as a project β create a workspace β send one message β `screenshot` the loaded app and the response; `attach_file` both. **Video:** start `agent-browser record` before the flow and stop it with a hard timeout (`timeout 30 agent-browser record stop`); if stopping hangs (known), attach the truncated WebM plus the screenshots and say so. - **oRPC Effect path:** pin/unpin a memory entry (rides `handlerGen` + runtime `effect/context`); screenshot before/after; grep logs for `ManagedRuntime disposed`/defect lines (expect none). - **Graceful quit:** record the terminal with `script -q /tmp/<workspace>-shutdown.log` (or `agent-tty` if present), `kill -TERM <pid>` β expect `[shutdown]` lines, `AppRuntime disposed {ms}`, exit 0, no force-exit message; attach the typescript. Exercise the timeout branch once with a scratch hung finalizer β `warn` + timely exit. - **Electron (best effort):** with `Xvfb`, `make dev` + agent-browser via CDP (electron skill): screenshot splash β main window, quit via menu, confirm exit < 5 s; otherwise state that the Electron path is covered by `tests/e2e` in CI and the shared `dispose()` path exercised by `server.ts`. **Gate suites per PR** (plus `make static-check` always): | PR | Must pass | |---|---| | 1 | `src/node/services/di/*`, `serviceContainer.test.ts`, `src/node/orpc/*`, `memoryMeta*`, `make test-integration` | | 2 | + `heartbeatService.test.ts`, `idleCompactionService.test.ts`, `retryManager.test.ts` | | 3 | + `bun test src/node/services`, `src/cli/*.test.ts`; record PR 4 gate numbers | | 4 | + `streamManager*.test.ts`, `aiService.test.ts`, `workspaceService*.test.ts` | | 5 | + tests/ui via `make test-integration`, `src/cli/server.test.ts`, `src/cli/cli.test.ts` | | 6 | converted suites + full `make test-integration` + sandbox startup/shutdown evidence | ## 8. Non-goals (explicit) - streamManager ENGINE CORE conversion (first `AppFiberScope` occupant; separate phase). - `Schema` at persistence boundaries; OAuth refresh/device-flow workers; `AgentStatusService` `setInterval` β Effect. - `initialize()` as a Layer/startup effect (D2); per-service optional tags (D3); `streamBridge` on the runtime; layer finalizers for existing `dispose()` steps. - Any change to persisted data, IPC wire shapes, or oRPC handler bodies beyond the `effect/context` source. ## 9. Assumptions stated - `Effect.context<never>()` inside `EffectRunnerLive` returns the enclosing build context including an upstream `TestClock` entry (PR 2 test; fallback: provide `Clock.Clock` explicitly in the helper). - `Scope.fork(parent)` inside a `Layer.effect` body yields a child closed by the runtime's layer scope on `dispose()` (PR 2 `AppFiberScope` test). - Layer bodies never need to observe sibling construction order; all ordering that matters is expressed as `provide`/`provideMerge` stages or wiring-layer statement order. - `EffectRunner`'s `R = never` constraint is sufficient for every lifecycle fork in the three Phase 11 workers and `StreamManager.schedulePartialWrite` (they only use `Effect.sleep`/`Schedule`/`Effect.sync`/`Effect.tryPromise` β no service tags). Verified by typecheck in PR 2/5. - The desktop tail's teardown remains explicit unless a later RFC proves reverse-construction order compatible; this plan does not attempt it. </details> --- _Generated with `xum` β’ Model: `anthropic:claude-fable-5-1` β’ Thinking: `xhigh`_ <!-- mux-attribution: model=anthropic:claude-fable-5-1 thinking=xhigh -->
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Effect migration Phase 11, PR 4a of 6 (PR 4 ships as 4a + 4b, see below): the head of the core service graph β every service through
AIServiceβ now builds as staged per-service Layers (HistoryLive,ProviderLive, β¦AILiveindi/layers/core.ts, composed S1 β S2a β S2b β S3 withLayer.provideMergebetween stages andLayer.mergeAllonly for verified siblings). The remainder of the former imperative body (MemoryConsolidationServiceβ¦WorkspaceTurnManagerplus all setter/listener wiring, order unchanged) survives asbuildCoreTail, run by a transitional projection layer over the layer-built head.CoreLivereplacesCoreProjectionLivein both roots (AppLive,CoreRootLive);MemoryMetaandWorkspaceMcpOverridesbecome explicit core-graph inputs (desktop:MemoryMetaLive/CrossCuttingLive; CLI:MemoryMetaLive+ newWorkspaceMcpOverridesDefaultLive), soCoreServicesOptions.memoryMetaService/workspaceMcpOverridesServiceare gone. Service classes, constructors, facades and the wiring statements are untouched.Stacked on PR 1 #4049, PR 2 #4050, PR 3 #4051. Plan:
<details>at the bottom.Implementation
di/layers/core.tsβ 13 adapter layers with today's argument lists (S1: History Β· InitStateManager Β· Provider Β· BackgroundProcessManager Β· ExtensionMetadata Β· Memory Β· TerminalAttentionStore Β· IdleDispatcher Β· TurnRequestBuilderBindings; S2a: SessionUsage Β· WorkspaceGoal; S2b: StreamManager; S3: AI),CoreInputTags,WorkspaceMcpOverridesDefaultLive, the transitionalCoreTailProjectionLive(Layer.effectContextoverbuildCoreTail, exposing the six tail tags), andCoreLive = CoreTailProjectionLive βΉ S3.TurnRequestBuilderBindingsLiveisLayer.sync(one mutable record per graph build, never shared across runtimes).coreServices.tsβbuildCoreGraphβbuildCoreTail(head: CoreGraphHead): CoreGraphTail: the head constructions are deleted (they are the layers now); the tail keeps every remaining statement verbatim. The two wiring lines that only need head services (the extension-metadata registration probe,bindings.memoryService) run first in the tail.CoreOptionsmoves here (next toCoreServicesOptions) so the layers module is the only importer across the seam (type-only the other way β no cycle).di/tags.tsβTerminalAttentionStoreTag(graph-internal collaborator of Task/TurnManager;CoreTagsnow includes it),CoreRootTagsgainsWorkspaceMcpOverrides.di/layers/app.ts/desktop.tsβCoreLive;CoreOptionsFromDesktopLiveno longer smugglesmemoryMetaService/workspaceMcpOverridesServicethrough the options.coreServicesRoot.test.ts): the PR 3 identity harness is unchanged and green againstCoreLive; new behavioral wiring assertions (bindings identity table +onWorkflowRunStatusChangedβemitWorkflowRunActivity; goal-continuation consumer registered β a second registration is refused;streamManagerholds the MCP manager; registration probe installed and answeringfalsefor an unknown id), CLI-default inputs test, and the type-level missing-provider test (// @ts-expect-erroronmakeAppRuntime(CoreLive)). The 4b relocation of the tail into S4βS8 +CoreWiringLivewill land under these tests unchanged.PR 4 notes
Re-derived DAG (from the constructor argument lists in the former
buildCoreGraph)Inputs provided by the roots beneath
CoreLive(CoreInputTags):Config,SessionLocator,ProvidersConfigStore,SecretsStore,FileLeaseManager,CoreOptions,MemoryMeta,WorkspaceMcpOverrides(the last two wereopts.x ?? new X(...)defaults inside the body; they are always present, so they are inputs rather than optional options β desktop already built both in layers).MemoryMetais an input, not a stage member){}Layer.sync)Sibling claims in this PR (all checked against the argument lists above): S1's nine leaves depend only on inputs; S2a's two need only S1.
Layer.mergeAllsiblings build in declaration order in rc.112 (mergeAllEffectβforEachwithconcurrency: n, verified by a scratch probe) but nothing here relies on it. A throw inside amergeAllsibling surfaces as the same synchronous raw throw frommakeAppRuntime(scratch probe; the root test pins the equivalent for the projection).4a/4b decision: split
The full peel (S1βS8 +
CoreWiringLive, kept locally on a backup branch for 4b) measured +598/β408 product lines, well past the plan's ~600-line review-size guard even though most of it is 1:1 relocation of constructor calls and their rationale comments. This PR is 4a: S1βS3 as layers (+413/β186 product, +96 test) with the transitionalCoreTailProjectionLiverunning the unchanged remainder (buildCoreTail). 4b (next, the only follow-up; PR 5 is not started) peels S4βS8 into layers, replacesbuildCoreTailwithCoreWiringLive, deletes the projection, and lands under the behavioral wiring tests added here.I6 constructor side-effect audit
Moved into layers in this PR (13 + the 2 inputs):
HistoryService(S1)InitStateManager(S1)new EventStore(config, "init-status.json", β¦)(own store)ProviderService(S1)emitter.setMaxListeners;providersConfigStore.watchProvidersFile(β¦)BackgroundProcessManager(S1)setMaxListenerson itselfExtensionMetadataService(S1)WorkspaceGoalService,StreamManager,AIService) callextensionMetadataMemoryService(S1, was afterAIService)super()onlyTerminalAttentionStore(S1, was beforeTaskService)IdleDispatcher(S1, was last)SessionUsageService(S2a)WorkspaceGoalService(S2a)StreamManager(S2b)AIService(S3)providerService.onConfigChanged(β¦),streamManager.setEventSink(β¦),setMaxListeners, buildsProviderModelFactory/TurnRequestBuilderover its argsMemoryMetaService(input,MemoryMetaLive; CLI roots now layer-built instead ofopts.memoryMetaService ?? new)WorkspaceMcpOverridesService(input; CLIWorkspaceMcpOverridesDefaultLive)Still constructed by
buildCoreTailin this PR, positions relative to each other unchanged (audited now for 4b):MemoryConsolidationService(sidecar path only),MCPConfigService(asserts/fields),MCPServerManager(own unref'dsetInterval),WorkspaceService(backgroundProcessManager.onΓ5,aiService.onΓ6,initStateManager.on,extensionMetadata.setTombstoneClearedListener, module-globalsetWorkflowArchiveAdmissionGuard, startsrecoverBashMonitorStateAfterRestart()β all declared deps/self; none of its setter-provided collaborators),TaskService(aiService.onΓ3 β registered afterWorkspaceService's listeners, guaranteed because Task depends on Workspace;new AgentPeerMessageBroker(workspaceService),new GitPatchArtifactService(config)),WorkspaceTurnManager(new TaskHandleStore(config)).Construction-order changes vs
main, all inert by the table above:MemoryMeta/WorkspaceMcpOverrides/Memory/TerminalAttentionStore/IdleDispatcherare built before instead of afterAIService; S1 siblings have no defined mutual order; the registration probe is installed after the S2βS3 constructors instead of before them. The wiring statement order inside the tail is unchanged.Deviations from the plan
MemoryMetaandWorkspaceMcpOverridesare core-graph inputs (CoreInputTags) rather than S1 members: the desktop already built both in layers (PR 1/PR 3), so making the core read the tags directly removes theCoreServicesOptions.memoryMetaService/workspaceMcpOverridesServicepass-through (the twocoreOptions.*identity asserts inserviceContainer.test.tsthat pinned that pass-through are removed β the tag identity asserts next to them remain).CoreOptionstype moved fromdi/layers/core.tstocoreServices.ts(next toCoreServicesOptions) so the seam has a single import direction.coreServicesRoot.test.ts: the throwing-body spy targetsbuildCoreTail(rename ofbuildCoreGraph); everything else in the PR 3 harness is unchanged.CoreTagsincludesTerminalAttentionStoreTag(not aCoreServicesfield);CoreLive's output isExclude<CoreTags, MemoryMeta>becauseMemoryMetais an input β the roots' merged context still carries everyCoreTagsentry (identity test).Pre-review audits (plan Β§3)
rg 'fork' src/node/services/di/layers/β none).disposeAppRuntime/closeScopeBoundeduntouched).makeAppRuntime(constructor semantics), inside the callers' existing startup error paths; pinned for the projection (buildCoreTailspy) and for the desktop root (serviceContainer.test.ts), and probed for a nestedmergeAllsibling (scratch).rg 'spyOn\(|new (History|InitState|Provider|β¦)' src tests: all direct constructions and private-method spies compile unchanged); the only test-visible renames arebuildCoreGraph β buildCoreTailand the two removed option fields.MemoryConsolidationServiceconstruction is unchanged text inside the tail; no lookup/await was inserted anywhere near its funnels.Re-recorded gate numbers (R7/R8 post-staging data point)
Same methodology as PR 3: sibling
git worktrees under/tmp(origin/main=0b52386f1vs this branch =cfedef9bc, pre-rebase; the rebase ontoffa2780f6touched no shared code) with symlinkednode_modules, interleaved runs, shared 96-core Coder host at load β 140β150, CPU PSIsome avg60β 29β35 %.(a) Typecheck β the
make typecheckcommand (concurrentlyover bothtsgoprojects), 6 interleaved pairs:tsgo --extendedDiagnostics(deterministic compiler-work counts; wall check times were dominated by host noise β the main project measured 3.75 β 4.18 s in a first 4-run series and 5.69 β 5.54 s in a second 6-run series under rising load):tsconfig.json)tsconfig.main.json)(b) Startup β
bun src/cli/index.ts server --no-authwith a freshXUM_ROOT,XUM_LOG_LEVEL=debug, recorded underscript -f, 10 interleaved pairs, SIGTERM β 1 s afterinitialize completed:[startup] AppRuntime builtms (median, minβmax)new ServiceContainer2ndβ15th) is 0.9β1.5 β 1.6β2.7 ms median, +β0.3 ms at the minimumServiceContainer.initialize completed { totalMs }(median, minβmax)[shutdown] AppFiberScope closedβ explicit steps β[shutdown] AppRuntime disposedin every transcriptVerdict: typecheck flat (R8), construction +β6 ms cold / sub-millisecond warm against a ~100 ms
initialize()and a splash measured in hundreds of ms (R7 β recorded, not a gate failure). 4b adds six more layers; re-measure there.Lessons for 4b / PR 5
coreServicesRoot.test.tsare now the oracle for the tail relocation: 4b should movebuildCoreTailinto S4βS8 layers +CoreWiringLivewith those tests unchanged, and swap thebuildCoreTailspy in the throwing-body test for an equivalent nested-layer throw (e.g. spyingcreateAgentPluginsMcpProviderinsideMCPConfigLive).WorkspaceService's constructor needs nothing beyond S3; staging it afterMCPServerManager(plan's S6) is a construction-order-parity choice, not a dependency β say so in the stage comment.Layer.provideMerge(Layer.succeed(History)(fake))beneathCoreLive) β the plan's "per-service swap" leverage is available from this PR on.TurnRequestBuilderBindingsmust beLayer.sync, neverLayer.succeedwith a literal: the record is mutated by wiring, and a shared literal would leak across runtimes (tests build many).Illegal instruction(exit 132) hitbun test src/node/servicesthree times on this host in different files (BackupRepoCache wedge,workflows/WorkflowRunner.test.ts); each file passes on rerun here and on pristinemain. Run the suite in chunks when it happens.Validation
make static-checkgreen (typecheck both projects incl. the@ts-expect-errortest, lint, fmt, docs).bun test src/node/services: every file green except the known environment baselines on this host, identical on pristineorigin/mainβtaskGitPatchEngineΓ2,WorkspaceTurnManagerterminal recovery Γ2,agent_skill_deleteΓ1,BackupRepoCacheΓ7 β plus one order-dependent flake (AttachmentService.generateCompletedReportsAttachment β¦ newest first, passes alone on both trees).bun test src/cli185/185. Named PR 4 gate (streamManager*,aiService,workspaceService*) 659/659. jesttests/ipc/{doubleRegister,savedQueries,windowTitle,acp.disconnectCleanup}18/18.XUM_LOG_LEVEL=debug):xum workflowecho run from the branch:[startup] AppRuntime builtβok from pr4aβ[shutdown] AppFiberScope closedβBackgroundProcessManager.terminateAll()β[shutdown] AppRuntime disposedβ exit 0 (the orderworkflow.test.tspins).xum servergraceful quit: 10/10 branch runs exit 0 within 150β204 ms of SIGTERM with both[shutdown]runtime lines in place (table above).make dev-server-sandbox --clean-projects):AppRuntime built {ms: 12},initialize completed {totalMs: 239}; via agent-browser: added a scratch git repo as a project, sent "Reply with exactly the single word: pong" β worktree workspace created, model repliedpong(screenshot in the workspace chat showsv0.28.3-nightly.148-17-gcfedef9bc); noManagedRuntime disposed/defect lines in the log; SIGTERM β exit in 344 ms with[shutdown] AppFiberScope closed {ms: 1}β¦[shutdown] AppRuntime disposed {ms: 3}.before-quitpath (sameServiceContainer.dispose();tests/e2ein CI) and the oRPC memory pin/unpin round-trip (covered byeffectBridge.test.ts+ the identity tests).Risks
main. A throwing layer body still surfaces as the same synchronous constructor-style throw fromnew ServiceContainer(stores)/createCoreServices(opts)(tests).initialize()change.π Implementation Plan
Effect migration β Wave 3 / Phase 11: ManagedRuntime + Layer dependency injection
0. Summary
Replace the two hand-written composition roots (
createCoreServices+ theServiceContainerconstructor) with an EffectLayergraph built once per process by aManagedRuntime("AppRuntime"), while keeping every service class, constructor signature, Promise facade, private method, and test seam compatible. The runtime becomes (a) the owner of the app-lifetimeScope, (b) the provider of"effect/context"for oRPC Effect-native handlers, and (c) the source of two runtime seams: anEffectRunner(context-bound, unsupervised runner that lets clock-driven workers run on aTestClock) and anAppFiberScope(a runtime-owned, supervised scope whose close is awaited bydispose()β the slot the streamManager engine core will occupy later).Six stacked, independently mergeable PRs. Product PRs keep existing tests unchanged; only the final test-modernization PR edits tests. Net product LoC β +420 (per-PR estimates below). Service classes are not rewritten β Layers are thin adapters around existing constructors; cycle-breaking setter wiring moves into explicit "wiring layers" that replay today's order.
Unlocks (not done here): streamManager ENGINE CORE conversion,
TestClockfor timing suites, app-lifetime scopes.1. Verified current state (evidence)
src/node/services/coreServices.ts:103-389(createCoreServices: 25 constructions, 12turnRequestBuilderBindingswrites, ~14 setters) andsrc/node/services/serviceContainer.ts:161-575(45 more constructions;aiService.on(...)/workspaceService.on(...)analytics wiring at 474-574; global registrationssetGlobalCoderService/setSshPromptServiceat 469-471).new ServiceContainer(stores)is called byheadlessEnvironment.ts:111,tests/ipc/setup.ts,src/cli/server.ts:132,src/node/acp/serverConnection.ts:155,src/desktop/main.ts:653;src/cli/run.ts:661andsrc/cli/workflow.ts:376callcreateCoreServicesdirectly. β two graph roots (App vs Core), five process entry points, all constructing synchronously.ServiceContainer.initialize()(577-642) awaits sixinitialize()s (no try/catch; failure propagates tomain.ts:1255-1265"Startup Failed" dialog + quit;server.ts/ACP log and exit), then syncstart()s idleCompaction/heartbeat/agentStatus, then two fire-and-forget sweeps. All constructors are synchronous; two have side effects on declared constructor dependencies only (AIServiceβstreamManager.setEventSink,WorkspaceServiceβbackgroundProcessManager.on/aiService.on).dispose()(746-779) is explicit and hand-ordered (backgroundProcessManager.beginShutdown()MUST be first β it is a latch protecting persisted monitor records; bridges stop before sessions close;terminateAlllate;timelineService.flush()last).shutdown()(718-732) is a second sequence fired concurrently by a secondbefore-quitlistener (main.ts:1321).main.ts:1296-1304racesdispose()against 5 s thenapp.quit();cli/server.ts:227-268has a 5 sprocess.exit(1)force timer;tests/ipccleanup callsdispose()thenshutdown();headlessEnvironment.disposenever callsservices.dispose().effect. OnlyContext.Servicetag:MemoryMeta(src/node/orpc/effectContext.ts:21).handlerGen(@orpc/experimental-effect) runsEffect.runPromiseExitper request andEffect.providesopts.context["effect/context"].streamBridge.tsruns streams on the global runtime. Scope-owning workers:heartbeatService.ts:134-243,idleCompactionService.ts:86-122(Scope.makeUnsafe+Effect.runSync(Scope.close(..)), valid only because their fibers suspend solely on the clock),oauthFlowManager.ts:164,streamManager.ts:4767/4054(alreadyEffect.runFork(Scope.close(..))β the async-close precedent).memoryConsolidationService.ts:667-703, 837-860: check-and-reserve funnels with zero suspensions beforeinFlight.set/harvestInFlight.set.node_modules/effect/dist).Context.Service<Self, Shape>()("id")(moduleContext, notServiceMap);Layer.{succeed,sync,effect,effectContext,effectDiscard,provide,provideMerge,mergeAll,build,buildWithScope}(noLayer.scoped;Layer.effectstripsScopefrom R);ManagedRuntime.make(layer)β{ runSync, runSyncExit, runFork, runPromise, runPromiseExit, contextEffect, cachedContext, scope, dispose(), disposeEffect };Effect.{runSyncWith,runForkWith,runPromiseWith,runPromiseExitWith}(context);Effect.context<R>();Effect.serviceOption;Scope.{fork,forkUnsafe,close,provide};TestClockfromeffect/testing(layer, adjust, setTime, withLive);Clock.Clockis aContext.Reference(defaulted;TestClock.layer()overrides it).ManagedRuntime.js):makecreatesscope = Scope.makeUnsafe("parallel")andlayerScope = Scope.forkUnsafe(scope, "sequential"); the firstrunXforks a build fiber overLayer.buildWithMemoMapβ a fully synchronous layer graph builds synchronously, soruntime.runSync(Effect.context())succeeds and setscachedContext; afterwards everyrunXisEffect.runβ¦With(cachedContext)(no extra async boundary). Fibers started throughruntime.runXare registered inscope(onFiberStart: Fiber.runIn(scope)).dispose()=Scope.close(scope)(interrupt registered fibers in parallel β layer finalizers sequentially in reverse), after which anyruntime.runXdies with"ManagedRuntime disposed".Layer.mergeAll(A, B)is not a dependency resolver: B's requirements are not satisfied by A's outputs; requirements bubble up. Dependencies are satisfied only viaLayer.provide/provideMergechains. Siblings inmergeAllmay build concurrently.Config.saveConfig,WorkspaceService.retireKernelWorkflowRunReferences/startStartupRecovery/createSession/updateAgentStatus,MCPServerManager.startServers,AgentPluginInstallService.reconcileJournals, β¦); module-level export spies (agentStatusService.generateWorkspaceStatus,sshConnectionPool.verifyHostKeyAgainstPolicyEffect, β¦); direct construction in tests (Config44 files,HistoryService22,MemoryMetaService11,WorkspaceService7,IdleDispatcher6,StreamManager4,ServiceContainer3); partial-mock casts (InitStateManager193,AIService158,TaskService149,ORPCContext62).effectBridge.test.ts:24-30builds a partialORPCContextviabuildOrpcEffectContext+as unknown as ORPCContext.heartbeatService.test.ts6 real sleeps,idleCompactionService.test.ts2,retryManager.test.ts3setSystemTime,streamManager.test.ts7 (partial-write debounce),streamBridge.test.ts11 (heartbeat ticker), OAuth device-flow suites 14 (non-goal).2. Target architecture
2.1 Building blocks (all under
src/node/services/di/; the only directory allowed to importLayer/Context/ManagedRuntime/TestClock)tags.tsContext.Servicetag per service class provided by the graph. Type-only imports of service classes β no runtime import cycles. Ids"xum/<Name>". Naming: class name minus trailingService(MemoryMeta,Workspace,History); classes without that suffix or colliding with an exported name get aTagsuffix (ConfigTag,StreamManagerTag,IdleDispatcherTag). Exports the unionsCoreTagsandAppTags.effectRunner.tsinterface EffectRunner { runSync<A,E>(e: Effect<A,E,never>): A; runSyncExit; runFork; runPromise; runPromiseExit }β a context-bound, unsupervised runner whose methods accept only effects with no service requirements (R = never; defaulted references likeClockdo not appear inR). That makes "not a service locator" type-enforced: a fiber that needs services must take them as explicit constructor dependencies and, if it must be awaited on shutdown, fork intoAppFiberScope.defaultEffectRunner= the globalEffect.runX(today's exact behavior).effectRunnerFromContext(ctx)=Effect.runβ¦With(ctx).EffectRunnerTag+EffectRunnerLive = Layer.effect(EffectRunnerTag, Effect.map(Effect.context<never>(), effectRunnerFromContext)), placed at the base of the graph so the captured context contains only refs (Clock, laterLogger/Random) plus stores. Fibers forked through it are owned by the worker's ownScope(explicitstart/stop), not by the ManagedRuntime;runtime.dispose()does not interrupt them. Services import only this file fromdi/.appFiberScope.tsAppFiberScopeTag: Scope.Closeable.AppFiberScopeLive = Layer.effect(AppFiberScopeTag, Effect.gen(function*(){ const parent = yield* Effect.scope; return yield* Scope.fork(parent, "parallel"); }))β a child of the runtime's layer scope. Fibers forked into it viaEffect.forkIn(_, appFiberScope)are interrupted and awaited when the scope closes. This is the supervised seam for I/O-suspended fibers (engine core, later).ServiceContainer.dispose()closes it explicitly and early (Β§5) so interrupted fibers can still use their dependencies during finalization;runtime.dispose()later re-closes it idempotently as a backstop. No production occupant in Phase 11; the seam exists with tests.appRuntime.tsmakeAppRuntime(layer):ManagedRuntime.make(layer)+ eager synchronous build (runtime.runSync(Effect.context<R>());assert(runtime.cachedContext !== undefined)); a layer body that suspends is a programming error and throws here β exactly where a throwing constructor throws today, so every entry point's existing catch/dialog/log path is preserved.disposeAppRuntime(runtime, timeoutMs)andcloseScopeBounded(scope, timeoutMs)share one shape:Effect.uninterruptibleteardown shell aroundEffect.interruptible(target.pipe(Effect.timeout(timeoutMs)))wheretargetisruntime.disposeEffectresp.Scope.close(scope, Exit.void)(never a non-cancellable JS Promise wrapper);Effect.catchTag("TimeoutError", β¦)+Effect.catchDefectβlog.warn; run viaEffect.runPromise; never rejects; idempotent (Scope.closeis idempotent;disposeEffectis guarded by a latch). Verify the exact rcEffect.timeouterror type at implementation time (rc.112: fails withCause.TimeoutError,_tag: "TimeoutError"). Module doc comment = the DI contract (Β§2.3, Β§5).layers/stores.tsStoresLive(stores: ConfigStores)=Layer.mergeAllofLayer.succeedforConfigTag,SessionLocatorTag,ProvidersConfigStoreTag,SecretsStoreTag,FileLeaseManagerTag(true siblings β no inter-dependencies).StoresFromCoreOptionsLivereproduces theopts.x ?? new X(config.rootDir)defaults ofcoreServices.ts:106-112for the CLI root.layers/core.tsCoreOptionsTag(today'sCoreServicesOptionsminus stores β carries the optional cross-cutting services exactly as today). PR 3:CoreProjectionLive = Layer.effectContext(...)wrapping the existingcreateCoreServicesbody and returning aContext<CoreTags>(coarse projection, zero behavior change). PR 4: peel into per-serviceLayer.effect(Tag, Effect.gen(...))layers composed in explicit dependency stages (Layer.provideMergebetween stages;Layer.mergeAllonly for true siblings within a stage β every sibling claim below was checked against the constructor argument lists incoreServices.tsand must be re-checked in the PR): S1 History Β· InitState Β· Provider Β· BackgroundProcess Β· ExtensionMetadata Β· MemoryMeta Β· TerminalAttention Β· IdleDispatcher Β· WorkspaceMcpOverrides(default) Β·TurnRequestBuilderBindingsTag(Layer.succeed(_, {})) β S2a SessionUsage Β· Goal Β· Memory β S2b StreamManager (needs SessionUsage) β S3 AIService β S4 Consolidation Β· MCPConfig β S5 MCPServerManager β S6 Workspace β S7 Task β S8 TurnManager βCoreWiringLive(Layer.effectDiscard,Effect.synconly β noacquireRelease, replayscoreServices.ts:137-166, 209-210, 258-270, 288-325, 349-352, 360-367in order).layers/desktop.tsCrossCuttingLive(policy, telemetry, experiments, backup, sessionTiming, analytics, devTools, workspaceMcpOverrides, browserBridgeTokenManager),CoreOptionsFromDesktopLive(derivesCoreOptionsTagfrom those tags +extensionMetadataPath), then group layers (Layer.effectContextreturning aContextof several tags, constructed in today's order):BrowserLive,DesktopBridgeLive,OauthLive,WorkersLive(idleCompaction, heartbeat, agentStatus, timeline, refine),TerminalEditorLive,MiscDesktopLive; staged withprovideMergewhere one group needs another.DesktopWiringLive(Effect.synconly) = setters +aiService.on/workspaceService.on/memoryConsolidationService.onwiring + global registrations.layers/app.tsAppLive(stores) = DesktopLive βΉ CoreLive βΉ CoreOptionsFromDesktopLive βΉ CrossCuttingLive βΉ AppFiberScopeLive βΉ EffectRunnerLive βΉ StoresLive(stores)β readX βΉ Yas "X is provided with Y, and both stay exposed", i.e.X.pipe(Layer.provideMerge(Y))(rc.112 signature:provideMerge(that: provider)(self: consumer); the right-hand operand is the dependency). EveryβΉkeeps all tags visible in the finalContext<AppTags>.testEffectRunner.ts(test helper, sibling oftestHistoryService.ts)makeTestEffectRunner()β{ runner, adjust(duration), setTime(ms), dispose }over one memoisedManagedRuntime.make(EffectRunnerLive.pipe(Layer.provideMerge(TestClock.layer())))(the TestClock is the provider; the runner captures it), so the worker under test andTestClock.adjustshare oneTestClock.2.2 Composition roots after Phase 11
ServiceContainerkeeps its public fields and the synchronousnew ServiceContainer(stores): the constructor callsmakeAppRuntime(AppLive(stores)), storesthis.serviceContext = runtime.runSync(Effect.context<AppTags>()), and assigns fields viaContext.get(this.serviceContext, Tag).toORPCContext()returns the same plain fields plus"effect/context": this.serviceContext.initialize()is untouched.dispose()follows Β§5.createCoreServices(opts)keeps its signature and return shape plusruntimeandappFiberScopefields;cli/run.ts:1574-1580andcli/workflow.ts:275-320cleanup lists gaincloseScopeBounded(appFiberScope)beforesession.dispose()anddisposeAppRuntime(runtime)as the final step (PR 3).Staged composition skeleton (PR 4 shape; direction matters):
oRPC typing.
OrpcEffectServices(ineffectContext.ts) becomesAppTags, soORPCContext["effect/context"]: Context<AppTags>is satisfied by the runtime context in production.buildOrpcEffectContextstays as the narrow test helper it already is (its only caller,effectBridge.test.ts:24-30, deliberately builds a partial context and casts it viaunknown); no production caller remains after PR 1.2.3 Invariants (the "DI contract"; enforced by tests and the
appRuntime.tsdoc comment)Layer.succeed/Layer.sync/Layer.effectover sync effects;acquireReleasewith a sync acquire is fine).makeAppRuntimeasserts the eager build completed. Future async resource acquisition belongs ininitialize()/startup effects or an explicit async factory root (ServiceContainer.create()), never silently inside a layer.ManagedRuntime. Workers hold anEffectRunner(defaultdefaultEffectRunner);EffectRunner.runXβ‘Effect.runβ¦With(ctx)β same sync-start semantics asEffect.runX, and still valid afterruntime.dispose(), so late callbacks cannot hit "ManagedRuntime disposed". Supervision, when needed, is explicit viaAppFiberScope.Effect.runPromise(this.effectsβ¦)facades) and thememoryConsolidationServicefunnels are untouched. Audit item: no DI lookup, runner call, orawaitmay be inserted beforeinFlight.set/harvestInFlight.set. Only lifecycle forks in workers move tothis.runner.runX.defaultEffectRunner.dispose()/shutdown(). Layer bodies and wiring layers register no finalizers in Phase 11 (Effect.synconly), soruntime.dispose()reorders nothing. The one supervised resource (AppFiberScope) is closed explicitly at a fixed position indispose()(Β§5).provide/provideMergestages; never rely onmergeAllsibling order.CoreLiveshared by App and CLI). Unit harnesses (createTestHistoryService,createTestToolConfig,createAgentSessionHarness, β¦) intentionally bypass Layers.2.4 Decisions and alternatives (product-LoC deltas)
D1 β Granularity: coarse core first (PR 3), per-service core stages behind a decision gate (PR 4), group layers for the desktop tail (PR 5)
Honest framing: the three unlocks (engine-core async scope, TestClock, app-lifetime scope) are delivered by
AppRuntime+EffectRunner+AppFiberScopeand do not require per-service layers. Per-service core layers are migration leverage: typed requirement sets for the engine-core work, per-service swap in integration tests, explicit dependency stages instead of implicit ordering.900/β700. Desktop tail has hand-tuned teardown that must not become finalizers, so per-service there buys uniformity only. Rejected.CoreProjectionLive(+120/β10) delivers the shared root and runtime ownership; PR 4 peels the core into staged per-service layers (+330/β290) only if PR 3's typecheck/startup budgets hold (gate in Β§3); desktop tail as ~6 group layers (+170/β150). Tags for all services either way (~3 LoC each).D2 β Async init stays an explicit `initialize()`; Layers construct only
Folding
initialize()into layer construction would make the build asynchronous (breaks I1), change failure semantics (today: fail-fast β dialog/log), and move the six-step order into memoised builds. Deferred; a later phase can turninitialize()intoruntime.runPromise(startupEffect)with per-stepEffect.timeout.D3 β Optional cross-cutting services stay optional via `CoreOptionsTag`, not `Effect.serviceOption`
Core layer bodies read
opts.policyServiceetc. exactly as today, so CLI (absent) vs desktop (present) behavior is unchanged and no service gains a newundefinedbranch.D4 β Two seams instead of one: `EffectRunner` (unsupervised, clock-bound) + `AppFiberScope` (supervised)
A single "runtime handle" conflates two needs. Workers need which clock (TestClock) and must keep sync
stop(); the engine core needs who awaits me on shutdown. ExplicitClockinjection per worker was rejected (aprovideService(Clock.Clock, β¦)at every fork site, and it does not extend to other refs).D5 β oRPC: `effect/context` = the runtime's `Context`; `handlerGen` unchanged
handlerGenalreadyEffect.provides the context per request; providing ~70 entries instead of one is one Map merge per request. The existingechoAsync/echoEffectprobes record the delta as a diagnostic in the PR body (no stable benchmark harness exists to make it a hard gate).effect/wrapnot needed.3. Phasing β six stacked PRs
Every PR:
make static-check; gate suites below; existing tests unchanged (PR 6 is the only PR that edits tests, and only to replace real-timer probes). Before@codex review, run the house pre-review audits:EffectRunner+ worker scope, or supervised viaAppFiberScope).Effect.uninterruptibleend-to-end; bounded waits inside useEffect.interruptible(Effect.timeout(...))(house shape from π€ refactor: convert memoryConsolidationService and workspaceStatusGenerator internals to Effect; make in-flight run-lock reservation deterministicΒ #4038).disposeAppRuntime/closeScopeBoundedand every Promise facade fold defects;makeAppRuntimeis the one place allowed to throw (constructor semantics).rg 'spyOn\(' src/node/services/<touched>.test.ts tests/per touched class; constructor arity and private-method Promise signatures unchanged (typecheck of tests proves it).EffectRunnerruns to its firstsleepbeforerunForkreturns (mirrorsheartbeatService.ts:199-202).memoryConsolidationServiceis in the diff.PR 1 β Skeleton: AppRuntime + Stores/MemoryMeta layers + runtime-backed
effect/context+ dispose hook (+~150 LoC)Scope
di/tags.ts(ConfigTag,SessionLocatorTag,ProvidersConfigStoreTag,SecretsStoreTag,FileLeaseManagerTag,MemoryMetamoved fromorpc/effectContext.ts, which re-exports it;AppTagsunion).di/layers/stores.ts(StoresLive),di/layers/core.tswithMemoryMetaLive = Layer.effect(MemoryMeta, Effect.map(ConfigTag, c => new MemoryMetaService(c.rootDir))),di/layers/app.ts(AppLive(stores) = MemoryMetaLive βΉ StoresLive).di/appRuntime.ts(makeAppRuntime,disposeAppRuntime);APP_RUNTIME_DISPOSE_TIMEOUT_MSinsrc/constants/.coreServices.ts:CoreServicesOptions.memoryMetaService?(precedent:workspaceMcpOverridesService?).serviceContainer.ts: build runtime first, passContext.get(ctx, MemoryMeta)tocreateCoreServices,public readonly runtime,toORPCContext()["effect/context"] = this.serviceContext,dispose()appendsdisposeAppRuntimebehind adisposedlatch; newlog.debug("[startup] AppRuntime built", { ms }).orpc/effectContext.ts:OrpcEffectServices = AppTags;buildOrpcEffectContextretyped/test-helper doc.headlessEnvironment.disposecallsawait services.dispose()before removing the temp dir (the bench harness currently leaks the container; runtime ownership starts here).Acceptance
di/appRuntime.test.ts: (a) sync build setscachedContext; (b) a layer with an async body makesmakeAppRuntimethrow synchronously (I1 enforced); (c) probe layers' finalizers run in reverse order on dispose; (d) dispose is idempotent and bounded (hung finalizer βwarn, resolves at the timeout); (e)runtime.runForkafter the eager build starts synchronously.serviceContainer.test.ts:Context.get(toORPCContext()["effect/context"], MemoryMeta) === services.memoryMetaService;dispose()closes the runtime;dispose(); shutdown()(tests/ipc order) is clean; a throwing layer surfaces as a synchronous throw fromnew ServiceContainer(stores)(same shape as today's constructor throw β existing entry-point catch paths).effectBridge.test.ts,memoryMeta*.test.tsunchanged and green; echo-probe overhead recorded in the PR body.bun test src/node/services/di src/node/services/serviceContainer.test.ts src/node/orpc src/node/services/memoryMeta*Β·make test-integrationΒ·make static-check.Rollback:
git revert; classes untouched.PR 2 β Runtime seams:
EffectRunner+AppFiberScope; TestClock on idleCompaction/heartbeat/retryManager (+~140 LoC)Scope
di/effectRunner.ts,di/appFiberScope.ts;AppLivegainsAppFiberScopeLive βΉ EffectRunnerLiveat the base;ServiceContainerexposesappFiberScope(used only bydispose()in Phase 11) and closes it per Β§5.IdleCompactionService,HeartbeatService,RetryManager: trailing optionalrunner: EffectRunner = defaultEffectRunner; every lifecycleEffect.runSync/runForkinstart/stop/schedule/cancelbecomesthis.runner.runX. Deadline math (Date.now()/injectednow) unchanged.ServiceContainerpassesContext.get(ctx, EffectRunnerTag)to the two workers;RetryManagerkeeps the default until PR 5 (sostreamManager.tsis untouched here).di/testEffectRunner.tshelper.Acceptance
defaultEffectRunnerpath, which is production behavior wherever no runner is injected): heartbeatSTARTUP_DELAY_MSβ first tick afteradjust, one tick perCHECK_INTERVAL_MS, no ticks afterstop(); idleCompaction initial delay + cadence; retryManager fires exactly atdelayMs,cancel()beforeadjustnever fires.runner.runSync(Scope.close(scope, Exit.void))completes synchronously for a fiber suspended on a TestClock sleep;runForkthrough the runner reaches its first sleep synchronously;Effect.context<never>()insideEffectRunnerLivesees the upstreamTestClock(else the helper providesClock.Clockexplicitly β same seam, one line).AppFiberScopecontract tests: (i) an I/O-suspended fiber (interruptibleEffect.asyncthat never resolves, with a cancel path) forked withEffect.forkIn(_, appFiberScope)is interrupted and awaited bycloseScopeBounded(appFiberScope)β and this happens before the explicit teardown steps indispose()(assert ordering against a spy ondesktopBridgeServer.stop); (ii) a fiber forked viaEffectRunneris not interrupted by either close (documents the asymmetry); (iii)disposeAppRuntimeafterwards idempotently re-closes the already-closed child scope (no error, no second finalizer run).TestClock.adjustleaves continuations pending, the helper addsEffect.yieldNow/Fiber.awaitβ decided by tests.heartbeatService.test.ts,idleCompactionService.test.ts,retryManager.test.ts,serviceContainer.test.ts,di/*, tests/ipc.Rollback: revert restores defaults; no call site depends on the new params.
PR 3 β Shared core root: coarse
CoreProjectionLive+createCoreServicesfacade + CLI runtime disposal (+120 / β10)Scope
CoreOptionsTag;StoresFromCoreOptionsLive.CoreProjectionLive = Layer.effectContext(Effect.gen(function*(){ const opts = yield* CoreOptionsTag; const stores = yield* β¦; const core = buildCoreGraph({ ...opts, ...stores }); return Context.make(History, core.historyService).pipe(Context.add(...)) }))wherebuildCoreGraphis today'screateCoreServicesbody, unchanged, renamed.createCoreServices(opts)=makeAppRuntime(CoreProjectionLive βΉ StoresFromCoreOptionsLive βΉ AppFiberScopeLive βΉ EffectRunnerLive βΉ Layer.succeed(CoreOptionsTag, opts)), returns today'sCoreServicesobject read from the context plusruntimeandappFiberScope.cli/run.tsandcli/workflow.tscleanup lists appendcloseScopeBounded(appFiberScope)beforesession.dispose()anddisposeAppRuntime(runtime)afterbackgroundProcessManager.terminateAll().ServiceContainerstops callingcreateCoreServices;AppLive = CoreProjectionLive βΉ CoreOptionsFromDesktopLive βΉ CrossCuttingLive βΉ β¦(cross-cutting services move intoCrossCuttingLivenow because core options derive from them). Desktop constructions otherwise stay in the constructor.Acceptance
CoreServicesfield===Context.get(ctx, Tag);serviceContainer.test.tsunchanged and green.make typecheckwall time,[startup] AppRuntime builtms andinitializetotals vsorigin/mainbaseline from the sandbox (Β§7). Proceed to PR 4 only if typecheck regresses < 10 % and startup within noise; otherwise stop at (C).bun test src/node/services,src/cli/*.test.ts(run/workflow/server/cli), tests/ipc,make static-check.Rollback: revert restores the imperative call; PR 1/2 unaffected.
PR 4 β Peel the core into staged per-service Layers +
CoreWiringLive(+330 / β290 β net β +40; split 4a/4b if > ~600 diff lines)Scope
Layer.effectadapters with today's argument lists;CoreWiringLive(Effect.synconly) replays the wiring lines in order;CoreLive = CoreWiringLive.pipe(Layer.provideMerge(S8))replacesCoreProjectionLive;buildCoreGraphdeleted.StreamManager β SessionUsageis the kind of edge that turns "siblings" into a stage split) and record it in the PR body.AIService) / 4b (S4βS8 + wiring) if needed β 4a alone is mergeable because the remaining services are built by a shrunken projection layer that reads S1βS3 from the context.Acceptance
turnRequestBuilderBindingsfully populated; goal continuation consumer registered onidleDispatcher;streamManagerMCP manager set; registration probe installed onextensionMetadata.neveratmakeAppRuntime) demonstrated by a type-level test (// @ts-expect-error).streamManager*.test.ts,aiService.test.ts,workspaceService*.test.ts.Rollback: revert to PR 3's projection.
PR 5 β
DesktopLivegroup layers +DesktopWiringLive; thinServiceContainer;StreamManagerrunner param (+170 / β150 β net β +20)Scope
Layer.effectContext, today's construction order inside each;provideMergebetween groups that depend on each other);DesktopWiringLive(Effect.synconly) =serviceContainer.ts:209, 263-265, 271, 288-290, 334-340, 348, 365, 375, 381-382, 434, 438-471, 474-574in order.ServiceContainerconstructor =makeAppRuntime(AppLive(stores))+ field assignment from the context.toORPCContext()unchanged in shape.StreamManager: optional trailingrunner: EffectRunner;schedulePartialWritefork (streamManager.ts:1141) andRetryManagerconstruction use it;Scope.closestaysEffect.runFork(existing async-close precedent).WorkersLivereceivesEffectRunnerTag.Acceptance
serviceContainer.test.tsassertions unchanged; new identity test overtoORPCContext()fields vs tags;dispose()/shutdown()call order asserted via spies on the public methods already spied today.make test-integration),src/cli/server.test.ts,src/cli/cli.test.ts,streamManager*.test.ts,aiService.test.ts.PR 6 β TestClock adoption sweep + shutdown hardening + contract docs (+~20 LoC product; tests edited)
Scope
makeTestEffectRunner()inheartbeatService.test.ts,idleCompactionService.test.ts,retryManager.test.ts, and the partial-write debounce cases ofstreamManager.test.ts; keep one real-timer smoke test per worker (guards thedefaultEffectRunnerpath).cli/server.ts:[shutdown]log lines per step incl.AppRuntime disposed {ms}; confirm the wholedispose()fits the existing 5 s force-exit budget.di/appRuntime.ts(I1βI8, Β§5).Acceptance: converted suites have zero
setTimeout-based cadence waits (grep in PR body), same assertions;make test-integrationgreen; sandbox startup/shutdown evidence (Β§7).4. TestClock story
Effect.sleep,Schedule.fixed,Effect.timeout,Clock.currentTimeMillisread theClockreference from the running fiber's context. Workers that fork through anEffectRunnerbuilt underTestClock.layer()run on the test clock;await testRunner.adjust("2 minutes")advances it.Date.now(),setTimeout,setIntervalare unaffected β heartbeat deadline math via injectednow,AgentStatusService's ref'dsetInterval, andbackgroundProcessManagerstay on real timers/injected timestamps.heartbeatService.test.ts(6),idleCompactionService.test.ts(2),retryManager.test.ts(3setSystemTimeβadjust;Date.now-basedretryAtmay move toClock.currentTimeMillisonly if a test needs both clocks aligned),streamManager.test.tsdebounce cases (7).streamBridge.test.tsticker (11) β needs a context/runner parameter onsubscriptionIterable; OAuth device-flow polling andoauthFlowManager.test.ts(25) β non-goal.backgroundProcessManager72,quickjsRuntime26, lock sleeps inworkspaceService/taskService), end-to-end suites (tests/ipc, e2e).adjustruns due sleeps and their synchronous continuations before resolving (or the helper yields until they do);Schedule.fixedanchoring underTestClockmatches the wall-clock expectations inheartbeatService.ts:149-155; syncScope.closeof a TestClock-suspended fiber completes synchronously.5. Shutdown protocol
main.tsbefore-quit(preventDefault βdispose()raced with 5 s βapp.quit(); update-install path fire-and-forget), the secondbefore-quitlistener'sshutdown()(unchanged, concurrent),cli/server.tsSIGINT/SIGTERM (5 s force exit), ACPclose(), tests/ipc (dispose()thenshutdown()), headless bench (dispose()from PR 1).ServiceContainer.dispose()order:backgroundProcessManager.beginShutdown()β unchanged, first (latch protecting persisted monitor records).closeScopeBounded(appFiberScope, APP_FIBER_SCOPE_CLOSE_TIMEOUT_MS)β interrupts and awaits supervised fibers while every dependency they might touch during finalization is still alive. No occupants in Phase 11; the position is fixed now so the engine-core phase does not have to re-derive it.desktopBridgeServer.stop()β¦terminateAll()β¦timelineService.flush()).disposeAppRuntime(runtime, APP_RUNTIME_DISPOSE_TIMEOUT_MS)β closes the runtime scope (interrupts any fiber started viaruntime.runXβ none long-lived in Phase 11; runs layer finalizers β none in Phase 11 by I5). Hung βwarnat the timeout; never rejects.Budget: 2 s + 2 s inner bounds inside the callers' 5 s outer budgets; the outer race in
main.tsremains the last line of defense.Rule for future occupants: anything forked into
AppFiberScopemust tolerate interruption at any suspension point and must not depend on resources torn down in step 1; anything that needs a Layer finalizer must first prove reverse-construction order is compatible with steps 2β3 (I5).disposedmakesdispose()idempotent (twobefore-quitlisteners, tests/ipc dispose+shutdown).shutdown()never touches the runtime orAppFiberScope.EffectRunnerhandles keep working after runtime dispose (I2), so a straytick()/scheduleRetry()after quit cannot defect. TheManagedRuntimeis referenced only byServiceContainerand thecreateCoreServicesreturn value.stop()stays synchronous (runner.runSync(Scope.close)) because their fibers suspend only on the clock. The engine core will fork intoAppFiberScope(step 2.2 awaits it) β the reason both seams exist now.uncaughtException/SIGKILL run no finalizers. Finalizers are best-effort; durable state must remain crash-safe without them (AGENTS.md self-healing rule). Nothing in Phase 11 makes a finalizer the sole guardian of durable state.6. Risk register
runSyncthrows at startupprovideMergestages; wiring layers replay today's order; tests/ipc as behavioral gateshutdown()β₯dispose(); dispose+shutdown in tests)disposedlatch; runtime/AppFiberScope closed only indispose(); PR 1 testruntime.runXafter dispose β defectEffectRunner, never the ManagedRuntimeContextβServiceMap, Layer renames)Layer/Context/ManagedRuntime/TestClockimports confined todi/; exact pinAppRuntime builtms +initializetotals vs baseline in sandbox; PR 3 gatemake typecheckwall time; fallback (C)Effect.provideof a ~70-entry Contextsrc/cli/*.test.tsassert the cleanup steps existEffectRunnerexpecting dispose to await itEffectRunner("unsupervised"); PR 2 asymmetry test; review audit 1Rollback: PRs are stacked; revert in reverse order (6β1). Service classes are never modified except for optional trailing params, so any revert restores the previous composition root wholesale with no data or API implications.
7. Dogfooding (per PR; evidence attached to the PR body)
Environment (headless Coder host, no
DISPLAY):<XUM_ROOT>/logs/*.logshows, in order:Loading services...,[startup] AppRuntime built {ms},[startup] ServiceContainer.initialize starting, six step durations,[startup] ServiceContainer.initialize completed {totalMs, stepDurationsMs}. Paste baseline (origin/main) vs branch numbers.xum serverexits non-zero with the existing logged error and no unhandled-rejection trace; for desktop, confirm by code path (loadServices()rejects βmain.ts:1255dialog) and viasrc/cli/server.test.ts/ACP tests.open <url>βsnapshot -iβ add a scratch git repo as a project β create a workspace β send one message βscreenshotthe loaded app and the response;attach_fileboth. Video: startagent-browser recordbefore the flow and stop it with a hard timeout (timeout 30 agent-browser record stop); if stopping hangs (known), attach the truncated WebM plus the screenshots and say so.handlerGen+ runtimeeffect/context); screenshot before/after; grep logs forManagedRuntime disposed/defect lines (expect none).script -q /tmp/<workspace>-shutdown.log(oragent-ttyif present),kill -TERM <pid>β expect[shutdown]lines,AppRuntime disposed {ms}, exit 0, no force-exit message; attach the typescript. Exercise the timeout branch once with a scratch hung finalizer βwarn+ timely exit.Xvfb,make dev+ agent-browser via CDP (electron skill): screenshot splash β main window, quit via menu, confirm exit < 5 s; otherwise state that the Electron path is covered bytests/e2ein CI and the shareddispose()path exercised byserver.ts.Gate suites per PR (plus
make static-checkalways):src/node/services/di/*,serviceContainer.test.ts,src/node/orpc/*,memoryMeta*,make test-integrationheartbeatService.test.ts,idleCompactionService.test.ts,retryManager.test.tsbun test src/node/services,src/cli/*.test.ts; record PR 4 gate numbersstreamManager*.test.ts,aiService.test.ts,workspaceService*.test.tsmake test-integration,src/cli/server.test.ts,src/cli/cli.test.tsmake test-integration+ sandbox startup/shutdown evidence8. Non-goals (explicit)
AppFiberScopeoccupant; separate phase).Schemaat persistence boundaries; OAuth refresh/device-flow workers;AgentStatusServicesetIntervalβ Effect.initialize()as a Layer/startup effect (D2); per-service optional tags (D3);streamBridgeon the runtime; layer finalizers for existingdispose()steps.effect/contextsource.9. Assumptions stated
Effect.context<never>()insideEffectRunnerLivereturns the enclosing build context including an upstreamTestClockentry (PR 2 test; fallback: provideClock.Clockexplicitly in the helper).Scope.fork(parent)inside aLayer.effectbody yields a child closed by the runtime's layer scope ondispose()(PR 2AppFiberScopetest).provide/provideMergestages or wiring-layer statement order.EffectRunner'sR = neverconstraint is sufficient for every lifecycle fork in the three Phase 11 workers andStreamManager.schedulePartialWrite(they only useEffect.sleep/Schedule/Effect.sync/Effect.tryPromiseβ no service tags). Verified by typecheck in PR 2/5.Generated with
xumβ’ Model:anthropic:claude-fable-5-1β’ Thinking:xhighβ’ Cost:$21.59