Skip to content

πŸ€– refactor: Effect Phase 11 PR 1 β€” ManagedRuntime skeleton (AppRuntime + Stores/MemoryMeta layers + runtime-backed effect/context) - #4049

Merged
ThomasK33 merged 1 commit into
mainfrom
effect-phase11-managed-runtime-di
Sep 2, 2026
Merged

Conversation

@ThomasK33

Copy link
Copy Markdown
Member

Summary

Effect migration Wave 3 / Phase 11, PR 1 of 6 (skeleton): introduces the app-lifetime Effect ManagedRuntime ("AppRuntime") built from a Layer graph, with the first two layers (StoresLive exposing the ConfigStores, MemoryMetaLive constructing MemoryMetaService), and wires it into ServiceContainer: the runtime is built eagerly and synchronously before the constructor-wired services, its built Context becomes the oRPC "effect/context", and disposeAppRuntime (bounded, never rejects, idempotent) is the last dispose() step. Every service class, constructor signature, facade, and test seam is unchanged; existing tests are untouched and green.

Background

Two migration waves (#4022β†’#4040) converted service internals to Effect behind Promise facades. #4040's completion notes name a DI/runtime skeleton as the prerequisite for the streamManager engine-core conversion (needs an app-owned async Scope.close), TestClock for timing suites, and app-lifetime scopes. The approved Phase 11 plan (embedded below) phases that into six PRs; this is the smallest possible first step that proves the pattern end-to-end with tests unchanged.

Implementation

  • src/node/services/di/tags.ts β€” Context.Service tags (ConfigTag, SessionLocatorTag, ProvidersConfigStoreTag, SecretsStoreTag, FileLeaseManagerTag, MemoryMeta moved here from orpc/effectContext.ts, which re-exports it) + the AppTags union. Type-only imports of service classes β†’ no import cycles.
  • di/layers/stores.ts (StoresLive), di/layers/core.ts (MemoryMetaLive = Layer.effect(MemoryMeta, Effect.map(ConfigTag, c => new MemoryMetaService(c.rootDir)))), di/layers/app.ts (AppLive(stores) = MemoryMetaLive.pipe(Layer.provideMerge(StoresLive(stores)))).
  • di/appRuntime.ts β€” makeAppRuntime(layer): ManagedRuntime.make + eager runSync(Effect.context()) + assert(cachedContext); a layer body that suspends or throws fails at construction, exactly where a throwing service constructor fails today (so every entry point's existing startup catch path applies). disposeAppRuntime(runtime, timeoutMs): uninterruptible shell, disposeEffect forked detached, interruptible bounded Fiber.join + Effect.timeout, TimeoutError/defects folded to log.warn. The module doc comment carries the DI contract (sync layer bodies as a Phase 11 compatibility rule; only the composition root holds the runtime; no layer finalizers yet).
  • ServiceContainer: public readonly runtime: AppRuntime<AppTags> built first; the layer-built MemoryMetaService is handed to createCoreServices via a new optional memoryMetaService? (same precedent as workspaceMcpOverridesService?); toORPCContext()["effect/context"] = runtime.context; dispose() ends with disposeAppRuntime behind a runtimeDisposed latch.
  • orpc/effectContext.ts: OrpcEffectServices = AppTags; buildOrpcEffectContext stays as the narrow test helper it already is (only caller: effectBridge.test.ts).
  • headlessEnvironment.dispose now calls services.dispose() (the bench harness previously leaked the container; runtime ownership starts here).
  • APP_RUNTIME_DISPOSE_TIMEOUT_MS = 2 s in src/constants/terminationTimeouts.ts (inside the 5 s quit budgets of desktop/main.ts / cli/server.ts).

Validation

New tests: di/appRuntime.test.ts (sync build caches context; async layer body β†’ synchronous throw; throwing body β†’ synchronous throw; runFork after eager build starts synchronously; finalizers run in reverse acquisition order; dispose idempotent; hung finalizer β†’ returns at timeout with a warn, no rejection) and three serviceContainer.test.ts cases (field ↔ effect/context identity for MemoryMeta; runtime still alive at the last explicit dispose step and gone after; a throwing layer surfaces as a synchronous new ServiceContainer() throw). effectBridge.test.ts / memoryMeta*.test.ts unchanged and green.

Pre-review audits (plan Β§3): interruption posture β€” one detached fiber (disposeEffect), whose join is the only interruptible wait; teardown shell Effect.uninterruptible; makeAppRuntime is the single place allowed to throw and disposeAppRuntime folds TimeoutError + defects; spy seams β€” no spyOn targets MemoryMetaService, constructor arity unchanged ((xumHome)), tests typecheck; sync-start pinned by test; MemoryMetaService constructor only computes a path (no collaborator side effects).

Dogfooding (dev-server sandbox on a headless Coder host; XUM_LOG_LEVEL=debug DEV_SERVER_SANDBOX_ARGS="--clean-projects")

  • Startup ordering (branch): [startup] AppRuntime built { ms: 3 } β†’ [startup] ServiceContainer.initialize starting β†’ six step durations β†’ [startup] ServiceContainer.initialize completed { totalMs: 251 }. Baseline origin/main standalone xum server: initialize completed { totalMs: 271 } (workspaceService 82 / taskService 104 ms). The added constructor work is the 3 ms build.
  • Memory pin/unpin through the UI (Settings β†’ Experiments β†’ Agent Memory on; Settings β†’ Memory; seeded <XUM_ROOT>/memory/global/pr1-dogfood.md): Pin β†’ memory-meta.json shows "pinned": true, "accessCount": 1; Unpin β†’ "pinned": false. These memory.* procedures ride handlerGen; they use the transitional context.memoryMetaService.effects… style, so this proves the layer-built instance serves the handler path (tag resolution through effect/context itself is proven by effectBridge.test.ts + the identity test). Screenshots (03-memory-experiment-enabled, 04-memory-panel, 05-memory-pinned, 06-memory-unpinned) and a WebM of the full pin/unpin cycle were captured with agent-browser and are attached in the Mux chat transcript β€” GitHub image upload is unavailable from this host (SSO upload-token failure).
  • Graceful quit (standalone node dist/cli/index.js server under script -q, kill -TERM): Shutting down server... β†’ AgentStatusService stopped β†’ terminateAll β†’ [shutdown] AppRuntime disposed { ms: 2 } β†’ COMMAND_EXIT_CODE="0", no "Cleanup timed out". Repeated after the probe below was reverted (clean rebuild): same result.
  • Startup-never-crash parity probe (local edit, not committed): a Layer.sync(MemoryMeta, () => { throw … }) in AppLive β†’ Failed to initialize server: Error: PR1 dogfood probe: layer body threw during startup via the existing main().catch β†’ exit code 1, stack points at the layer body, no unhandled-rejection trace, no AppRuntime built/initialize lines (failed at construction, as designed). The sandbox's nodemon showed app crashed - waiting for file changes, identical to a throwing constructor.
  • Electron path: not exercised here (no display); the desktop quit path shares ServiceContainer.dispose() with cli/server.ts, which was exercised above, and tests/e2e covers it in CI.

Test lanes

make static-check green. bun test src/node/services/di src/node/services/serviceContainer.test.ts src/node/orpc src/node/services/memoryMeta* green. Full bun test src under host load wedged in workflow_run.test.ts (unrelated tool test); every unexpected (fail) in that lane either passes in isolation (workflow_run 25/25, workspaceGoalService 189/189, WorkspaceFooterBar 15/15) or fails identically on an origin/main worktree (workspaceTurnManager 2, productIdentity 1, agent_skill_delete 1 β€” pre-existing environment failures, alongside the known taskService/workspaceService baselines). jest lane (TEST_INTEGRATION=1 bun x jest tests, tests/ipc + tests/ui): see the CI checks / the run summary in the notes below.

Risks

Low. Behavior change is confined to: (1) MemoryMetaService is constructed by a layer instead of createCoreServices (same arguments, same single instance β€” asserted by identity tests), (2) "effect/context" carries six tags instead of one (only effectBridge probes yield tags today), (3) dispose() gains a final bounded runtime close (no layer finalizers exist yet, so it reorders nothing), (4) the headless bench harness now disposes its container. The remaining risk is the sync-layer contract itself; it is enforced at construction and covered by tests.

PR 1 notes

  • Echo-probe overhead (effectBridge.test.ts informational bench, 2000 sequential calls, three runs each): branch β€” effect 24.3 / 21.9 / 22.0 Β΅s/call, overhead vs plain async 8.3 / 8.3 / 7.9 Β΅s/call; origin/main β€” effect 18.5 / 26.9 / 23.0 Β΅s/call, overhead βˆ’1.5 / 4.0 / 10.0 Β΅s/call. Noise-level identical; note the probe uses the test's narrow buildOrpcEffectContext context on both sides, and the production context has six entries in PR 1.
  • Deviations from the plan (all within the plan's stated contract):
    1. disposeAppRuntime bounds the wait by forking disposeEffect detached and timing out the interruptible Fiber.join, rather than Effect.timeout directly on disposeEffect: scope finalizers run uninterruptibly, so interrupting the close itself would still wait on a hung finalizer. The contract (bounded, never rejects, idempotent) is what the tests pin.
    2. AppRuntime<R> is a small interface { managed, context, get } and ServiceContainer.runtime is that wrapper; "effect/context" is runtime.context (the plan's serviceContext). Keeps Context.get inside di/.
    3. The latch guards only the runtime-dispose step, not all of dispose(), so existing dispose semantics are byte-identical.
    4. The [startup] AppRuntime built debug line lives in makeAppRuntime (shared by the future CLI root in PR 3) instead of ServiceContainer.
  • Lessons for PR 2 (EffectRunner + AppFiberScope + TestClock on idleCompaction/heartbeat/retryManager):
    • rc.112 API notes: Layer.succeed is curried-only (Layer.succeed(Tag)(value)); ManagedRuntime<R, ER> is contravariant in R, so helpers accepting any runtime must take ManagedRuntime<never, never>; Effect.timeout fails with Cause.TimeoutError (_tag: "TimeoutError"); Effect.runSync really does throw on an Effect.promise inside a layer body, so the eager-build assert is a belt-and-braces check.
    • Bounded teardown shape that actually bounds: Effect.forkDetach(target) + Effect.interruptible(Fiber.join(fiber).pipe(Effect.timeout(ms))) inside Effect.uninterruptible. Reuse for closeScopeBounded(appFiberScope).
    • Bun spyOn(namespaceImport, "AppLive") intercepts ServiceContainer's named import (live binding) β€” a cheap way to inject test layers/probes without new production seams; PR 2's EffectRunner tests can use the same trick for AppLive/EffectRunnerLive.
    • Dev-server sandbox is fragile across a crash cycle (its build watcher died after the probe; nodemon stayed in "app crashed"); for startup/shutdown probes prefer a standalone node dist/cli/index.js server under script -q with a temp XUM_ROOT β€” it also yields the exit code.
    • Full bun test src can wedge under host load (a workflow_run duplicate-guard test hung without a timeout); classify unexpected failures by re-running in isolation and against an origin/main worktree with a symlinked node_modules (~15 s per file) rather than waiting on the lane.

πŸ“‹ Implementation Plan

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.provides 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

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
Loading

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):

// 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)

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 + 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.
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 turn initialize() into runtime.runPromise(startupEffect) with per-step Effect.timeout.

D3 β€” Optional cross-cutting services stay optional via `CoreOptionsTag`, not `Effect.serviceOption`

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.

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. Explicit Clock injection per worker was rejected (a provideService(Clock.Clock, …) at every fork site, and it does not extend to other refs).

D5 β€” oRPC: `effect/context` = the runtime's `Context`; `handlerGen` unchanged

handlerGen already Effect.provides 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.

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 πŸ€– refactor: convert memoryConsolidationService and workspaceStatusGenerator internals to Effect; make in-flight run-lock reservation deterministicΒ #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):

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.

Generated with xum β€’ Model: anthropic:claude-fable-5-1 β€’ Thinking: xhigh β€’ Cost: $45.57

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

This comment has been minimized.

@chatgpt-codex-connector

This comment has been minimized.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Hooray!

Reviewed commit: 4cd8010ca4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

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".

@chatgpt-codex-connector

This comment has been minimized.

@chatgpt-codex-connector

This comment has been minimized.

@ThomasK33

ThomasK33 commented Sep 2, 2026

Copy link
Copy Markdown
Member Author

Status note: the red Test / Unit check is a trunk regression, not this PR…

Resolved. The trunk regression (productIdentity.test.ts version-lock) was fixed upstream in #4048 (bea8aff5e). This PR was rebased onto it (cf727c7c3), both Codex reviews re-ran clean on the new head (normal πŸ‘, security clean; one P1 about fail-fast layer bodies was answered in-thread β€” it is the approved plan's I1 parity contract with the pre-existing throwing-constructor path β€” and resolved), all required checks passed, and the PR merged through the queue as 7c5416697.

… + Stores/MemoryMeta layers + runtime-backed effect/context
@ThomasK33
ThomasK33 force-pushed the effect-phase11-managed-runtime-di branch from 4cd8010 to cf727c7 Compare September 2, 2026 01:33
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

This comment has been minimized.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ’‘ Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cf727c7c34

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

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".

Comment thread src/node/services/di/appRuntime.ts
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

Re-requesting after replying to the P1 on di/appRuntime.ts:56: the fail-fast layer build is the approved plan's I1 contract (parity with the pre-existing throwing-constructor path, which every entry point already catches β€” see the thread reply and the "Startup-never-crash parity probe" in the PR body). No code change.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. πŸ‘

Reviewed commit: cf727c7c34

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

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".

@chatgpt-codex-connector

This comment has been minimized.

@ThomasK33
ThomasK33 added this pull request to the merge queue Sep 2, 2026
Merged via the queue into main with commit 7c54166 Sep 2, 2026
19 of 20 checks passed
@ThomasK33
ThomasK33 deleted the effect-phase11-managed-runtime-di branch September 2, 2026 02:13
asm pushed a commit to asm/mux that referenced this pull request Sep 2, 2026
…me seams; TestClock on heartbeat/idleCompaction/retryManager (coder#4050)

## Summary

Phase 11 PR 2 of the Effect migration adds the two runtime seams the
plan defines and puts the three clock-driven workers on the first one:
**`EffectRunner`** (context-bound, *unsupervised*, `R = never` runner β€”
lets `HeartbeatService`, `IdleCompactionService` and `RetryManager` run
on the app runtime's `Clock`, i.e. a `TestClock` in tests) and
**`AppFiberScope`** (a runtime-owned, *supervised* `Scope` that
`ServiceContainer.dispose()` now closes explicitly and early, bounded,
before the hand-ordered teardown). Service constructors gain one
trailing optional `runner` parameter defaulting to the global runtime;
no call site outside `ServiceContainer` changes, no persisted data or
IPC shapes change.

Stacked on #4049 (PR 1). Plan: Β§2.1
`effectRunner.ts`/`appFiberScope.ts`, Β§2.3 I2/I5, Β§3 "PR 2", Β§4, Β§5
(full plan in the toggle below).

## Implementation

- `di/effectRunner.ts` β€” `EffectRunner` interface
(`runSync/runSyncExit/runFork/runPromise/runPromiseExit`, accepting only
`Effect<A, E, never>`), `defaultEffectRunner` (global `Effect.runX`),
`effectRunnerFromContext(ctx)` (`Effect.run…With(ctx)`),
`EffectRunnerTag`, `EffectRunnerLive` (captures
`Effect.context<never>()` at the base of the graph).
- `di/appFiberScope.ts` β€” `AppFiberScopeTag: Scope.Closeable`,
`AppFiberScopeLive` (synchronous body: child of the runtime's layer
scope, parallel finalizers).
- `di/appRuntime.ts` β€” `closeScopeBounded(scope, timeoutMs)`; shares one
`boundedTeardown` shape with `disposeAppRuntime` (uninterruptible shell,
detached target fiber, interruptible bounded join, `TimeoutError`/defect
β†’ `log.warn`, never rejects, idempotent).
`APP_FIBER_SCOPE_CLOSE_TIMEOUT_MS` (2 s) in
`src/constants/terminationTimeouts.ts`.
- `layers/app.ts` β€” `AppLive = MemoryMetaLive β–Ή AppFiberScopeLive β–Ή
EffectRunnerLive β–Ή StoresLive`; `AppTags` gains `RuntimeSeamTags`.
- Workers β€” every lifecycle `Effect.runSync/runFork` in
`start/stop/scheduleRetry/interruptRetryFiber` becomes
`this.runner.runX`; deadline math (`Date.now()`) untouched.
`ServiceContainer` passes `Context.get(ctx, EffectRunnerTag)` to
heartbeat and idle compaction; `RetryManager` keeps the default at its
`agentSession.ts` call site (StreamManager gets its runner in PR 5).
- `ServiceContainer` β€” `appFiberScope` field; `dispose()` =
`beginShutdown()` β†’ **`closeScopeBounded(appFiberScope)`** β†’ existing
explicit sequence β†’ `disposeAppRuntime` (plan Β§5). `dispose()` is
latched as a whole (`disposePromise ??= disposeOnce()`, plan Β§5.3):
concurrent/repeated callers await the one in-flight teardown, so a
second caller cannot observe the already-marked-closed scope and run the
explicit steps while the first is still awaiting supervised fibers
(Codex P2, round 1). This replaces PR 1's runtime-only `runtimeDisposed`
latch.
- `di/testEffectRunner.ts` β€” `makeTestEffectRunner()` β†’ `{ runner,
adjust, setTime, dispose }` over `EffectRunnerLive β–Ή TestClock.layer()`;
the worker under test and `adjust` share one clock.

## PR 2 notes

**Deviations from the plan text**

1. `EffectRunnerLive` strips three *build-fiber artifacts* from the
captured context: the layer `Scope`, `Layer.CurrentMemoMap`, and
`Scheduler.Scheduler`. The plan assumed `Effect.context<never>()` yields
"refs plus stores"; in rc.112 a `Layer.effect` body's fiber context also
carries the layer scope, the memo map, and β€” because `makeAppRuntime`
builds eagerly with `runSync` β€” the **sync (microtask)
`MixedScheduler`** of that build. Without the omit every fiber forked
through the runner would be scheduled on microtasks instead of the
default `setImmediate` scheduler `Effect.runFork` uses. Pinned by
`effectRunner.test.ts` ("schedules forked fibers on the default
scheduler…", red without the omit).
2. `STARTUP_DELAY_MS`/`CHECK_INTERVAL_MS` (heartbeat) and
`INITIAL_CHECK_DELAY_MS`/`CHECK_INTERVAL_MS` (idle compaction) are now
exported so the cadence tests do not hard-code the values.
3. `EffectRunner` methods take no `RunOptions` (no worker passes any);
add when a caller needs them.
4. Whole-`dispose()` latch (above) instead of the runtime-only latch
from PR 1 β€” `Scope.close` marks a scope closed before its finalizers
finish, so per-step idempotence is not enough to keep the Β§5 order under
concurrent disposal.
5. `boundedTeardown` log lines: `[shutdown] AppRuntime disposed {ms}`
(unchanged wording) and `[shutdown] AppFiberScope closed {ms}`; timeout
β†’ `[shutdown] <subject> teardown timed out; finalizers continue
best-effort`.

**Pinned runtime facts (rc.112, all asserted in `di/*.test.ts`)**

- `runFork` through a runner (default or TestClock-bound) executes the
fiber up to its first `Effect.sleep` before returning;
`runSync(Effect.forkIn(...))` flushes the forked child to its first
sleep too.
- `runner.runSync(Scope.close(scope, Exit.void))` completes
synchronously for a fiber suspended on a `TestClock` sleep (interrupt β†’
latch cancel path β†’ exit, all synchronous) β€” the workers' sync `stop()`
contract holds on both clocks.
- `TestClock.adjust` resumes every due sleep **and runs its synchronous
continuation** before resolving (latch `openUnsafe` β†’ `fiber.evaluate`
inline, then `yieldNow`); a sleep registered during that continuation
whose deadline is still inside the adjust window fires in the same
`adjust` (`Schedule.fixed` cadence: one tick per interval,
`adjust(2Γ—interval)` β†’ 2 ticks). No extra `yieldNow`/`Fiber.await`
needed in the helper.
- `Effect.context<never>()` inside `EffectRunnerLive` sees an upstream
`TestClock` provided via `provideMerge` (`Clock.currentTimeMillis`
through the runner reads 0, then the adjusted time) β€” the explicit
`Clock.Clock` fallback was not needed.
- `Scope.fork(parent)` in a `Layer.effect` body yields a child that the
runtime's layer scope closes on `disposeAppRuntime` (backstop test), and
re-closing an already-closed child is a no-op (no second finalizer run,
no error).
- `Effect.forkIn(_, scope)` registers a finalizer that interrupts **and
awaits** the fiber; an `Effect.uninterruptible(Effect.never)` occupant
makes the close hang β†’ `closeScopeBounded` warns and returns at the
bound.
- Asymmetry documented by test: a fiber forked through the
`EffectRunner` is interrupted by neither
`closeScopeBounded(appFiberScope)` nor `disposeAppRuntime`.

**Pre-review audits (plan Β§3 preamble)**

1. Interruption posture β€” heartbeat scheduler / idle-compaction checker:
forked via `runner.runSync(Effect.forkIn(_, lifecycleScope))`,
unsupervised, interrupted synchronously by `stop()` (unchanged). Retry
fiber: `runner.runFork`, unsupervised, interrupted by
`interruptRetryFiber` (unchanged).
`closeScopeBounded`/`disposeAppRuntime`: one detached fiber each for the
close target (kept running best-effort past the bound). No production
occupant of `AppFiberScope`.
2. Uninterruptible teardown β€” `boundedTeardown` is
`Effect.uninterruptible` end-to-end; only the bounded join is
`Effect.interruptible`.
3. No defect escapes β€” `boundedTeardown` folds `TimeoutError` and
defects to `log.warn`; both Promise facades never reject (timeout tests
for both).
4. Spy-seam check β€” `rg 'spyOn\('` finds no spies on the three workers'
methods in `src`/`tests`; constructor arity unchanged (trailing optional
params only); typecheck of tests green.
5. Sync-start β€” pinned (see above).
6. Constructor side-effect audit β€” no constructor moved into a layer;
`AppFiberScopeLive`/`EffectRunnerLive` have no side effects beyond
forking a scope / capturing a context.
7. N/A (`memoryConsolidationService` untouched).

## Validation

- `bun test` gate: `heartbeatService*.test.ts`,
`idleCompactionService*.test.ts`, `retryManager*.test.ts`,
`serviceContainer.test.ts`, `di/*`, `src/node/orpc` β€” 235 pass, 0 fail,
3 skipped (7 new TestClock cases across the three workers, 7 runner-fact
cases, 5 `AppFiberScope` contract cases, 3 new container cases incl. the
dispose-order assertion against `desktopBridgeServer.stop` a TestClock
injected beneath the real `AppLive` via `spyOn(appLayers, "AppLive")`,
and the concurrent-`dispose()` case).
- `make static-check` green. `TEST_INTEGRATION=1 bun x jest
tests/ipc/...` locally: 20/25 suites pass; the 5 failing suites (`fork`,
`nameGeneration`, `modelNotFound`, `mcpConfig`, `init` "even when init
fails") all need real provider calls (HTTP 403 from the Coder AI bridge)
or SSH and fail identically for environment reasons β€” CI `Test /
Integration` is the lane for those.
- **Dogfooding** (headless Coder host; `node dist/cli/index.js server`
under `script -q -e` with a temp `XUM_ROOT`, `XUM_LOG_LEVEL=debug`):
- Startup order unchanged: `[startup] AppRuntime built {ms: 3}` β†’
`ServiceContainer.initialize starting` β†’ step logs β†’
`IdleCompactionService started` / `HeartbeatService started` β†’
`initialize completed {totalMs: 305}`.
- Graceful quit (SIGTERM 36 s after start): `[shutdown] AppFiberScope
closed {ms: 1}` β†’ `AgentStatusService stopped` β†’ `terminateAll` β†’
`[shutdown] AppRuntime disposed {ms: 3}`, exit **0** in 190 ms, no
force-exit message.
- Timeout branch (scratch `PR2_HANG_PROBE` env-gated occupant
`Effect.uninterruptible(Effect.never)` forked into `appFiberScope`,
**not committed**): `[shutdown] AppFiberScope teardown timed out;
finalizers continue best-effort {timeoutMs: 2000}` β†’ `AppFiberScope
closed {ms: 2036}` β†’ explicit steps β†’ `AppRuntime disposed`, exit 0.
- Note (pre-existing, not this PR): a SIGTERM sent ~6 s after startup
exits ~12 s later on **both** this branch and an `origin/main` build
(`AppRuntime disposed` lands within ~40 ms of `Shutting down server...`
in both; the tail is after `dispose()`, in
`serverService.stopServer()`), while at 36 s after startup both exit in
<200 ms.
- Dev-server sandbox (`--clean-projects`): added a scratch repo project,
created a workspace, sent a message and got the reply; enabled the
Workspace Heartbeats experiment and configured a 5-minute heartbeat on
the workspace β€” the heartbeat ticker (running on the real clock through
the context-bound runner) picked the workspace up once the interval
elapsed: `HeartbeatService: tracking workspace` (3:01) β†’
`HeartbeatService: queued heartbeat` β†’ `HeartbeatService: executing
heartbeat` (3:06) and the `[Heartbeat]` turn appeared in the chat.
Screenshots attached in the chat transcript.

## Risks

Low. Production forks are byte-for-byte the same effects; only the
runner object differs (`Effect.run…With(ctx)` vs `Effect.runX`), and the
`Scheduler`/`Scope` omit keeps the fiber scheduling identical to before.
`dispose()` gains one bounded, idempotent, never-rejecting await with no
occupants yet. Rollback: revert restores the defaults; no call site
depends on the new parameters.

## Lessons for PR 3

- `Effect.context<never>()` inside a layer body is the *build fiber's*
context: expect `Scope`, `Layer.CurrentMemoMap` and the eager build's
sync `Scheduler` in it β€” never capture it without the omit, and keep
`EffectRunnerLive` at the base so stores are the only services it
carries.
- `TestClock.layer()` provided beneath the real `AppLive`
(`realAppLive(stores).pipe(Layer.provideMerge(TestClock.layer()))` via
`spyOn(appLayers, "AppLive")`) puts the whole container's runner on
virtual time β€” reusable for `createCoreServices` identity/cadence tests
without production seams.
- The `boundedTeardown` helper is the shape for the CLI roots'
`closeScopeBounded(appFiberScope)` (before `session.dispose()`) and
`disposeAppRuntime(runtime)` (after `terminateAll`) in
`cli/run.ts`/`cli/workflow.ts`.
- PR 3 must record the PR 4 decision-gate numbers: `make typecheck` wall
time (branch vs `origin/main`), `[startup] AppRuntime built` ms (this
PR: 3 ms) and `initialize` totals (this PR: 227–305 ms in the
sandbox/CLI probes) vs baseline.

---

<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: `$14.97`_

<!-- mux-attribution: model=anthropic:claude-fable-5-1 thinking=xhigh
costs=14.97 -->
asm pushed a commit to asm/mux that referenced this pull request Sep 2, 2026
…ore root + createCoreServices facade + CLI runtime disposal (coder#4051)

## Summary

Effect migration Phase 11, PR 3 of 6: the whole core service graph now
builds inside the app's Effect `ManagedRuntime` as one coarse layer
(`CoreProjectionLive`), shared by the desktop `ServiceContainer` and the
headless CLI roots (`xum run`, `xum workflow`), which gain runtime
ownership + disposal. Zero behavior change by construction: today's
imperative construction body (`createCoreServices` β†’ renamed
`buildCoreGraph`) runs unchanged inside the layer. This PR also records
the **PR 4 decision gate** numbers (typecheck wall time, startup
timings) β€” see below.

Plan: `~/.xum/plans/mux/effect-phase11-managed-runtime-di.md` Β§3 "PR 3"
(attached verbatim below). Stack: #4049 (PR 1, skeleton) β†’ #4050 (PR 2,
runtime seams) β†’ **this**.

## Implementation

- `di/tags.ts`: tags for the 18 remaining `CoreServices` fields
(`History`, `InitStateManagerTag`, `Provider`,
`BackgroundProcessManagerTag`, `SessionUsage`, `WorkspaceGoal`,
`IdleDispatcherTag`, `AI`, `StreamManagerTag`, `MCPConfig`,
`MCPServerManagerTag`, `ExtensionMetadata`, `Workspace`, `Task`,
`WorkspaceTurnManagerTag`, `Memory`, `MemoryConsolidation`,
`TurnRequestBuilderBindingsTag`) + the 7 desktop cross-cutting services
(`Policy`, `Telemetry`, `Experiments`, `SessionTiming`, `Analytics`,
`DevTools`, `WorkspaceMcpOverrides`); unions `CoreTags`, `CoreRootTags`,
`CrossCuttingTags`, `AppTags`.
- `di/layers/core.ts`: `CoreOptionsTag` (`CoreServicesOptions` minus
`ConfigStores`), **`CoreProjectionLive = Layer.effectContext(…)`**
wrapping `buildCoreGraph` and returning `Context<CoreTags>`;
`coreContextFromServices` / `coreServicesFromContext` (the two
directions of the projection); `CoreRootLive(opts)` = the CLI root graph
(`CoreProjectionLive β–Ή succeed(CoreOptionsTag) β–Ή AppFiberScopeLive β–Ή
EffectRunnerLive β–Ή StoresFromCoreOptionsLive`).
- `di/layers/stores.ts`: `StoresFromCoreOptionsLive(opts)` reproduces
the `opts.x ?? new X(config.rootDir)` store defaults for CLI roots.
- `di/layers/desktop.ts` (new): `CrossCuttingLive` (group layer,
`Layer.effectContext`, today's construction order) and
`CoreOptionsFromDesktopLive` (derives the core options from the
layer-built cross-cutting instances + `MemoryMeta` + `Config`).
- `di/layers/app.ts`: `AppLive = CoreProjectionLive β–Ή
CoreOptionsFromDesktopLive β–Ή CrossCuttingLive β–Ή MemoryMetaLive β–Ή
AppFiberScopeLive β–Ή EffectRunnerLive β–Ή StoresLive`.
- `coreServicesRoot.ts` (new): `createCoreServices(opts)` =
`makeAppRuntime(CoreRootLive(opts))`, returns today's `CoreServices`
object read back from the context **plus `runtime` and
`appFiberScope`**. Kept as a separate module from `coreServices.ts` (see
notes).
- `serviceContainer.ts`: stops calling `createCoreServices`; reads the
cross-cutting services and the core graph from the runtime context.
`BackupService`/`BrowserBridgeTokenManager` and all other desktop
constructions stay in the constructor (PR 5).
- `cli/run.ts`, `cli/workflow.ts`: cleanup lists gain
`closeScopeBounded(appFiberScope)` right after
`backgroundProcessManager.beginShutdown()` (before `session.dispose()`)
and `disposeAppRuntime(runtime.managed)` after
`backgroundProcessManager.terminateAll()` (PR 2's `boundedTeardown`
shape: uninterruptible, bounded, never rejects, idempotent).

### Tests
- `coreServicesRoot.test.ts` (new): identity of every `CoreServices`
field vs `runtime.get(Tag)` (exhaustive `Record<keyof CoreServices,
Tag>`), stores identity/defaults, `CoreOptionsTag` contents, the CLI
cleanup pair (scope closed while runtime alive β†’ runtime disposed,
idempotent), throwing graph body β†’ synchronous throw.
- `serviceContainer.test.ts`: existing tests unchanged; one added
identity test over 22 public fields vs tags + `CoreOptionsTag`
derivation.
- `cli/workflow.test.ts`: one added end-to-end test pinning the cleanup
order through the debug shutdown lines (`AppFiberScope closed` β†’
`terminateAll() called` β†’ `AppRuntime disposed`).

## PR 3 notes

**Deviations from the plan**
1. `createCoreServices` lives in a new module `coreServicesRoot.ts`
instead of `coreServices.ts`: `di/layers/core.ts` must import
`buildCoreGraph` from `coreServices.ts`, so a facade in the same module
would create a runtime import cycle (`coreServices β†’ di/layers/core β†’
coreServices`). Keeping the body in place gives reviewers a 3-line diff
on `coreServices.ts` proving the body is unchanged; the two CLI import
sites move to the new module. PR 4 deletes `buildCoreGraph`, after which
the facade can fold back if desired.
2. `CrossCuttingLive` carries only the seven services the core options
derive from. `BackupService` and `BrowserBridgeTokenManager` (pure
constructors, no core dependency) stay in the constructor until PR 5's
group layers.
3. `CoreOptionsTag` is provided **above** the runtime seams in
`CoreRootLive` (not beneath `EffectRunnerLive` as the plan's formula
reads), so the runner's captured context stays "stores + refs" exactly
as PR 2 pinned.
4. The `xum run` cleanup order cannot be unit-tested without a provider
(the provider-denial path exits through the `unhandledRejection` handler
before the cleanup list β€” pre-existing); it is pinned through `xum
workflow` (same helpers, same order) and shown live in the dogfooding
transcript below.

**Pre-review audits (plan Β§3)**
1. Interruption posture β€” no new fiber forks; the only fibers are the
synchronous layer build fiber and `boundedTeardown`'s detached teardown
fibers (PR 2).
2. Uninterruptible teardown β€” CLI roots call PR 2's
`closeScopeBounded`/`disposeAppRuntime` unchanged.
3. No defect escapes β€” both helpers never reject (pinned by
`di/*.test.ts`); in `run.ts` they additionally sit inside
`runBestEffortCleanup`. `createCoreServices` throws only from
`makeAppRuntime` (constructor semantics), inside the callers' existing
startup error paths (`main().catch` / `createWorkflowContext`'s catch β†’
`disposeWorkflowResources`).
4. Spy seams β€” `rg 'createCoreServices'` β†’ only `cli/run.ts`,
`cli/workflow.ts` (+ new test); no test constructs or spies through it.
Return shape is additive (`runtime`, `appFiberScope`); every field
identity is asserted. No service class, constructor, facade or private
method changed.
5. Sync-start β€” eager `runSync` build; `CoreProjectionLive`'s body only
reads tags; asserted by `cachedContext !== undefined` after
`createCoreServices` and by the unchanged TestClock test on
`ServiceContainer`.
6. Constructor side-effect audit (I6) for the constructors that moved
into layers: `PolicyService` (`setMaxListeners` on its own emitter),
`TelemetryService`, `ExperimentsService`, `SessionTimingService`,
`AnalyticsService`, `DevToolsService`, `WorkspaceMcpOverridesService`
(assert on its own arg) β€” each touches only its declared arguments;
relative order preserved (policy β†’ telemetry β†’ experiments β†’
sessionTiming β†’ analytics β†’ devTools β†’ workspaceMcpOverrides). Store
defaults for CLI roots (`WorkspaceSessionLocator`,
`ProvidersConfigStore`, `SecretsStore`, `FileLeaseManager`) now build
before `HistoryService`/`InitStateManager`; their constructors only
compute paths. Core body constructors: unmoved.
7. Zero-suspension audit β€” `memoryConsolidationService` is constructed
inside `buildCoreGraph`; `git diff` on `coreServices.ts` is the module
doc comment, the function rename, and one stale comment word β€” no code
change.

## DECISION GATE for PR 4 β€” **GATE: PASS**

Criteria (plan Β§3 PR 3): proceed to PR 4 (staged per-service core
layers) only if `make typecheck` regresses **< 10 %** AND startup is
**within noise**; otherwise stop at (C) coarse projection.

**Methodology.** Same host, same `node_modules` (symlinked), both
revisions checked out as sibling `git worktree`s under `/tmp`
(`origin/main` = `655bc56f4` vs this branch = `c6494b1d2`) so Make/git
prerequisite overhead is location-neutral β€” a first attempt that
compared the Mux-managed worktree against a `/tmp` worktree carried a
~0.6 s prerequisite bias (`make typecheck TSGO=true`: 1.44 s vs 0.85 s)
unrelated to code. Runs interleaved main/branch/main/… to cancel host
drift; host was a shared 96-core Coder box at load β‰ˆ 130–146 with CPU
PSI `some avg60` β‰ˆ 30–34 % throughout (recorded before/after each
series). Medians reported.

**(a) `make typecheck` wall time** (both `tsgo` projects concurrently, 6
interleaved pairs):

| | origin/main | branch | Ξ” |
|---|---|---|---|
| median | **10.77 s** | **11.20 s** | **+4.0 %** |
| min / max | 10.46 / 11.73 s | 10.67 / 11.85 s | |

tsgo `--extendedDiagnostics` (4 runs each, interleaved) isolates the
compiler itself:

| project | metric | origin/main | branch | Ξ” |
|---|---|---|---|---|
| renderer (`tsconfig.json`) | check time (median) | 7.43 s | 7.53 s |
+1.3 % |
| renderer | types / instantiations | 1 942 113 / 7 799 643 | 1 928 628
/ 7 775 777 | βˆ’0.7 % / βˆ’0.3 % |
| main (`tsconfig.main.json`) | check time (median) | 3.34 s | 3.28 s |
βˆ’1.8 % |
| main | types / instantiations | 1 047 206 / 4 264 332 | 1 047 163 / 4
250 448 | Β±0 / βˆ’0.3 % |

**(b) Startup** (`bun src/cli/index.ts server --no-auth` with a fresh
`XUM_ROOT`, `XUM_LOG_LEVEL=debug`, recorded under `script -f`, 5
interleaved pairs; SIGTERM β‰ˆ1 s after `initialize completed`):

| metric | origin/main | branch | note |
|---|---|---|---|
| `[startup] AppRuntime built` ms (median, min–max) | 4 (4–4) | 11
(10–21) | expected: the whole core graph + cross-cutting services now
build inside the runtime (relocated from the constructor, see next row)
|
| `new ServiceContainer(stores)` cold, first construction in a fresh
process (3 processes) | 15.7 / 15.8 / 17.1 ms | 17.3 / 18.0 / 27.8 ms |
total construction β‰ˆ +2 ms cold; warm (2nd–15th construction) median 0.9
β†’ 1.1 ms |
| `[startup] ServiceContainer.initialize completed { totalMs }` (median,
min–max) | 80 (78–97) | 82 (73–120) | within noise |
| SIGTERM β†’ exit wall (median), exit code | 156 ms, 0/0/0/0/0 | 150 ms,
0/0/0/0/0 | `[shutdown] AppFiberScope closed` β†’ explicit steps β†’
`[shutdown] AppRuntime disposed` in every transcript |

PR 2 measured 3 ms / 227–305 ms on a different day/host state; today's
baseline on `origin/main` is 4 ms / 80 ms, so the comparison above is
like-for-like.

**Verdict: GATE: PASS** β€” typecheck +4.0 % wall (< 10 %;
compiler-internal check time within Β±2 %, type counts flat), startup
within noise (initialize 80 β†’ 82 ms; construction +β‰ˆ2 ms cold). Proceed
to PR 4.

## Lessons for PR 4
- Re-derive the DAG from the constructor argument lists in
`buildCoreGraph` before writing stages; the plan's stage table was
checked once. Known splitting edge: `StreamManager(historyService,
sessionUsageService, …)` needs `SessionUsage` β‡’ S2a (SessionUsage Β· Goal
Β· Memory) β†’ S2b (StreamManager); `MemoryService(config,
memoryMetaService)` needs `MemoryMeta` (S1);
`MemoryConsolidationService` needs `AIService` (S3) β‡’ S4;
`MCPConfigService` needs `aiService` as `workspaceMetadataProvider` β‡’
S4; `MCPServerManager` needs `mcpConfigService` +
`workspaceMcpOverridesService` β‡’ S5; `WorkspaceService` needs
`streamManager`, `aiService`, `extensionMetadata`, … β‡’ S6; `TaskService`
needs `workspaceService` + `TerminalAttentionStore` β‡’ S7;
`WorkspaceTurnManager` needs `taskService` β‡’ S8; `IdleDispatcher` is
constructed last today but has no dependencies (S1) β€” the wiring layer
must still register the goal-continuation consumer after
`Task`/`Workspace` exist.
- `CoreWiringLive` must replay, in order:
`extensionMetadata.setRegistrationProbe`, `turnRequestBuilderBindings.*`
(memoryService, mcpServerManager, workspaceHeartbeatService,
onWorkflowRunStatusChanged, workflowResultContinuationSender,
taskService, workspaceTurnManager), `streamManager.setMCPServerManager`,
`mcpServerManager.setSecretsResolver`, the `workspaceService.set*`
calls,
`workspaceGoalService.setOnActivityChange/setStreamInterrupter/registerGoalContinuationConsumer`,
`taskService.setWorkspaceTurnManager`,
`workspaceService.setAgentTaskIntegration`.
- `TerminalAttentionStore` and the default
`WorkspaceMcpOverridesService` are constructed inside the body but not
exported in `CoreServices`; they need tags in PR 4
(`TerminalAttentionStoreTag`; `WorkspaceMcpOverrides` exists β€” CLI roots
need a `Layer.sync` default beneath `CoreOptionsTag`).
- `coreContextFromServices`/`coreServicesFromContext` and the exhaustive
`Record<keyof CoreServices, Tag>` identity test are the acceptance
harness for the peel: after PR 4 the same test must pass with
`CoreProjectionLive` replaced by `CoreLive`.
- The `xum run` cleanup order can only be observed end-to-end with a
provider (transcript below); keep the `xum workflow` debug-line test as
the CI pin.

## Validation

- `make static-check` green; `bun test src/node/services` (6,762 pass;
the only failures β€” `taskGitPatchEngine` Γ—2, `WorkspaceTurnManager`
terminal recovery Γ—2, `agent_skill_delete` Γ—1, `BackupRepoCache` Γ—7,
`workflow_run duplicate guard` wedge β€” reproduce identically on a
pristine `origin/main` worktree on this host, i.e. pre-existing
environment baselines; `workflow_run.test.ts` passes 25/25 in
isolation); `bun test src/cli` 185/185; jest
`tests/ipc/{doubleRegister,savedQueries,windowTitle,acp.disconnectCleanup}`
18/18 (the `ServiceContainer`-booting suites that need no provider; the
provider/SSH-dependent rest is CI's lane).
- Dogfooding (headless Coder host, `script -f`-recorded transcripts,
`XUM_LOG_LEVEL=debug`):
- **`xum run`** (real turn, `anthropic:claude-haiku-4-5` via the Coder
AI bridge): reply `pong`, `Cost: $0.02`, exit 0; tail of the transcript
shows `[startup] AppRuntime built {ms: 8}` … `[shutdown] AppFiberScope
closed {ms: 6}` β†’ `BackgroundProcessManager.cleanup(run-…)`
(session.dispose) β†’ `terminateAll() called` β†’ `Cleaned up temp dir` β†’
`[shutdown] AppRuntime disposed {ms: 9}` β†’ `COMMAND_EXIT_CODE="0"`. A
first attempt with the plain API key hit the bridge's 403 β€” that errored
stream also ran the full cleanup list in the same order (exit 1 from the
stream error, not from cleanup).
- **`xum workflow`** (`wf run ./workflows/echo.js`): `AppRuntime built`
β†’ `ok from pr3` β†’ `AppFiberScope closed` β†’ `terminateAll() called` β†’
`AppRuntime disposed` β†’ exit 0 (same order the new `workflow.test.ts`
case pins in CI).
- **`xum server` graceful quit**: 5/5 probe runs on the branch exit 0
within 127–164 ms of SIGTERM with `[shutdown] AppFiberScope closed`
before the explicit steps and `[shutdown] AppRuntime disposed` last
(identical shape on `origin/main`, where PR 2 already placed these two
steps in `ServiceContainer.dispose()`).
- **Dev-server sandbox** (`make dev-server-sandbox --clean-projects`):
backend boots (`AppRuntime built {ms: 8}`, `initialize completed
{totalMs: 235–281}` with the seeded config), the web UI loads
(screenshot in the workspace chat shows the branch version string
`v0.28.3-nightly.148-16-gc6494b1d2`), and SIGTERM exits in 241 ms with
the two `[shutdown]` runtime lines in place.
- Not exercised headless: the Electron `before-quit` path (covered by
code path parity β€” `main.ts` calls the same `ServiceContainer.dispose()`
β€” and `tests/e2e` in CI). The oRPC Effect path (`handlerGen` +
`effect/context`) is covered by the unchanged `effectBridge.test.ts` and
the identity tests (`Context.get(effectContext, MemoryMeta) ===
services.memoryMetaService`).

## Risks

- **Low.** Service classes, constructors, facades and the core
construction body are unchanged; the only construction-order changes are
the pure store defaults (CLI roots) and
`BackupService`/`BrowserBridgeTokenManager` moving after the core graph
on the desktop (both constructors capture arguments only). A layer body
that throws still surfaces as the same synchronous throw from `new
ServiceContainer(stores)` / `createCoreServices(opts)` (tests). New CLI
cleanup steps never reject (PR 2 contract, tests) and are bounded (2 s
each) inside the existing budgets.
- Startup: construction moves inside the runtime (+β‰ˆ2 ms cold total, see
gate); no `initialize()` changes.

---

<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: `$22.48`_

<!-- mux-attribution: model=anthropic:claude-fable-5-1 thinking=xhigh
costs=22.48 -->
asm pushed a commit to asm/mux that referenced this pull request Sep 2, 2026
…–S3 (History … AIService) + remainder projection (coder#4054)

## 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`, … `AILive` in `di/layers/core.ts`, composed S1 β†’ S2a β†’
S2b β†’ S3 with `Layer.provideMerge` between stages and `Layer.mergeAll`
only for verified siblings). The remainder of the former imperative body
(`MemoryConsolidationService` … `WorkspaceTurnManager` plus **all**
setter/listener wiring, order unchanged) survives as `buildCoreTail`,
run by a transitional projection layer over the layer-built head.
`CoreLive` replaces `CoreProjectionLive` in both roots (`AppLive`,
`CoreRootLive`); `MemoryMeta` and `WorkspaceMcpOverrides` become
explicit core-graph *inputs* (desktop:
`MemoryMetaLive`/`CrossCuttingLive`; CLI: `MemoryMetaLive` + new
`WorkspaceMcpOverridesDefaultLive`), so
`CoreServicesOptions.memoryMetaService/workspaceMcpOverridesService` are
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 transitional
`CoreTailProjectionLive` (`Layer.effectContext` over `buildCoreTail`,
exposing the six tail tags), and `CoreLive = CoreTailProjectionLive β–Ή
S3`. `TurnRequestBuilderBindingsLive` is `Layer.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. `CoreOptions` moves here (next to
`CoreServicesOptions`) 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; `CoreTags` now includes it),
`CoreRootTags` gains `WorkspaceMcpOverrides`.
- **`di/layers/app.ts` / `desktop.ts`** β€” `CoreLive`;
`CoreOptionsFromDesktopLive` no longer smuggles
`memoryMetaService`/`workspaceMcpOverridesService` through the options.
- Tests (`coreServicesRoot.test.ts`): the PR 3 identity harness is
unchanged and green against `CoreLive`; new **behavioral wiring
assertions** (bindings identity table + `onWorkflowRunStatusChanged` β†’
`emitWorkflowRunActivity`; goal-continuation consumer registered β€” a
second registration is refused; `streamManager` holds the MCP manager;
registration probe installed and answering `false` for an unknown id),
CLI-default inputs test, and the **type-level missing-provider test**
(`// @ts-expect-error` on `makeAppRuntime(CoreLive)`). The 4b relocation
of the tail into S4–S8 + `CoreWiringLive` will 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 were `opts.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).

| Service | Constructor dependencies (tags) | Stage |
|---|---|---|
| HistoryService | SessionLocator | S1 |
| InitStateManager | Config | S1 |
| ProviderService | Config, Options(policy), ProvidersConfigStore,
FileLeaseManager | S1 |
| BackgroundProcessManager | β€” | S1 |
| ExtensionMetadataService | Options(path) | S1 |
| MemoryService | Config, MemoryMeta | S1 (plan said S2a; `MemoryMeta`
is an input, not a stage member) |
| TerminalAttentionStore | Config | S1 |
| IdleDispatcher | β€” | S1 (constructed last today; its consumer
registration stays in the wiring after Task/Workspace) |
| TurnRequestBuilderBindings `{}` | β€” | S1 (`Layer.sync`) |
| SessionUsageService | Config, History, Provider (lazy accessor) | S2a
|
| WorkspaceGoalService | Config, History, ExtensionMetadata, Options,
ProvidersConfigStore | S2a |
| StreamManager | History, **SessionUsage**, Provider (lazy accessor) |
S2b (the plan's known split) |
| AIService | Config, History, InitState, Provider, BackgroundProcess,
SessionUsage, WorkspaceMcpOverrides, Options, **StreamManager**,
Bindings, ProvidersConfigStore, SecretsStore | S3 |
| MemoryConsolidationService | Config, Memory, MemoryMeta, History,
**AI**, Options, SessionUsage | S4 (4b) |
| MCPConfigService | Options, Config, **AI** (workspaceMetadataProvider)
| S4 (4b) |
| MCPServerManager | **MCPConfig**, Config, Options,
WorkspaceMcpOverrides | S5 (4b) |
| WorkspaceService | Config, History, AI, InitState, ExtensionMetadata,
BackgroundProcess, SessionUsage, Options, StreamManager, SecretsStore,
ProvidersConfigStore | deps end at S3 β€” 4b stages it **S6** (after
MCPServerManager) to keep today's construction order; its MCP manager /
consolidation collaborators arrive via setters |
| TaskService | Config, History, AI, **Workspace**, InitState,
SessionUsage, WorkspaceGoal, SecretsStore, TerminalAttention | S7 (4b) |
| WorkspaceTurnManager | Config, History, AI, Workspace, InitState,
**Task**, TerminalAttention, StreamManager | S8 (4b) |

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.mergeAll` siblings build in declaration order in rc.112
(`mergeAllEffect` β†’ `forEach` with `concurrency: n`, verified by a
scratch probe) but nothing here relies on it. A throw inside a
`mergeAll` sibling surfaces as the same synchronous raw throw from
`makeAppRuntime` (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 transitional
`CoreTailProjectionLive` running the unchanged remainder
(`buildCoreTail`). **4b** (next, the only follow-up; PR 5 is not
started) peels S4–S8 into layers, replaces `buildCoreTail` with
`CoreWiringLive`, 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):

| Constructor (layer) | Side effects at construction | On declared args
only? | Order-sensitive? |
|---|---|---|---|
| `HistoryService` (S1) | none | βœ“ | no |
| `InitStateManager` (S1) | `new EventStore(config, "init-status.json",
…)` (own store) | βœ“ | no |
| `ProviderService` (S1) | `emitter.setMaxListeners`;
`providersConfigStore.watchProvidersFile(…)` | βœ“ (store input) | no |
| `BackgroundProcessManager` (S1) | `setMaxListeners` on itself | βœ“ | no
|
| `ExtensionMetadataService` (S1) | none | βœ“ | no β€” the registration
probe (a setter) used to be installed immediately after construction and
now runs as the first tail statement; none of the constructors built in
between (`WorkspaceGoalService`, `StreamManager`, `AIService`) call
`extensionMetadata` |
| `MemoryService` (S1, was after `AIService`) | `super()` only | βœ“ | no
|
| `TerminalAttentionStore` (S1, was before `TaskService`) | none | βœ“ |
no |
| `IdleDispatcher` (S1, was last) | asserts only | βœ“ | no |
| `SessionUsageService` (S2a) | none (provider accessor is lazy) | βœ“ |
no |
| `WorkspaceGoalService` (S2a) | asserts/fields only | βœ“ | no |
| `StreamManager` (S2b) | fields only | βœ“ | no |
| `AIService` (S3) | `providerService.onConfigChanged(…)`,
`streamManager.setEventSink(…)`, `setMaxListeners`, builds
`ProviderModelFactory`/`TurnRequestBuilder` over its args | βœ“ | yes,
satisfied by staging (Provider S1, StreamManager S2b) |
| `MemoryMetaService` (input, `MemoryMetaLive`; CLI roots now
layer-built instead of `opts.memoryMetaService ?? new`) | path join | βœ“
| no |
| `WorkspaceMcpOverridesService` (input; CLI
`WorkspaceMcpOverridesDefaultLive`) | assert | βœ“ | no |

Still constructed by `buildCoreTail` in this PR, positions relative to
each other unchanged (audited now for 4b): `MemoryConsolidationService`
(sidecar path only), `MCPConfigService` (asserts/fields),
`MCPServerManager` (own unref'd `setInterval`), `WorkspaceService`
(`backgroundProcessManager.on` Γ—5, `aiService.on` Γ—6,
`initStateManager.on`, `extensionMetadata.setTombstoneClearedListener`,
module-global `setWorkflowArchiveAdmissionGuard`, starts
`recoverBashMonitorStateAfterRestart()` β€” all declared deps/self; none
of its setter-provided collaborators), `TaskService` (`aiService.on` Γ—3
β€” registered after `WorkspaceService`'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`/`IdleDispatcher`
are built before instead of after `AIService`; 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

- `MemoryMeta` and `WorkspaceMcpOverrides` are 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
the `CoreServicesOptions.memoryMetaService/workspaceMcpOverridesService`
pass-through (the two `coreOptions.*` identity asserts in
`serviceContainer.test.ts` that pinned that pass-through are removed β€”
the tag identity asserts next to them remain).
- `CoreOptions` type moved from `di/layers/core.ts` to `coreServices.ts`
(next to `CoreServicesOptions`) so the seam has a single import
direction.
- `coreServicesRoot.test.ts`: the throwing-body spy targets
`buildCoreTail` (rename of `buildCoreGraph`); everything else in the PR
3 harness is unchanged.
- `CoreTags` includes `TerminalAttentionStoreTag` (not a `CoreServices`
field); `CoreLive`'s output is `Exclude<CoreTags, MemoryMeta>` because
`MemoryMeta` is an input β€” the roots' merged context still carries every
`CoreTags` entry (identity test).

### Pre-review audits (plan Β§3)

1. Interruption posture β€” no new or moved fiber forks; layer bodies are
synchronous constructors (`rg 'fork' src/node/services/di/layers/` β†’
none).
2. Uninterruptible teardown β€” unchanged
(`disposeAppRuntime`/`closeScopeBounded` untouched).
3. No defect escapes β€” a layer body that throws surfaces as the
synchronous throw from `makeAppRuntime` (constructor semantics), inside
the callers' existing startup error paths; pinned for the projection
(`buildCoreTail` spy) and for the desktop root
(`serviceContainer.test.ts`), and probed for a nested `mergeAll` sibling
(scratch).
4. Spy-seam check β€” no constructor signature changed (`rg 'spyOn\(|new
(History|InitState|Provider|…)' src tests`: all direct constructions and
private-method spies compile unchanged); the only test-visible renames
are `buildCoreGraph β†’ buildCoreTail` and the two removed option fields.
5. Sync-start β€” unchanged (no runner changes).
6. I6 β€” table above.
7. Zero-suspension (I3) β€” `MemoryConsolidationService` construction 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 worktree`s under `/tmp`
(`origin/main` = `0b52386f1` vs this branch = `cfedef9bc`, pre-rebase;
the rebase onto `ffa2780f6` touched no shared code) with symlinked
`node_modules`, interleaved runs, shared 96-core Coder host at load β‰ˆ
140–150, CPU PSI `some avg60` β‰ˆ 29–35 %.

**(a) Typecheck** β€” the `make typecheck` command (`concurrently` over
both `tsgo` projects), 6 interleaved pairs:

| | origin/main | branch | Ξ” |
|---|---|---|---|
| wall median | **11.00 s** | **10.99 s** | **βˆ’0.1 %** |
| min / max | 10.45 / 11.74 s | 10.42 / 11.50 s | |

`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):

| project | types | instantiations |
|---|---|---|
| renderer (`tsconfig.json`) | 1 928 628 β†’ 1 929 856 (+0.06 %) | 7 775
777 β†’ 7 779 016 (+0.04 %) |
| main (`tsconfig.main.json`) | 1 047 163 β†’ 1 048 459 (+0.12 %) | 4 250
448 β†’ 4 253 308 (+0.07 %) |

**(b) Startup** β€” `bun src/cli/index.ts server --no-auth` with a fresh
`XUM_ROOT`, `XUM_LOG_LEVEL=debug`, recorded under `script -f`, **10
interleaved pairs**, SIGTERM β‰ˆ 1 s after `initialize completed`:

| metric | origin/main | branch | note |
|---|---|---|---|
| `[startup] AppRuntime built` ms (median, min–max) | 12 (11–31) | 18
(16–19) | **+6 ms cold**: 13 layers + 4 stage compositions built through
the Layer machinery on first use instead of one coarse layer; in-process
warm construction (`new ServiceContainer` 2nd–15th) is 0.9–1.5 β†’ 1.6–2.7
ms median, +β‰ˆ0.3 ms at the minimum |
| `ServiceContainer.initialize completed { totalMs }` (median, min–max)
| 97 (52–109) | 99 (45–182) | within noise (unchanged code) |
| SIGTERM β†’ exit wall (median, min–max), exit code | 161 ms (149–197), 0
Γ—10 | 174 ms (150–204), 0 Γ—10 | `[shutdown] AppFiberScope closed` β†’
explicit steps β†’ `[shutdown] AppRuntime disposed` in every transcript |

Verdict: 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

- The behavioral wiring tests in `coreServicesRoot.test.ts` are now the
oracle for the tail relocation: 4b should move `buildCoreTail` into
S4–S8 layers + `CoreWiringLive` with those tests unchanged, and swap the
`buildCoreTail` spy in the throwing-body test for an equivalent
nested-layer throw (e.g. spying `createAgentPluginsMcpProvider` inside
`MCPConfigLive`).
- `WorkspaceService`'s constructor needs nothing beyond S3; staging it
after `MCPServerManager` (plan's S6) is a construction-order-parity
choice, not a dependency β€” say so in the stage comment.
- Every S1–S3 layer is exported, so integration tests can swap a single
service (`Layer.provideMerge(Layer.succeed(History)(fake))` beneath
`CoreLive`) β€” the plan's "per-service swap" leverage is available from
this PR on.
- `TurnRequestBuilderBindings` must be `Layer.sync`, never
`Layer.succeed` with a literal: the record is mutated by wiring, and a
shared literal would leak across runtimes (tests build many).
- bun 1.2.15 `Illegal instruction` (exit 132) hit `bun test
src/node/services` three times on this host in different files
(BackupRepoCache wedge, `workflows/WorkflowRunner.test.ts`); each file
passes on rerun here and on pristine `main`. Run the suite in chunks
when it happens.

## Validation

- `make static-check` green (typecheck both projects incl. the
`@ts-expect-error` test, lint, fmt, docs).
- `bun test src/node/services`: every file green except the known
environment baselines on this host, identical on pristine `origin/main`
β€” `taskGitPatchEngine` Γ—2, `WorkspaceTurnManager` terminal 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/cli` 185/185. Named
PR 4 gate (`streamManager*`, `aiService`, `workspaceService*`) 659/659.
jest
`tests/ipc/{doubleRegister,savedQueries,windowTitle,acp.disconnectCleanup}`
18/18.
- Dogfooding (headless Coder host, `XUM_LOG_LEVEL=debug`):
- **`xum workflow`** echo run from the branch: `[startup] AppRuntime
built` β†’ `ok from pr4a` β†’ `[shutdown] AppFiberScope closed` β†’
`BackgroundProcessManager.terminateAll()` β†’ `[shutdown] AppRuntime
disposed` β†’ exit 0 (the order `workflow.test.ts` pins).
- **`xum server` graceful quit**: 10/10 branch runs exit 0 within
150–204 ms of SIGTERM with both `[shutdown]` runtime lines in place
(table above).
- **Dev-server sandbox** (`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
replied `pong` (screenshot in the workspace chat shows
`v0.28.3-nightly.148-17-gcfedef9bc`); no `ManagedRuntime
disposed`/defect lines in the log; SIGTERM β†’ exit in 344 ms with
`[shutdown] AppFiberScope closed {ms: 1}` … `[shutdown] AppRuntime
disposed {ms: 3}`.
- Not exercised headless: the Electron `before-quit` path (same
`ServiceContainer.dispose()`; `tests/e2e` in CI) and the oRPC memory
pin/unpin round-trip (covered by `effectBridge.test.ts` + the identity
tests).

## Risks

- **Low–medium.** No service class, constructor, facade or wiring
statement changed; the moved constructors are audited above and
construction-order changes are limited to trivially inert constructors.
The transitional projection keeps the tail exactly as on `main`. A
throwing layer body still surfaces as the same synchronous
constructor-style throw from `new ServiceContainer(stores)` /
`createCoreServices(opts)` (tests).
- Startup: +β‰ˆ6 ms cold construction (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: `$21.59`_

<!-- mux-attribution: model=anthropic:claude-fable-5-1 thinking=xhigh
costs=21.59 -->
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).

![pong
reply](https://github.com/user-attachments/assets/7fae654e-8c34-496c-9d0d-c7ab4a5e83d2)

![memory
pinned](https://github.com/user-attachments/assets/4c33414f-1dc1-4d23-84a8-7e251d1674fe)


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.

![Sandbox smoke on this branch: pong reply in a worktree workspace,
Stats tab
populated](https://github.com/user-attachments/assets/2591febd-0ffc-45e6-b331-8ec7cec3a3da)

---

<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
-->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant