π€ refactor: Effect Phase 11 PR 6 β TestClock sweep, per-step [shutdown] timing, DI contract docs - #4062
Conversation
β¦reamManager debounce interrupt probe on virtual time, one default-runner smoke per worker)
β¦tainer.dispose(), xum server cleanup, CLI cleanup lists
β¦ shutdown order, layer cost); fix lint in idleCompaction smoke
|
@codex review |
|
@codex security review |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
π‘ Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ac4fc78c22
βΉοΈ 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".
β¦tract doc names timeout constants; deterministic default-runner debounce smoke
|
@codex review |
|
@codex security review |
|
Codex Review: Didn't find any major issues. π Reviewed commit: βΉοΈ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with π. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
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
TestClockthrough the workers' injectedEffectRunner(makeTestEffectRunner()), with exactly one default-runner smoke per worker guarding the production (real-clock) path. (2) Shutdown hardening: every step ofServiceContainer.dispose(), thexum serversignal handler and the CLI roots' cleanup lists now writes a[shutdown] <step> {ms}debug line (newshutdownStephelper, 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 outsidedispose()(below). (3) DI contract: thedi/appRuntime.tsmodule comment is now the durable contract β invariants I1βI8, the two-seam asymmetry, the Β§5 shutdown order, the rule for futureAppFiberScopeoccupants, 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-rolledsetTimeout/clearTimeoutspy harness (runNextTimer(),scheduledTimers) is gone; every timing case drives the backoff withclock.adjust(...)on amakeTestEffectRunner()passed as the 4th ctor arg. "Timer pending / no timer pending" assertions becameisRetryPendingplus a negativeadjustfar past any backoff (a still-armed retry would fire). PR 2's separateretryManager.testClock.test.tsis 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 realsetTimeoutregistration once to prove the default runner's sleep lands on Effect's default clock with the backoff delay and firesonRetryβ without a 2 s wall-clock wait.setSystemTimestays: it only pinsDate.now()for thescheduledAtequality 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 withadjust(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 β synchronousstop()); itstestClocksibling's doc comment claimed the real-timer suite covered the default runner, but that suite never calledstart().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> startingbeforerun(), 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, notinstanceof 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. ThePromiseoverload is declared first becausePromise<void>is assignable tovoid;@typescript-eslint/no-misused-promisesguards the other direction.shutdownStep.test.tspins the sync-no-Promise / thenable-awaited / error-propagation contract.serviceContainer.tsβdisposeOnce()wraps each of its 21 explicit steps;closeScopeBounded/disposeAppRuntimekeep 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.closeAllSessionsandserverService.stopServerare timed and a final[shutdown] exiting {totalMs}is the last JS-side line beforeprocess.exit(0).cli/runCleanup.tsβ the loop times each step;cli/workflow.tsβdisposeWorkflowResourcesnow builds the same kind of step list and runs it throughrunBestEffortCleanup(same containment as its former eighttry/catchblocks; warn wording is nowxum 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 %)
streamManager.test.tsretryManager.test.ts(+ deletedretryManager.testClock.test.ts)TestClock) instead of two, and one file instead of twoidleCompactionService.test.tsheartbeatService.test.tsGrep in the converted files (acceptance):
streamManager.test.tsno longer waitssetTimeout(β¦, throttleMs + β¦);retryManager.test.tshas nospyOn(globalThis, "setTimeout")outside the single smoke and norunNextTimer.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:waitForConditionpolls and 20 ms settles on Promise chains (tick()βresyncFromConfigβqueue) plus one 300 ms mock dispatch delay whose assertion isDate.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.start(), so there was no default-runner smoke to keep β added one.setSystemTimecalls pinDate.now()forscheduledAt; the actual timer surrogate was the globalsetTimeoutspy harness, which is what the TestClock replaces.setSystemTimestays (no test needs the two clocks aligned, soscheduledAtstays onDate.now()).lastPartialWriteTimeinside the throttle window, but they callattachWorkflowRunToToolCall/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 onwaitForStartupAutoRetryRerunWindow(plainsetTimeoutinsideagentSession.ts) and Promise settlement, not onRetryManager's clock β not convertible through a stream-manager double; skipped.forkIn(resourceScope)branch replaced by a plainrunFork, both the original 720 ms version and the TestClock version still pass β the stream-end path also callsinterruptPartialWriteFiberdirectly, 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, tempXUM_ROOT,XUM_LOG_LEVEL=debug, underscript -q -e -f), 5 runs each:Shutting down server...β[shutdown] exiting)initialize completed(β 8.4 s after spawn)dispose61β64 ms)initialize completeddispose76β82 ms)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 afterprocess.exit(0). Nothing insidedispose()(every step 0β16 ms;AppRuntime disposed6β16 ms), nothing inserverService.stopServer()(1 ms β PR 2's guess was wrong; PR 5's strace was right).Cause:
workerPool.tscreates the tokenizerWorkerat import time (β 1.9 s after spawn inxum server); the worker evaluatesai-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 V8TerminateExecution, 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.exitat t; unref'd, no other work):process.exitati.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 onmain(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 firstrun()(an idlexum 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 closedissource-map-support(registered bycli/server.ts) mapping Effect-internal frames the first time the log helper captures a stack inside a fiber β verified standalone: first in-fibernew Error().stack47.8 ms with source maps vs 0.3 ms after / 0.3 ms without; not teardown work. (b) In the CLI roots' listsappFiberScope.close/appRuntime.disposeare timed byrunBestEffortCleanupand log their ownβ¦ closed/β¦ disposedline (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.tstimeout/never-rejects cases, the PR 5 order test).Pre-review audits (plan Β§3 preamble)
shutdownStepcreates none. The TestClock suites fork the same effects through aTestClock-bound runner.boundedTeardownuntouched. IndisposeOnce()synchronous steps are timed without a Promise (no new suspension point); async steps get one.finallymicrotask after an await that already existed. Order asserted unchanged.shutdownSteprethrows after logging (containment unchanged:disposeOncepropagates as before,runBestEffortCleanupcontains as before);log.debugcannot throw (safePipeLogcatches). Both bounded teardowns still never reject.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) andshutdownStepcalls 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.isRetryPendingtrue andpartialWriteFiberdefined synchronously after the scheduling call, before anyadjust) and directly bydi/effectRunner.test.ts.memoryConsolidationServicenot in the diff).Phase 11 completion state
EffectRunner/AppFiberScopeβMemoryMetaβ 8 cross-cutting β 19 core layers in 8 stages +CoreWiringLiveβ 6 desktop group layers +DesktopWiringLive), built once per process by oneManagedRuntime(AppLivefor desktop/xum server/ACP/tests-ipc;CoreRootLiveforxum run/xum workflow); the oRPCeffect/context; the two runtime seams (EffectRunnerin the three clock-driven workers andStreamManager/RetryManager;AppFiberScopewith 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.ServiceContainer.initialize()(six awaitedinitialize()s + threestart()s β a futureruntime.runPromise(startupEffect)with per-stepEffect.timeout);streamBridge.tsstreams on the global runtime (needs a runner/context parameter onsubscriptionIterable, which would also letstreamBridge.test.ts's 11 ticker waits move to a TestClock); the hand-ordereddispose()/shutdown()steps (layer finalizers would require proving reverse-construction order compatible β I5);AgentStatusService's ref'dsetInterval; OAuth device-flow polling.AppFiberScopeoccupant (next phase): the streamManager engine core β fork the per-stream engine fiber intoAppFiberScopesodispose()step 2 interrupts and awaits in-flight streams whilehistoryService/sessionUsageare 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-checkgreen (typecheck both projects, prettier, eslint, docs).retryManager15/15,streamManager123/123,idleCompactionService20/20 +testClock2/2,heartbeatService74/74 +testClock2/2,serviceContainer+runCleanup+workflow+server+cli56/56,di/*.bun test src13 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 tests677 pass / 75 fail / 49 skip (122 suites, 1298 s) β the same environment baseline as PR 5 (669/77/49): every failing suite is provider-backed (403 Forbiddenfrom the AI bridge / no xAI key:tests/ipc/streaming/*,providers/*,workspace/fork|init,acp.integration,run/smoke,nameGenerationΓ2,runtime/*), one of the foursrc/**/__tests__bun:test files jest picks up, or the knownterminal.test.ts(1) /sendModeDropdown.test.ts(1) rows; CITest / Integration(real keys) is green on this head. CI round 1:Test / Integrationfailed only ontests/ipc/providers/anthropicCacheStrategy.test.ts("Expected cache creation but got 0 tokens" β a live-provider cache-token assertion unrelated to this diff).xum serverSIGTERM matrices above (exit 0 Γ10, every[shutdown]line present in every transcript);xum workflowecho run from the branch βAppRuntime builtβok from pr6β[shutdown] backgroundProcessManager.beginShutdownβAppFiberScope closedβsession.disposeβ β¦ βterminateAllβAppRuntime disposed(the orderworkflow.test.tspins), 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 (noDISPLAY; covered bytests/e2ein CI and the shareddispose()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 workflowcleanup list going through the same best-effort runner asxum 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.π Implementation Plan
Effect migration β Wave 3 / Phase 11: ManagedRuntime + Layer dependency injection
0. Summary
Replace the two hand-written composition roots (
createCoreServices+ theServiceContainerconstructor) with an EffectLayergraph built once per process by aManagedRuntime("AppRuntime"), while keeping every service class, constructor signature, Promise facade, private method, and test seam compatible. The runtime becomes (a) the owner of the app-lifetimeScope, (b) the provider of"effect/context"for oRPC Effect-native handlers, and (c) the source of two runtime seams: anEffectRunner(context-bound, unsupervised runner that lets clock-driven workers run on aTestClock) and anAppFiberScope(a runtime-owned, supervised scope whose close is awaited bydispose()β the slot the streamManager engine core will occupy later).Six stacked, independently mergeable PRs. Product PRs keep existing tests unchanged; only the final test-modernization PR edits tests. Net product LoC β +420 (per-PR estimates below). Service classes are not rewritten β Layers are thin adapters around existing constructors; cycle-breaking setter wiring moves into explicit "wiring layers" that replay today's order.
Unlocks (not done here): streamManager ENGINE CORE conversion,
TestClockfor timing suites, app-lifetime scopes.1. Verified current state (evidence)
src/node/services/coreServices.ts:103-389(createCoreServices: 25 constructions, 12turnRequestBuilderBindingswrites, ~14 setters) andsrc/node/services/serviceContainer.ts:161-575(45 more constructions;aiService.on(...)/workspaceService.on(...)analytics wiring at 474-574; global registrationssetGlobalCoderService/setSshPromptServiceat 469-471).new ServiceContainer(stores)is called byheadlessEnvironment.ts:111,tests/ipc/setup.ts,src/cli/server.ts:132,src/node/acp/serverConnection.ts:155,src/desktop/main.ts:653;src/cli/run.ts:661andsrc/cli/workflow.ts:376callcreateCoreServicesdirectly. β two graph roots (App vs Core), five process entry points, all constructing synchronously.ServiceContainer.initialize()(577-642) awaits sixinitialize()s (no try/catch; failure propagates tomain.ts:1255-1265"Startup Failed" dialog + quit;server.ts/ACP log and exit), then syncstart()s idleCompaction/heartbeat/agentStatus, then two fire-and-forget sweeps. All constructors are synchronous; two have side effects on declared constructor dependencies only (AIServiceβstreamManager.setEventSink,WorkspaceServiceβbackgroundProcessManager.on/aiService.on).dispose()(746-779) is explicit and hand-ordered (backgroundProcessManager.beginShutdown()MUST be first β it is a latch protecting persisted monitor records; bridges stop before sessions close;terminateAlllate;timelineService.flush()last).shutdown()(718-732) is a second sequence fired concurrently by a secondbefore-quitlistener (main.ts:1321).main.ts:1296-1304racesdispose()against 5 s thenapp.quit();cli/server.ts:227-268has a 5 sprocess.exit(1)force timer;tests/ipccleanup callsdispose()thenshutdown();headlessEnvironment.disposenever callsservices.dispose().effect. OnlyContext.Servicetag:MemoryMeta(src/node/orpc/effectContext.ts:21).handlerGen(@orpc/experimental-effect) runsEffect.runPromiseExitper request andEffect.providesopts.context["effect/context"].streamBridge.tsruns streams on the global runtime. Scope-owning workers:heartbeatService.ts:134-243,idleCompactionService.ts:86-122(Scope.makeUnsafe+Effect.runSync(Scope.close(..)), valid only because their fibers suspend solely on the clock),oauthFlowManager.ts:164,streamManager.ts:4767/4054(alreadyEffect.runFork(Scope.close(..))β the async-close precedent).memoryConsolidationService.ts:667-703, 837-860: check-and-reserve funnels with zero suspensions beforeinFlight.set/harvestInFlight.set.node_modules/effect/dist).Context.Service<Self, Shape>()("id")(moduleContext, notServiceMap);Layer.{succeed,sync,effect,effectContext,effectDiscard,provide,provideMerge,mergeAll,build,buildWithScope}(noLayer.scoped;Layer.effectstripsScopefrom R);ManagedRuntime.make(layer)β{ runSync, runSyncExit, runFork, runPromise, runPromiseExit, contextEffect, cachedContext, scope, dispose(), disposeEffect };Effect.{runSyncWith,runForkWith,runPromiseWith,runPromiseExitWith}(context);Effect.context<R>();Effect.serviceOption;Scope.{fork,forkUnsafe,close,provide};TestClockfromeffect/testing(layer, adjust, setTime, withLive);Clock.Clockis aContext.Reference(defaulted;TestClock.layer()overrides it).ManagedRuntime.js):makecreatesscope = Scope.makeUnsafe("parallel")andlayerScope = Scope.forkUnsafe(scope, "sequential"); the firstrunXforks a build fiber overLayer.buildWithMemoMapβ a fully synchronous layer graph builds synchronously, soruntime.runSync(Effect.context())succeeds and setscachedContext; afterwards everyrunXisEffect.runβ¦With(cachedContext)(no extra async boundary). Fibers started throughruntime.runXare registered inscope(onFiberStart: Fiber.runIn(scope)).dispose()=Scope.close(scope)(interrupt registered fibers in parallel β layer finalizers sequentially in reverse), after which anyruntime.runXdies with"ManagedRuntime disposed".Layer.mergeAll(A, B)is not a dependency resolver: B's requirements are not satisfied by A's outputs; requirements bubble up. Dependencies are satisfied only viaLayer.provide/provideMergechains. Siblings inmergeAllmay build concurrently.Config.saveConfig,WorkspaceService.retireKernelWorkflowRunReferences/startStartupRecovery/createSession/updateAgentStatus,MCPServerManager.startServers,AgentPluginInstallService.reconcileJournals, β¦); module-level export spies (agentStatusService.generateWorkspaceStatus,sshConnectionPool.verifyHostKeyAgainstPolicyEffect, β¦); direct construction in tests (Config44 files,HistoryService22,MemoryMetaService11,WorkspaceService7,IdleDispatcher6,StreamManager4,ServiceContainer3); partial-mock casts (InitStateManager193,AIService158,TaskService149,ORPCContext62).effectBridge.test.ts:24-30builds a partialORPCContextviabuildOrpcEffectContext+as unknown as ORPCContext.heartbeatService.test.ts6 real sleeps,idleCompactionService.test.ts2,retryManager.test.ts3setSystemTime,streamManager.test.ts7 (partial-write debounce),streamBridge.test.ts11 (heartbeat ticker), OAuth device-flow suites 14 (non-goal).2. Target architecture
2.1 Building blocks (all under
src/node/services/di/; the only directory allowed to importLayer/Context/ManagedRuntime/TestClock)tags.tsContext.Servicetag per service class provided by the graph. Type-only imports of service classes β no runtime import cycles. Ids"xum/<Name>". Naming: class name minus trailingService(MemoryMeta,Workspace,History); classes without that suffix or colliding with an exported name get aTagsuffix (ConfigTag,StreamManagerTag,IdleDispatcherTag). Exports the unionsCoreTagsandAppTags.effectRunner.tsinterface EffectRunner { runSync<A,E>(e: Effect<A,E,never>): A; runSyncExit; runFork; runPromise; runPromiseExit }β a context-bound, unsupervised runner whose methods accept only effects with no service requirements (R = never; defaulted references likeClockdo not appear inR). That makes "not a service locator" type-enforced: a fiber that needs services must take them as explicit constructor dependencies and, if it must be awaited on shutdown, fork intoAppFiberScope.defaultEffectRunner= the globalEffect.runX(today's exact behavior).effectRunnerFromContext(ctx)=Effect.runβ¦With(ctx).EffectRunnerTag+EffectRunnerLive = Layer.effect(EffectRunnerTag, Effect.map(Effect.context<never>(), effectRunnerFromContext)), placed at the base of the graph so the captured context contains only refs (Clock, laterLogger/Random) plus stores. Fibers forked through it are owned by the worker's ownScope(explicitstart/stop), not by the ManagedRuntime;runtime.dispose()does not interrupt them. Services import only this file fromdi/.appFiberScope.tsAppFiberScopeTag: Scope.Closeable.AppFiberScopeLive = Layer.effect(AppFiberScopeTag, Effect.gen(function*(){ const parent = yield* Effect.scope; return yield* Scope.fork(parent, "parallel"); }))β a child of the runtime's layer scope. Fibers forked into it viaEffect.forkIn(_, appFiberScope)are interrupted and awaited when the scope closes. This is the supervised seam for I/O-suspended fibers (engine core, later).ServiceContainer.dispose()closes it explicitly and early (Β§5) so interrupted fibers can still use their dependencies during finalization;runtime.dispose()later re-closes it idempotently as a backstop. No production occupant in Phase 11; the seam exists with tests.appRuntime.tsmakeAppRuntime(layer):ManagedRuntime.make(layer)+ eager synchronous build (runtime.runSync(Effect.context<R>());assert(runtime.cachedContext !== undefined)); a layer body that suspends is a programming error and throws here β exactly where a throwing constructor throws today, so every entry point's existing catch/dialog/log path is preserved.disposeAppRuntime(runtime, timeoutMs)andcloseScopeBounded(scope, timeoutMs)share one shape:Effect.uninterruptibleteardown shell aroundEffect.interruptible(target.pipe(Effect.timeout(timeoutMs)))wheretargetisruntime.disposeEffectresp.Scope.close(scope, Exit.void)(never a non-cancellable JS Promise wrapper);Effect.catchTag("TimeoutError", β¦)+Effect.catchDefectβlog.warn; run viaEffect.runPromise; never rejects; idempotent (Scope.closeis idempotent;disposeEffectis guarded by a latch). Verify the exact rcEffect.timeouterror type at implementation time (rc.112: fails withCause.TimeoutError,_tag: "TimeoutError"). Module doc comment = the DI contract (Β§2.3, Β§5).layers/stores.tsStoresLive(stores: ConfigStores)=Layer.mergeAllofLayer.succeedforConfigTag,SessionLocatorTag,ProvidersConfigStoreTag,SecretsStoreTag,FileLeaseManagerTag(true siblings β no inter-dependencies).StoresFromCoreOptionsLivereproduces theopts.x ?? new X(config.rootDir)defaults ofcoreServices.ts:106-112for the CLI root.layers/core.tsCoreOptionsTag(today'sCoreServicesOptionsminus stores β carries the optional cross-cutting services exactly as today). PR 3:CoreProjectionLive = Layer.effectContext(...)wrapping the existingcreateCoreServicesbody and returning aContext<CoreTags>(coarse projection, zero behavior change). PR 4: peel into per-serviceLayer.effect(Tag, Effect.gen(...))layers composed in explicit dependency stages (Layer.provideMergebetween stages;Layer.mergeAllonly for true siblings within a stage β every sibling claim below was checked against the constructor argument lists incoreServices.tsand must be re-checked in the PR): S1 History Β· InitState Β· Provider Β· BackgroundProcess Β· ExtensionMetadata Β· MemoryMeta Β· TerminalAttention Β· IdleDispatcher Β· WorkspaceMcpOverrides(default) Β·TurnRequestBuilderBindingsTag(Layer.succeed(_, {})) β S2a SessionUsage Β· Goal Β· Memory β S2b StreamManager (needs SessionUsage) β S3 AIService β S4 Consolidation Β· MCPConfig β S5 MCPServerManager β S6 Workspace β S7 Task β S8 TurnManager βCoreWiringLive(Layer.effectDiscard,Effect.synconly β noacquireRelease, replayscoreServices.ts:137-166, 209-210, 258-270, 288-325, 349-352, 360-367in order).layers/desktop.tsCrossCuttingLive(policy, telemetry, experiments, backup, sessionTiming, analytics, devTools, workspaceMcpOverrides, browserBridgeTokenManager),CoreOptionsFromDesktopLive(derivesCoreOptionsTagfrom those tags +extensionMetadataPath), then group layers (Layer.effectContextreturning aContextof several tags, constructed in today's order):BrowserLive,DesktopBridgeLive,OauthLive,WorkersLive(idleCompaction, heartbeat, agentStatus, timeline, refine),TerminalEditorLive,MiscDesktopLive; staged withprovideMergewhere one group needs another.DesktopWiringLive(Effect.synconly) = setters +aiService.on/workspaceService.on/memoryConsolidationService.onwiring + global registrations.layers/app.tsAppLive(stores) = DesktopLive βΉ CoreLive βΉ CoreOptionsFromDesktopLive βΉ CrossCuttingLive βΉ AppFiberScopeLive βΉ EffectRunnerLive βΉ StoresLive(stores)β readX βΉ Yas "X is provided with Y, and both stay exposed", i.e.X.pipe(Layer.provideMerge(Y))(rc.112 signature:provideMerge(that: provider)(self: consumer); the right-hand operand is the dependency). EveryβΉkeeps all tags visible in the finalContext<AppTags>.testEffectRunner.ts(test helper, sibling oftestHistoryService.ts)makeTestEffectRunner()β{ runner, adjust(duration), setTime(ms), dispose }over one memoisedManagedRuntime.make(EffectRunnerLive.pipe(Layer.provideMerge(TestClock.layer())))(the TestClock is the provider; the runner captures it), so the worker under test andTestClock.adjustshare oneTestClock.2.2 Composition roots after Phase 11
ServiceContainerkeeps its public fields and the synchronousnew ServiceContainer(stores): the constructor callsmakeAppRuntime(AppLive(stores)), storesthis.serviceContext = runtime.runSync(Effect.context<AppTags>()), and assigns fields viaContext.get(this.serviceContext, Tag).toORPCContext()returns the same plain fields plus"effect/context": this.serviceContext.initialize()is untouched.dispose()follows Β§5.createCoreServices(opts)keeps its signature and return shape plusruntimeandappFiberScopefields;cli/run.ts:1574-1580andcli/workflow.ts:275-320cleanup lists gaincloseScopeBounded(appFiberScope)beforesession.dispose()anddisposeAppRuntime(runtime)as the final step (PR 3).Staged composition skeleton (PR 4 shape; direction matters):
oRPC typing.
OrpcEffectServices(ineffectContext.ts) becomesAppTags, soORPCContext["effect/context"]: Context<AppTags>is satisfied by the runtime context in production.buildOrpcEffectContextstays as the narrow test helper it already is (its only caller,effectBridge.test.ts:24-30, deliberately builds a partial context and casts it viaunknown); no production caller remains after PR 1.2.3 Invariants (the "DI contract"; enforced by tests and the
appRuntime.tsdoc comment)Layer.succeed/Layer.sync/Layer.effectover sync effects;acquireReleasewith a sync acquire is fine).makeAppRuntimeasserts the eager build completed. Future async resource acquisition belongs ininitialize()/startup effects or an explicit async factory root (ServiceContainer.create()), never silently inside a layer.ManagedRuntime. Workers hold anEffectRunner(defaultdefaultEffectRunner);EffectRunner.runXβ‘Effect.runβ¦With(ctx)β same sync-start semantics asEffect.runX, and still valid afterruntime.dispose(), so late callbacks cannot hit "ManagedRuntime disposed". Supervision, when needed, is explicit viaAppFiberScope.Effect.runPromise(this.effectsβ¦)facades) and thememoryConsolidationServicefunnels are untouched. Audit item: no DI lookup, runner call, orawaitmay be inserted beforeinFlight.set/harvestInFlight.set. Only lifecycle forks in workers move tothis.runner.runX.defaultEffectRunner.dispose()/shutdown(). Layer bodies and wiring layers register no finalizers in Phase 11 (Effect.synconly), soruntime.dispose()reorders nothing. The one supervised resource (AppFiberScope) is closed explicitly at a fixed position indispose()(Β§5).provide/provideMergestages; never rely onmergeAllsibling order.CoreLiveshared by App and CLI). Unit harnesses (createTestHistoryService,createTestToolConfig,createAgentSessionHarness, β¦) intentionally bypass Layers.2.4 Decisions and alternatives (product-LoC deltas)
D1 β Granularity: coarse core first (PR 3), per-service core stages behind a decision gate (PR 4), group layers for the desktop tail (PR 5)
Honest framing: the three unlocks (engine-core async scope, TestClock, app-lifetime scope) are delivered by
AppRuntime+EffectRunner+AppFiberScopeand do not require per-service layers. Per-service core layers are migration leverage: typed requirement sets for the engine-core work, per-service swap in integration tests, explicit dependency stages instead of implicit ordering.900/β700. Desktop tail has hand-tuned teardown that must not become finalizers, so per-service there buys uniformity only. Rejected.CoreProjectionLive(+120/β10) delivers the shared root and runtime ownership; PR 4 peels the core into staged per-service layers (+330/β290) only if PR 3's typecheck/startup budgets hold (gate in Β§3); desktop tail as ~6 group layers (+170/β150). Tags for all services either way (~3 LoC each).D2 β Async init stays an explicit `initialize()`; Layers construct only
Folding
initialize()into layer construction would make the build asynchronous (breaks I1), change failure semantics (today: fail-fast β dialog/log), and move the six-step order into memoised builds. Deferred; a later phase can turninitialize()intoruntime.runPromise(startupEffect)with per-stepEffect.timeout.D3 β Optional cross-cutting services stay optional via `CoreOptionsTag`, not `Effect.serviceOption`
Core layer bodies read
opts.policyServiceetc. exactly as today, so CLI (absent) vs desktop (present) behavior is unchanged and no service gains a newundefinedbranch.D4 β Two seams instead of one: `EffectRunner` (unsupervised, clock-bound) + `AppFiberScope` (supervised)
A single "runtime handle" conflates two needs. Workers need which clock (TestClock) and must keep sync
stop(); the engine core needs who awaits me on shutdown. ExplicitClockinjection per worker was rejected (aprovideService(Clock.Clock, β¦)at every fork site, and it does not extend to other refs).D5 β oRPC: `effect/context` = the runtime's `Context`; `handlerGen` unchanged
handlerGenalreadyEffect.provides the context per request; providing ~70 entries instead of one is one Map merge per request. The existingechoAsync/echoEffectprobes record the delta as a diagnostic in the PR body (no stable benchmark harness exists to make it a hard gate).effect/wrapnot needed.3. Phasing β six stacked PRs
Every PR:
make static-check; gate suites below; existing tests unchanged (PR 6 is the only PR that edits tests, and only to replace real-timer probes). Before@codex review, run the house pre-review audits:EffectRunner+ worker scope, or supervised viaAppFiberScope).Effect.uninterruptibleend-to-end; bounded waits inside useEffect.interruptible(Effect.timeout(...))(house shape from π€ refactor: convert memoryConsolidationService and workspaceStatusGenerator internals to Effect; make in-flight run-lock reservation deterministicΒ #4038).disposeAppRuntime/closeScopeBoundedand every Promise facade fold defects;makeAppRuntimeis the one place allowed to throw (constructor semantics).rg 'spyOn\(' src/node/services/<touched>.test.ts tests/per touched class; constructor arity and private-method Promise signatures unchanged (typecheck of tests proves it).EffectRunnerruns to its firstsleepbeforerunForkreturns (mirrorsheartbeatService.ts:199-202).memoryConsolidationServiceis in the diff.PR 1 β Skeleton: AppRuntime + Stores/MemoryMeta layers + runtime-backed
effect/context+ dispose hook (+~150 LoC)Scope
di/tags.ts(ConfigTag,SessionLocatorTag,ProvidersConfigStoreTag,SecretsStoreTag,FileLeaseManagerTag,MemoryMetamoved fromorpc/effectContext.ts, which re-exports it;AppTagsunion).di/layers/stores.ts(StoresLive),di/layers/core.tswithMemoryMetaLive = Layer.effect(MemoryMeta, Effect.map(ConfigTag, c => new MemoryMetaService(c.rootDir))),di/layers/app.ts(AppLive(stores) = MemoryMetaLive βΉ StoresLive).di/appRuntime.ts(makeAppRuntime,disposeAppRuntime);APP_RUNTIME_DISPOSE_TIMEOUT_MSinsrc/constants/.coreServices.ts:CoreServicesOptions.memoryMetaService?(precedent:workspaceMcpOverridesService?).serviceContainer.ts: build runtime first, passContext.get(ctx, MemoryMeta)tocreateCoreServices,public readonly runtime,toORPCContext()["effect/context"] = this.serviceContext,dispose()appendsdisposeAppRuntimebehind adisposedlatch; newlog.debug("[startup] AppRuntime built", { ms }).orpc/effectContext.ts:OrpcEffectServices = AppTags;buildOrpcEffectContextretyped/test-helper doc.headlessEnvironment.disposecallsawait services.dispose()before removing the temp dir (the bench harness currently leaks the container; runtime ownership starts here).Acceptance
di/appRuntime.test.ts: (a) sync build setscachedContext; (b) a layer with an async body makesmakeAppRuntimethrow synchronously (I1 enforced); (c) probe layers' finalizers run in reverse order on dispose; (d) dispose is idempotent and bounded (hung finalizer βwarn, resolves at the timeout); (e)runtime.runForkafter the eager build starts synchronously.serviceContainer.test.ts:Context.get(toORPCContext()["effect/context"], MemoryMeta) === services.memoryMetaService;dispose()closes the runtime;dispose(); shutdown()(tests/ipc order) is clean; a throwing layer surfaces as a synchronous throw fromnew ServiceContainer(stores)(same shape as today's constructor throw β existing entry-point catch paths).effectBridge.test.ts,memoryMeta*.test.tsunchanged and green; echo-probe overhead recorded in the PR body.bun test src/node/services/di src/node/services/serviceContainer.test.ts src/node/orpc src/node/services/memoryMeta*Β·make test-integrationΒ·make static-check.Rollback:
git revert; classes untouched.PR 2 β Runtime seams:
EffectRunner+AppFiberScope; TestClock on idleCompaction/heartbeat/retryManager (+~140 LoC)Scope
di/effectRunner.ts,di/appFiberScope.ts;AppLivegainsAppFiberScopeLive βΉ EffectRunnerLiveat the base;ServiceContainerexposesappFiberScope(used only bydispose()in Phase 11) and closes it per Β§5.IdleCompactionService,HeartbeatService,RetryManager: trailing optionalrunner: EffectRunner = defaultEffectRunner; every lifecycleEffect.runSync/runForkinstart/stop/schedule/cancelbecomesthis.runner.runX. Deadline math (Date.now()/injectednow) unchanged.ServiceContainerpassesContext.get(ctx, EffectRunnerTag)to the two workers;RetryManagerkeeps the default until PR 5 (sostreamManager.tsis untouched here).di/testEffectRunner.tshelper.Acceptance
defaultEffectRunnerpath, which is production behavior wherever no runner is injected): heartbeatSTARTUP_DELAY_MSβ first tick afteradjust, one tick perCHECK_INTERVAL_MS, no ticks afterstop(); idleCompaction initial delay + cadence; retryManager fires exactly atdelayMs,cancel()beforeadjustnever fires.runner.runSync(Scope.close(scope, Exit.void))completes synchronously for a fiber suspended on a TestClock sleep;runForkthrough the runner reaches its first sleep synchronously;Effect.context<never>()insideEffectRunnerLivesees the upstreamTestClock(else the helper providesClock.Clockexplicitly β same seam, one line).AppFiberScopecontract tests: (i) an I/O-suspended fiber (interruptibleEffect.asyncthat never resolves, with a cancel path) forked withEffect.forkIn(_, appFiberScope)is interrupted and awaited bycloseScopeBounded(appFiberScope)β and this happens before the explicit teardown steps indispose()(assert ordering against a spy ondesktopBridgeServer.stop); (ii) a fiber forked viaEffectRunneris not interrupted by either close (documents the asymmetry); (iii)disposeAppRuntimeafterwards idempotently re-closes the already-closed child scope (no error, no second finalizer run).TestClock.adjustleaves continuations pending, the helper addsEffect.yieldNow/Fiber.awaitβ decided by tests.heartbeatService.test.ts,idleCompactionService.test.ts,retryManager.test.ts,serviceContainer.test.ts,di/*, tests/ipc.Rollback: revert restores defaults; no call site depends on the new params.
PR 3 β Shared core root: coarse
CoreProjectionLive+createCoreServicesfacade + CLI runtime disposal (+120 / β10)Scope
CoreOptionsTag;StoresFromCoreOptionsLive.CoreProjectionLive = Layer.effectContext(Effect.gen(function*(){ const opts = yield* CoreOptionsTag; const stores = yield* β¦; const core = buildCoreGraph({ ...opts, ...stores }); return Context.make(History, core.historyService).pipe(Context.add(...)) }))wherebuildCoreGraphis today'screateCoreServicesbody, unchanged, renamed.createCoreServices(opts)=makeAppRuntime(CoreProjectionLive βΉ StoresFromCoreOptionsLive βΉ AppFiberScopeLive βΉ EffectRunnerLive βΉ Layer.succeed(CoreOptionsTag, opts)), returns today'sCoreServicesobject read from the context plusruntimeandappFiberScope.cli/run.tsandcli/workflow.tscleanup lists appendcloseScopeBounded(appFiberScope)beforesession.dispose()anddisposeAppRuntime(runtime)afterbackgroundProcessManager.terminateAll().ServiceContainerstops callingcreateCoreServices;AppLive = CoreProjectionLive βΉ CoreOptionsFromDesktopLive βΉ CrossCuttingLive βΉ β¦(cross-cutting services move intoCrossCuttingLivenow because core options derive from them). Desktop constructions otherwise stay in the constructor.Acceptance
CoreServicesfield===Context.get(ctx, Tag);serviceContainer.test.tsunchanged and green.make typecheckwall time,[startup] AppRuntime builtms andinitializetotals vsorigin/mainbaseline from the sandbox (Β§7). Proceed to PR 4 only if typecheck regresses < 10 % and startup within noise; otherwise stop at (C).bun test src/node/services,src/cli/*.test.ts(run/workflow/server/cli), tests/ipc,make static-check.Rollback: revert restores the imperative call; PR 1/2 unaffected.
PR 4 β Peel the core into staged per-service Layers +
CoreWiringLive(+330 / β290 β net β +40; split 4a/4b if > ~600 diff lines)Scope
Layer.effectadapters with today's argument lists;CoreWiringLive(Effect.synconly) replays the wiring lines in order;CoreLive = CoreWiringLive.pipe(Layer.provideMerge(S8))replacesCoreProjectionLive;buildCoreGraphdeleted.StreamManager β SessionUsageis the kind of edge that turns "siblings" into a stage split) and record it in the PR body.AIService) / 4b (S4βS8 + wiring) if needed β 4a alone is mergeable because the remaining services are built by a shrunken projection layer that reads S1βS3 from the context.Acceptance
turnRequestBuilderBindingsfully populated; goal continuation consumer registered onidleDispatcher;streamManagerMCP manager set; registration probe installed onextensionMetadata.neveratmakeAppRuntime) demonstrated by a type-level test (// @ts-expect-error).streamManager*.test.ts,aiService.test.ts,workspaceService*.test.ts.Rollback: revert to PR 3's projection.
PR 5 β
DesktopLivegroup layers +DesktopWiringLive; thinServiceContainer;StreamManagerrunner param (+170 / β150 β net β +20)Scope
Layer.effectContext, today's construction order inside each;provideMergebetween groups that depend on each other);DesktopWiringLive(Effect.synconly) =serviceContainer.ts:209, 263-265, 271, 288-290, 334-340, 348, 365, 375, 381-382, 434, 438-471, 474-574in order.ServiceContainerconstructor =makeAppRuntime(AppLive(stores))+ field assignment from the context.toORPCContext()unchanged in shape.StreamManager: optional trailingrunner: EffectRunner;schedulePartialWritefork (streamManager.ts:1141) andRetryManagerconstruction use it;Scope.closestaysEffect.runFork(existing async-close precedent).WorkersLivereceivesEffectRunnerTag.Acceptance
serviceContainer.test.tsassertions unchanged; new identity test overtoORPCContext()fields vs tags;dispose()/shutdown()call order asserted via spies on the public methods already spied today.make test-integration),src/cli/server.test.ts,src/cli/cli.test.ts,streamManager*.test.ts,aiService.test.ts.PR 6 β TestClock adoption sweep + shutdown hardening + contract docs (+~20 LoC product; tests edited)
Scope
makeTestEffectRunner()inheartbeatService.test.ts,idleCompactionService.test.ts,retryManager.test.ts, and the partial-write debounce cases ofstreamManager.test.ts; keep one real-timer smoke test per worker (guards thedefaultEffectRunnerpath).cli/server.ts:[shutdown]log lines per step incl.AppRuntime disposed {ms}; confirm the wholedispose()fits the existing 5 s force-exit budget.di/appRuntime.ts(I1βI8, Β§5).Acceptance: converted suites have zero
setTimeout-based cadence waits (grep in PR body), same assertions;make test-integrationgreen; sandbox startup/shutdown evidence (Β§7).4. TestClock story
Effect.sleep,Schedule.fixed,Effect.timeout,Clock.currentTimeMillisread theClockreference from the running fiber's context. Workers that fork through anEffectRunnerbuilt underTestClock.layer()run on the test clock;await testRunner.adjust("2 minutes")advances it.Date.now(),setTimeout,setIntervalare unaffected β heartbeat deadline math via injectednow,AgentStatusService's ref'dsetInterval, andbackgroundProcessManagerstay on real timers/injected timestamps.heartbeatService.test.ts(6),idleCompactionService.test.ts(2),retryManager.test.ts(3setSystemTimeβadjust;Date.now-basedretryAtmay move toClock.currentTimeMillisonly if a test needs both clocks aligned),streamManager.test.tsdebounce cases (7).streamBridge.test.tsticker (11) β needs a context/runner parameter onsubscriptionIterable; OAuth device-flow polling andoauthFlowManager.test.ts(25) β non-goal.backgroundProcessManager72,quickjsRuntime26, lock sleeps inworkspaceService/taskService), end-to-end suites (tests/ipc, e2e).adjustruns due sleeps and their synchronous continuations before resolving (or the helper yields until they do);Schedule.fixedanchoring underTestClockmatches the wall-clock expectations inheartbeatService.ts:149-155; syncScope.closeof a TestClock-suspended fiber completes synchronously.5. Shutdown protocol
main.tsbefore-quit(preventDefault βdispose()raced with 5 s βapp.quit(); update-install path fire-and-forget), the secondbefore-quitlistener'sshutdown()(unchanged, concurrent),cli/server.tsSIGINT/SIGTERM (5 s force exit), ACPclose(), tests/ipc (dispose()thenshutdown()), headless bench (dispose()from PR 1).ServiceContainer.dispose()order:backgroundProcessManager.beginShutdown()β unchanged, first (latch protecting persisted monitor records).closeScopeBounded(appFiberScope, APP_FIBER_SCOPE_CLOSE_TIMEOUT_MS)β interrupts and awaits supervised fibers while every dependency they might touch during finalization is still alive. No occupants in Phase 11; the position is fixed now so the engine-core phase does not have to re-derive it.desktopBridgeServer.stop()β¦terminateAll()β¦timelineService.flush()).disposeAppRuntime(runtime, APP_RUNTIME_DISPOSE_TIMEOUT_MS)β closes the runtime scope (interrupts any fiber started viaruntime.runXβ none long-lived in Phase 11; runs layer finalizers β none in Phase 11 by I5). Hung βwarnat the timeout; never rejects.Budget: 2 s + 2 s inner bounds inside the callers' 5 s outer budgets; the outer race in
main.tsremains the last line of defense.Rule for future occupants: anything forked into
AppFiberScopemust tolerate interruption at any suspension point and must not depend on resources torn down in step 1; anything that needs a Layer finalizer must first prove reverse-construction order is compatible with steps 2β3 (I5).disposedmakesdispose()idempotent (twobefore-quitlisteners, tests/ipc dispose+shutdown).shutdown()never touches the runtime orAppFiberScope.EffectRunnerhandles keep working after runtime dispose (I2), so a straytick()/scheduleRetry()after quit cannot defect. TheManagedRuntimeis referenced only byServiceContainerand thecreateCoreServicesreturn value.stop()stays synchronous (runner.runSync(Scope.close)) because their fibers suspend only on the clock. The engine core will fork intoAppFiberScope(step 2.2 awaits it) β the reason both seams exist now.uncaughtException/SIGKILL run no finalizers. Finalizers are best-effort; durable state must remain crash-safe without them (AGENTS.md self-healing rule). Nothing in Phase 11 makes a finalizer the sole guardian of durable state.6. Risk register
runSyncthrows at startupprovideMergestages; wiring layers replay today's order; tests/ipc as behavioral gateshutdown()β₯dispose(); dispose+shutdown in tests)disposedlatch; runtime/AppFiberScope closed only indispose(); PR 1 testruntime.runXafter dispose β defectEffectRunner, never the ManagedRuntimeContextβServiceMap, Layer renames)Layer/Context/ManagedRuntime/TestClockimports confined todi/; exact pinAppRuntime builtms +initializetotals vs baseline in sandbox; PR 3 gatemake typecheckwall time; fallback (C)Effect.provideof a ~70-entry Contextsrc/cli/*.test.tsassert the cleanup steps existEffectRunnerexpecting dispose to await itEffectRunner("unsupervised"); PR 2 asymmetry test; review audit 1Rollback: PRs are stacked; revert in reverse order (6β1). Service classes are never modified except for optional trailing params, so any revert restores the previous composition root wholesale with no data or API implications.
7. Dogfooding (per PR; evidence attached to the PR body)
Environment (headless Coder host, no
DISPLAY):<XUM_ROOT>/logs/*.logshows, in order:Loading services...,[startup] AppRuntime built {ms},[startup] ServiceContainer.initialize starting, six step durations,[startup] ServiceContainer.initialize completed {totalMs, stepDurationsMs}. Paste baseline (origin/main) vs branch numbers.xum serverexits non-zero with the existing logged error and no unhandled-rejection trace; for desktop, confirm by code path (loadServices()rejects βmain.ts:1255dialog) and viasrc/cli/server.test.ts/ACP tests.open <url>βsnapshot -iβ add a scratch git repo as a project β create a workspace β send one message βscreenshotthe loaded app and the response;attach_fileboth. Video: startagent-browser recordbefore the flow and stop it with a hard timeout (timeout 30 agent-browser record stop); if stopping hangs (known), attach the truncated WebM plus the screenshots and say so.handlerGen+ runtimeeffect/context); screenshot before/after; grep logs forManagedRuntime disposed/defect lines (expect none).script -q /tmp/<workspace>-shutdown.log(oragent-ttyif present),kill -TERM <pid>β expect[shutdown]lines,AppRuntime disposed {ms}, exit 0, no force-exit message; attach the typescript. Exercise the timeout branch once with a scratch hung finalizer βwarn+ timely exit.Xvfb,make dev+ agent-browser via CDP (electron skill): screenshot splash β main window, quit via menu, confirm exit < 5 s; otherwise state that the Electron path is covered bytests/e2ein CI and the shareddispose()path exercised byserver.ts.Gate suites per PR (plus
make static-checkalways):src/node/services/di/*,serviceContainer.test.ts,src/node/orpc/*,memoryMeta*,make test-integrationheartbeatService.test.ts,idleCompactionService.test.ts,retryManager.test.tsbun test src/node/services,src/cli/*.test.ts; record PR 4 gate numbersstreamManager*.test.ts,aiService.test.ts,workspaceService*.test.tsmake test-integration,src/cli/server.test.ts,src/cli/cli.test.tsmake test-integration+ sandbox startup/shutdown evidence8. Non-goals (explicit)
AppFiberScopeoccupant; separate phase).Schemaat persistence boundaries; OAuth refresh/device-flow workers;AgentStatusServicesetIntervalβ Effect.initialize()as a Layer/startup effect (D2); per-service optional tags (D3);streamBridgeon the runtime; layer finalizers for existingdispose()steps.effect/contextsource.9. Assumptions stated
Effect.context<never>()insideEffectRunnerLivereturns the enclosing build context including an upstreamTestClockentry (PR 2 test; fallback: provideClock.Clockexplicitly in the helper).Scope.fork(parent)inside aLayer.effectbody yields a child closed by the runtime's layer scope ondispose()(PR 2AppFiberScopetest).provide/provideMergestages or wiring-layer statement order.EffectRunner'sR = neverconstraint is sufficient for every lifecycle fork in the three Phase 11 workers andStreamManager.schedulePartialWrite(they only useEffect.sleep/Schedule/Effect.sync/Effect.tryPromiseβ no service tags). Verified by typecheck in PR 2/5.Generated with
xumβ’ Model:anthropic:claude-fable-5-1β’ Thinking:xhigh