🤖 feat: opt-in project bundle for settings backup - #4043
Conversation
This comment has been minimized.
This comment has been minimized.
|
@codex review |
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: df11068b2a
ℹ️ 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".
|
@codex review |
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: 624b3c251d
ℹ️ 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".
|
@codex review |
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: 08522dde8c
ℹ️ 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".
|
@codex review |
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: 9ec6fffe95
ℹ️ 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".
|
@codex review |
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: 800f195262
ℹ️ 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".
|
@codex review |
There was a problem hiding this comment.
💡 Codex Security Review
Here are some automated security review suggestions for this pull request.
Reviewed commit: 311abe4237
ℹ️ About Codex security reviews in GitHub
This is an experimental Codex feature. Security reviews are triggered when:
- You comment "@codex security review"
- A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review
Once complete, Codex will leave suggestions, or a comment if no findings are found.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 311abe4237
ℹ️ 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".
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5ca5c7ace1
ℹ️ 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".
This comment has been minimized.
This comment has been minimized.
|
@codex review |
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: 8036e1fddf
ℹ️ 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".
|
@codex review |
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: 8d85e8b843
ℹ️ 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".
|
@codex review |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Round 39 (Codex P2): a project removed and re-registered at the same path with identical config is invisible to a content snapshot of the registry, so the removed checkout's remote could still reach the replacement's bundle entry. Config now exposes configFileWriteGeneration() — inode, mtime, and size of config.json, which every save changes (a fresh file is renamed into place) regardless of the bytes and of which process wrote it — and the registry snapshot compared around remote discovery includes it, so any write in the window drops the export's hints. The discovery-window test now re-registers with identical config and rewrites the file.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2a04bfb563
ℹ️ 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".
This comment has been minimized.
This comment has been minimized.
…eration Round 40 (Codex P2): an inode/mtime/size tuple can return to a previous value across two same-size saves on a filesystem with coarse mtimes when the allocator reuses the freed inode — exactly the remove-then-re-register cycle the remote-hint check needs to see. Every Config save now stamps a random writeId into config.json (optional, passthrough-compatible; older builds drop it), and configFileWriteGeneration combines it with the stat tuple, which still covers writers that do not stamp. New config test: two saves of the same content differ only by the stamp and yield different generations.
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: cd6b3927b3
ℹ️ 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".
…irectory handle Round 41 (Codex P2 x2): - writeConfigDocument refreshes writeId on every config.json rewrite, so the config tool's read-modify-write advances the write generation like a Config save does instead of carrying the previous stamp forward. - An approved import target is pinned by a directory handle opened at planning and held until the imports are done (closed however the restore ends, and on a planning refusal). Execution compares the path against the handle's dev/ino: a directory deleted and recreated at the path cannot be given the same inode number back while a handle keeps it allocated, so the identity check is no longer ABA-vulnerable — on this host's filesystem the freed inode was reused 20/20 times without a handle and never with one.
Round 41 follow-up: the previous head's static-check failed typecheck on the two root-repair tests' typed casts, which lacked the writeId the assertion expects.
|
Codex Review: Didn't find any major issues. More of your lovely PRs please. Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
This comment has been minimized.
This comment has been minimized.
…WiringLive; thin ServiceContainer; StreamManager runner param (coder#4061) ## Summary Effect migration Phase 11, PR 5 of 6. **The desktop-only tail of the service graph now builds as Effect Layers too**: six `Layer.effectContext` **group layers** (`BrowserLive` · `DesktopBridgeLive` · `TerminalEditorLive` · `MiscDesktopLive` → `OauthLive` · `WorkersLive`) construct the ~40 remaining desktop services with their existing argument lists, `DesktopWiringLive` replays the former `ServiceContainer` constructor's setter / listener / global-registration statements **verbatim and in order** after `CoreLive`, and the `ServiceContainer` constructor shrinks to `makeAppRuntime(AppLive(stores))` plus field assignment from the built context (`toORPCContext()` unchanged in shape; `initialize()`/`dispose()`/`shutdown()` untouched, §5 order fixed). `StreamManager` gains an optional trailing `runner: EffectRunner`: its partial-write debounce forks through it and `AgentSession` hands the same runner to its `RetryManager`, so both sleep on the app runtime's `Clock` (a `TestClock` in tests). Stacked on PR 1 #4049, PR 2 #4050, PR 3 #4051, PR 4a #4054, PR 4b #4057. Plan: `<details>` at the bottom (§2.1/§2.2, §2.3 invariants, §3 "PR 5", §5, §7). ## Implementation - **`di/tags.ts`** — 47 new `Context.Service` tags (type-only imports; §2.1 naming; `Tag` suffix where the bare name is not a `*Service` class or would shadow — `WindowTag`, `QuickJSRuntimeFactoryTag`, `DesktopSessionManagerTag`, …), grouped as `BrowserTags | DesktopBridgeTags | TerminalEditorTags | MiscDesktopTags | OauthTags | WorkerTags = DesktopTags`; `AppTags = CoreRootTags | CrossCuttingTags | DesktopTags`. - **`di/layers/desktop.ts`** — six `Layer.effectContext` group layers (each yields its inputs, constructs several services in the constructor's original order, returns a `Context`; the `R` annotation on each group *is* its declared dependency set); `DesktopWiringLive` (`Layer.effectDiscard`, yields every collaborator first, then the wiring statements); composition below. - **`di/layers/app.ts`** — `AppLive = DesktopLive ▹ CoreLive ▹ CoreOptionsFromDesktopLive ▹ CrossCuttingLive ▹ MemoryMetaLive ▹ runtimeSeams`. **`core.ts`** — `StreamManagerLive` passes `yield* EffectRunnerTag` (5th ctor arg); `CoreInputTags` gains `EffectRunnerTag`. - **`serviceContainer.ts`** — constructor = build + 66 `this.x = get(Tag)` lines; service imports type-only; the never-read private `ptyService` field is gone (the PTY lives in the graph under `PTY` for `TerminalService`). `initialize()`, `toORPCContext()`, `shutdown()`, `dispose()` byte-identical. - **`streamManager.ts`** — `constructor(…, eventSink = () => undefined, runner: EffectRunner = defaultEffectRunner)`, `public readonly effectRunner`; `schedulePartialWrite`'s `runSync(forkIn)`/`runFork` and `interruptPartialWriteFiber`'s `runFork(Fiber.interrupt)` go through it; both `Scope.close` sites stay `Effect.runFork` (async-close precedent). **`agentSession.ts`/`retryManager.ts`** — `AgentSessionStreamManager` gains `readonly effectRunner?: EffectRunner`; `new RetryManager(…, this.streamManager.effectRunner)` (undefined → RetryManager's default, so doubles and the `aiService` fallback are unchanged). - **Tests** — `serviceContainer.test.ts`: exhaustive `Record<keyof Omit<ORPCContext, "headers" | "effect/context" | "effect/wrap">, Tag>` identity test (a new ORPC field without a tag fails to compile), a desktop-wiring behavioral test (bindings, every `set*` collaborator, idle-compaction outcome forwarding, SSH-prompt global registration observed via `isInteractiveHostKeyApprovalAvailable()`, a timing listener), and a `dispose()`/`shutdown()` order test via spies on the public methods already spied today; the original assertions are unchanged. `streamManager.test.ts`: the debounce fires on the injected runner's `TestClock` (red-checked against the global-runtime fork). ## PR 5 notes ### Group DAG (re-derived from the constructor argument lists) | Group | Services, in the former constructor's order | Declared requirements (`R`) | |---|---|---| | `BrowserLive` | BrowserBridgeTokenManager → AgentBrowserSessionDiscovery → BrowserControl → BrowserSessionStateHub → BrowserBridgeServer | Config | | `DesktopBridgeLive` | DesktopSessionManager → DesktopTokenManager → DesktopBridgeServer | Config, Experiments, Workspace | | `TerminalEditorLive` | PTY → Terminal → Editor → Tokenizer → Instructions | Config, SecretsStore, Workspace, SessionUsage, AI, Provider | | `MiscDesktopLive` | QuickJSRuntimeFactory, SshPrompt, **Window**, Backup, AgentPluginInstall, Project (needs SshPrompt), Update, Server, MenuEvent, Voice, Coder (singleton), ServerAuth, WorkspaceLifecycleHooks, WorktreeArchiveSnapshot | Config, SecretsStore, ProvidersConfigStore, Experiments, Policy, Provider, MCPServerManager, WorkspaceMcpOverrides | | `OauthLive` | McpOauth → MuxGatewayOauth → MuxGovernorOauth → CodexOauth → CoderOauth → CopilotOauth | Config, ProvidersConfigStore, FileLeaseManager, MCPConfig, Provider, Policy, Telemetry, **Window** | | `WorkersLive` | IdleCompaction → Heartbeat → Timeline → Refine (needs Timeline) → AgentStatus (needs Tokenizer, Window) | Config, **EffectRunner**, Experiments, History, ExtensionMetadata, Workspace, Task, IdleDispatcher, Memory, MemoryMeta, AI, SessionUsage, **Tokenizer**, **Window** | | `DesktopWiringLive` | the former constructor's 12 wiring blocks, verbatim, in order — incl. #4043's `backupService.setProjectService/setMemoryNotifier` after the `projectService.set*` lines (rebased) — runs after `CoreLive`, so core listeners still precede desktop ones | Config, CrossCuttingTags, CoreTags, DesktopTags | ``` DesktopBase = Layer.mergeAll(MiscDesktopLive, BrowserLive, DesktopBridgeLive, TerminalEditorLive) // true siblings DesktopUpper = Layer.mergeAll(OauthLive, WorkersLive).pipe(Layer.provideMerge(DesktopBase)) // both need Base DesktopLive = DesktopWiringLive.pipe(Layer.provideMerge(DesktopUpper)) AppLive = DesktopLive ▹ CoreLive ▹ CoreOptionsFromDesktopLive ▹ CrossCuttingLive ▹ MemoryMetaLive ▹ (AppFiberScope ▹ EffectRunner ▹ Stores) ``` "True siblings" was checked constructor by constructor (I6 table): no base-group constructor takes or calls another desktop service, so their relative build order is a don't-care. The two upper groups' edges (`WindowService`, `TokenizerService`) are the only cross-group dependencies and are expressed with `provideMerge`, never with `mergeAll` argument order. ### I6 constructor side-effect audit (38 constructions moved here; full 38-row table with file:line cites in the first PR comment) | Group | Constructors (args exactly as the former constructor passed them) | Beyond capturing args | Order that matters | |---|---|---|---| | Browser | `BrowserBridgeTokenManager()` · `AgentBrowserSessionDiscoveryService({resolveWorkspaceCandidatePathsFn})` · `BrowserControlService({discovery, resolveSessionEnvFn})` · `BrowserSessionStateHub({control})` · `BrowserBridgeServer({discovery, tokenManager, stateHub})` | token manager: own unref'd cleanup `setInterval` (as before); bridge server: unattached `WebSocketServer({noServer})` | intra-group arg order only | | DesktopBridge | `DesktopSessionManager({config, experimentsService, workspaceService})` · `DesktopTokenManager()` · `DesktopBridgeServer({sessionManager, tokenManager})` | token manager: own unref'd cleanup `setInterval` (as before) | intra-group arg order; core `Workspace` (staging) | | TerminalEditor | `PTYService()` · `TerminalService(config, pty, secretsStore)` · `EditorService(config, workspaceService)` · `TokenizerService(sessionUsage, ai, provider)` · `InstructionsService(config, ai, tokenizer)` | none (the tokenizer *worker* is created at `workerPool.ts` import time, not by the ctor — see Observations) | intra-group arg order | | Misc | `QuickJSRuntimeFactory()` · `SshPromptService()` · `WindowService()` · `BackupService(config, {gitRepo, payload})` · `AgentPluginInstallService(config, {isEnabled, mcpServerManager, workspaceMcpOverridesService})` · `ProjectService(config, sshPrompt, secretsStore)` · `UpdateService(config)` · `ServerService()` · `MenuEventService()` · `VoiceService(config, provider, policy, providersStore)` · `coderService` · `ServerAuthService(config)` · `WorkspaceLifecycleHooks()` · `WorktreeArchiveSnapshotService(config)` | `AgentPluginInstallService`: un-awaited startup journal reconcile + module-level discovery gate (as before, from its own args); `UpdateService`: `config.getUpdateChannel()` + un-awaited `initialize()` (no-op outside Electron) | `Project` after `SshPrompt` (same group, in order); `AgentPluginInstall` after core (staging) | | OAuth | `McpOauthService(config, mcpConfig, window, telemetry)` · `MuxGatewayOauthService(providersStore, provider, window)` · `MuxGovernorOauthService(config, window, policy)` · `CodexOauthService(providersStore, provider, window)` · `CoderOauthService(providersStore, fileLeaseManager, provider, window, policy)` · `CopilotOauthService(provider, window)` | **`CoderOauthService` subscribes `providerService.onConfigChanged`** (`coderOauthService.ts:373`) — a declared *core* dependency; `AIService`'s own subscription (S3) still precedes it because every desktop group builds above `CoreLive`; no other desktop ctor subscribes to `providerService` | after Misc (`WindowService`) via `OauthLive ▹ DesktopBase` | | Workers | `IdleCompactionService(config, history, extensionMetadata, executeIdleCompaction, runner)` · `HeartbeatService(config, extensionMetadata, workspace, task, idleDispatcher, runner)` · `TimelineService(config, history, experiments)` · `RefineService(config, memory, memoryMeta, history, ai, experiments, {timeline, sessionUsage, emitChatMessage, acquireTurnExclusion})` · `AgentStatusService(config, history, tokenizer, extensionMetadata, workspace, window, ai, {sessionUsage, requestAnalyticsIngest})` | none (scopes/fibers/intervals start in `start()`; `subscribeToWorkspace` is a wiring statement) | `Refine` after `Timeline` (same group); after Misc (`Window`) + TerminalEditor (`Tokenizer`) via `WorkersLive ▹ DesktopBase` | No moved constructor reads a setter-provided collaborator or registers listeners on `workspaceService`/`aiService`/`taskService`/`memoryConsolidationService`/`mcpServerManager` (only `CoderOauthService` → `providerService`, above). Wiring statements that used to sit *between* constructions now run after all of them; none of the constructors that followed them read the wired state, so the observable order of effects is unchanged. ### Split decision Raw product diff is +1149 / −517 (8 files), above the plan's ~600-line heuristic; I evaluated a 5a/5b split along group boundaries and kept one PR: the surface is mechanical relocation (~330 wiring + ~250 constructor-call lines moved verbatim, 184 lines of tags — `git diff --color-moved=dimmed-zebra origin/main -- src/node/services/serviceContainer.ts src/node/services/di/layers/desktop.ts` dims them), a mergeable 5a would need a throwaway hybrid constructor, and the wiring move would still land whole in one half. ### Deviations from the plan / observations - **`RetryManager` site.** The plan places the runner hand-off in `streamManager.ts`; the constructor is in `agentSession.ts` — reached via the optional `AgentSessionStreamManager.effectRunner` field. - **Tokenizer worker starts ~1 s later in `xum server`/ACP (not desktop).** `workerPool.ts` creates the tokenizer `Worker` at import time; `main` reached it via `serviceContainer.ts`'s early `TokenizerService` import (~0.9 s after spawn, before `effect`/`di/*` loaded), now via `core.ts` → `aiService` → `historyService` → `tokenizer` (~1.9 s). Total startup is unchanged (spawn → `initialize completed` ≈ 2.55 s on both) and the desktop is unaffected (`desktop/main.ts` imports `tokenizer` first), but a SIGTERM *during* the worker's ≈19 s encoding load waits for its current module evaluation on both trees (so a "1 s after init" probe read 0.6 s vs 1.6 s — strace: the gap sits between `exit(0)` and the worker thread's exit, not in `dispose()`, which is 70–120 ms on both). Steady-state shutdown is unchanged (table). Left as is: pre-existing import-time worker creation, unrelated to the composition root; an explicit warm-up call site is a follow-up candidate, not a bug. - `DesktopWiringLive` is a `Layer.effectDiscard` over an `Effect.gen` body whose only yields are service tags (synchronous); no finalizers, no forks (I5) — same shape as `CoreWiringLive`. ### Pre-review audits (plan §3) 1. **Interruption posture** — moved forks: `StreamManager.schedulePartialWrite` (`runner.runSync(forkIn(…, resourceScope))` / whitebox `runner.runFork`) and `interruptPartialWriteFiber` (`runner.runFork(Fiber.interrupt)`) — unsupervised through the runner exactly as through the global runtime; interrupted by the stream's resource-scope close and by re-arm, unchanged. `RetryManager` forks through the injected runner; cancelled by `cancel()`/`dispose()` as before. No forks in `di/layers/`. 2. **Uninterruptible teardown** — `dispose()`/`shutdown()` bodies byte-identical; the §5 order is now asserted. 3. **No defect escapes** — no new Promise facades; `makeAppRuntime` stays the one throw site (throwing-layer test passes through the deeper graph). 4. **Spy-seam check** — `rg 'spyOn\(' src/node/services/serviceContainer.test.ts tests/ipc tests/ui`: every target is a public method on an instance the container still exposes → intercepted, since each field *is* the context instance (identity test). Arity: only `StreamManager` gained a trailing optional param; `AgentSessionStreamManager` gained an optional readonly field. Typecheck of every test proves it. 5. **Sync-start** — the new debounce test pins that `partialWriteFiber` is armed synchronously and fires on `TestClock.adjust`. 6. **I6** — table above. 7. **I3** — `memoryConsolidationService.ts` not in the diff. ### Re-recorded gate numbers (R7/R8) Sibling worktrees under one scratch dir with shared `node_modules`, interleaved runs; `origin/main` = `1c81235c1` (4b) vs this branch. Host: 96 cores, CPU PSI `some avg60` ≈ 39–41 %. | metric | origin/main (4b) | branch (PR 5) | note | |---|---|---|---| | `tsgo --noEmit` wall, 3 interleaved pairs (median, min–max) | 12.01 s (11.47–14.04) | 11.94 s (11.82–14.71) | flat | | `tsgo --extendedDiagnostics` (renderer) | types 1 930 432 · check 10.50 s | types 1 934 836 (+0.23 %) · check 9.43 s | noise | | `new ServiceContainer(stores)` in-process, 3 runs × 15: **cold** (first) median | 27 ms (24–29) | **35 ms** (35–44) | **+≈8 ms cold** — Layer first-use for 7 more layers + 3 composition nodes (trend 12 → 18 → 23 → 27 → 35 ms across PR 3/4a/4b/main/PR 5; `main`'s 27 includes the imperative desktop constructor) | | … **warm** median (min) | 1.87 ms (1.11) | 2.18 ms (1.65) | +≈0.3 ms | | `[startup] AppRuntime built` in `xum server` (10 runs) | 11 ms (core only) | 16 ms (whole graph) | not comparable (main excludes the desktop ctor) | | spawn → `AppRuntime built` / → `initialize completed` (3 pairs) | 2.35 s / 2.55 s | 2.32 s / 2.55 s | unchanged | | `ServiceContainer.initialize completed { totalMs }` (10 runs) | 245 (215–336) | 243 (215–279) | unchanged code | | SIGTERM → exit, steady state (25 s after init; 5 pairs) | 151 ms (134–185), exit 0 ×5 | 169 ms (149–179), exit 0 ×5 | noise; `[shutdown] AppFiberScope closed` → explicit steps → `[shutdown] AppRuntime disposed` in every transcript | A chained (`provideMerge`-only) composition of the same six groups costs the same (30–42 / 2.1–3.0 ms): the +8 ms is Effect first-use, not sibling concurrency. ### Lessons for PR 6 (TestClock sweep + shutdown hardening + DI contract docs) - `StreamManager` takes a runner now: the partial-write debounce cases in `streamManager.test.ts` can move to `makeTestEffectRunner()` (5th ctor arg; the new test is the template); `RetryManager` gets its runner from `streamManager.effectRunner`, so `agentSession` harness tests can inject a TestClock through a stream-manager double. - The tokenizer worker is created at import time (`workerPool.ts`); `[shutdown]` timing probes must wait for its ≈19 s load or they measure its module-evaluation tail — PR 6's per-step `[shutdown]` lines will expose the `AppRuntime disposed` → `process.exit` gap. - `Record<keyof Omit<ORPCContext, …>, Tag>` is the ORPC exhaustiveness guard (exclude `effect/wrap` with `headers`/`effect/context`). Six group layers + three composition nodes cost +8 ms cold / +0.3 ms warm — record the per-layer first-use cost in the contract doc. ## Validation - `make static-check` green. `bun test` gate (`streamManager*`, `aiService`, `serviceContainer`, `coreServicesRoot`, `di/*`, `retryManager`, `heartbeat`, `idleCompaction`, `cli/server`, `cli/cli`) 375/375; all 26 `agentSession*` suites 279/279; `bun test src/node/services src/cli src/node/orpc src/node/acp` 7057 pass / 15 fail — the known host baselines (taskGitPatchEngine ×2, WorkspaceTurnManager ×2, agent_skill_delete, BackupRepoCache ×9) + one `attachmentService.completedReports` flake that passes in isolation on both trees. - `TEST_INTEGRATION=1 bun x jest tests` (tests/ipc + tests/ui): 669 pass / 77 fail / 49 skipped — all environment baselines: provider-backed suites (`403 Forbidden` from the AI bridge / missing xAI key), SSH/Docker rows, four `src/**/__tests__` bun:test files jest picks up, and `terminal.test.ts` (1) · `sendModeDropdown.test.ts` (1) · `reportRelocation.test.ts` (1), which fail identically on **pristine `origin/main`** (re-run in the foreground in the sibling worktree). CI is the lane for the provider suites. - **Dogfooding** (headless Coder host, `XUM_LOG_LEVEL=debug DEV_SERVER_SANDBOX_ARGS=--clean-projects make dev-server-sandbox`): log order `AppRuntime built { ms: 24 }` → `initialize starting` → six step durations → `initialize completed { totalMs: 233 }`; no `ManagedRuntime disposed`/defect lines. agent-browser: loaded the app (`v0.28.3-nightly.148-28-g909dadd90`), added a scratch git repo as a project, sent "Reply with exactly the single word: pong" → worktree workspace created, model replied `pong`, Stats tab populated (screenshot). **oRPC Effect path:** memory experiment enabled, `memory.save` → `setPinned true` → `list` (`pinned: true`) → `setPinned false` over `/orpc` (all `handlerGen` + runtime `effect/context`), then pinned/unpinned from the Memory tab; `memory-meta.json` flipped `pinned` true → false (screenshot). **Graceful quit:** SIGTERM → `[shutdown] AppFiberScope closed { ms: 1 }` → `AgentStatusService stopped` → `terminateAll()` → `[analytics-worker] Shutting down, closing DuckDB` → `[shutdown] AppRuntime disposed { ms: 7 }` → nodemon `clean exit`, 191 ms, exit 0; plus the 5 steady-state pairs in the table (exit 0 ×10). Not exercised headless: Electron `before-quit` (same `dispose()`; `tests/e2e` in CI).   https://github.com/user-attachments/assets/0d3e4c76-82d2-4344-80ae-b1dba493ecaf ## Risks - **Low–medium.** The one behavioral surface is the wiring relocation: every statement is verbatim and in order, the constructors that used to run between wiring lines are audited as not observing them (I6), the `tests/ipc` behavioral gate matches pristine `main`, and the desktop wiring test pins each collaborator edge. `dispose()`/`shutdown()` are unchanged and their order is asserted. - Startup: +≈8 ms cold construction; `initialize()` unchanged; tokenizer-worker import-order shift in `xum server`/ACP (observation above) — no functional change. --- <details> <summary>📋 Implementation Plan</summary> # Effect migration — Wave 3 / Phase 11: ManagedRuntime + Layer dependency injection ## 0. Summary Replace the two hand-written composition roots (`createCoreServices` + the `ServiceContainer` constructor) with an **Effect `Layer` graph** built once per process by a **`ManagedRuntime`** ("AppRuntime"), while keeping every service class, constructor signature, Promise facade, private method, and test seam compatible. The runtime becomes (a) the owner of the app-lifetime `Scope`, (b) the provider of `"effect/context"` for oRPC Effect-native handlers, and (c) the source of two runtime seams: an **`EffectRunner`** (context-bound, *unsupervised* runner that lets clock-driven workers run on a `TestClock`) and an **`AppFiberScope`** (a runtime-owned, *supervised* scope whose close is awaited by `dispose()` — the slot the streamManager engine core will occupy later). Six stacked, independently mergeable PRs. Product PRs keep existing tests unchanged; only the final test-modernization PR edits tests. Net product LoC ≈ **+420** (per-PR estimates below). Service classes are *not* rewritten — Layers are thin adapters around existing constructors; cycle-breaking setter wiring moves into explicit "wiring layers" that replay today's order. Unlocks (not done here): streamManager ENGINE CORE conversion, `TestClock` for timing suites, app-lifetime scopes. ## 1. Verified current state (evidence) - **Roots.** `src/node/services/coreServices.ts:103-389` (`createCoreServices`: 25 constructions, 12 `turnRequestBuilderBindings` writes, ~14 setters) and `src/node/services/serviceContainer.ts:161-575` (45 more constructions; `aiService.on(...)`/`workspaceService.on(...)` analytics wiring at 474-574; global registrations `setGlobalCoderService/setSshPromptService` at 469-471). `new ServiceContainer(stores)` is called by `headlessEnvironment.ts:111`, `tests/ipc/setup.ts`, `src/cli/server.ts:132`, `src/node/acp/serverConnection.ts:155`, `src/desktop/main.ts:653`; `src/cli/run.ts:661` and `src/cli/workflow.ts:376` call `createCoreServices` directly. ⇒ two graph roots (App vs Core), five process entry points, all constructing **synchronously**. - **Startup.** `ServiceContainer.initialize()` (577-642) awaits six `initialize()`s (no try/catch; failure propagates to `main.ts:1255-1265` "Startup Failed" dialog + quit; `server.ts`/ACP log and exit), then sync `start()`s idleCompaction/heartbeat/agentStatus, then two fire-and-forget sweeps. All constructors are synchronous; two have side effects on **declared constructor dependencies** only (`AIService` → `streamManager.setEventSink`, `WorkspaceService` → `backgroundProcessManager.on/aiService.on`). - **Teardown.** `dispose()` (746-779) is explicit and hand-ordered (`backgroundProcessManager.beginShutdown()` MUST be first — it is a latch protecting persisted monitor records; bridges stop before sessions close; `terminateAll` late; `timelineService.flush()` last). `shutdown()` (718-732) is a *second* sequence fired concurrently by a second `before-quit` listener (`main.ts:1321`). `main.ts:1296-1304` races `dispose()` against 5 s then `app.quit()`; `cli/server.ts:227-268` has a 5 s `process.exit(1)` force timer; `tests/ipc` cleanup calls `dispose()` then `shutdown()`; `headlessEnvironment.dispose` never calls `services.dispose()`. - **Existing Effect surface.** 25 files import `effect`. Only `Context.Service` tag: `MemoryMeta` (`src/node/orpc/effectContext.ts:21`). `handlerGen` (`@orpc/experimental-effect`) runs `Effect.runPromiseExit` per request and `Effect.provide`s `opts.context["effect/context"]`. `streamBridge.ts` runs streams on the global runtime. Scope-owning workers: `heartbeatService.ts:134-243`, `idleCompactionService.ts:86-122` (`Scope.makeUnsafe` + `Effect.runSync(Scope.close(..))`, valid only because their fibers suspend solely on the clock), `oauthFlowManager.ts:164`, `streamManager.ts:4767/4054` (already `Effect.runFork(Scope.close(..))` — the async-close precedent). `memoryConsolidationService.ts:667-703, 837-860`: check-and-reserve funnels with zero suspensions before `inFlight.set`/`harvestInFlight.set`. - **[email protected] 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` …
## Summary Version bump for the v0.28.4 patch release. The headline change since v0.28.3 is Gemini 3.8 Flash becoming the default Gemini Flash model (coder#4060). The release also carries browser Login with Coder on remote Xum servers (coder#4047), the opt-in project bundle for settings backup (coder#4043), the connection-indicator slow-response surfacing (coder#4059), send-queue and terminal-wake fixes (coder#4053, coder#4052), and the Effect Phase 11 runtime refactors. ## Implementation Bumped with `node ./scripts/set-package-version.js 0.28.4` so the root `package.json` and the legacy `packages/mux-compat` forwarding package stay version-locked (the v0.28.3 bump missed the compat package and broke `Test / Unit` on main, fixed in coder#4048). `src/common/compat/productIdentity.test.ts` passes locally. After this PR merges, the `v0.28.4` tag will be applied to the squash commit and the GitHub Release published to trigger the desktop/npm/docker pipelines. --- _Generated with `xum` • Model: `anthropic:claude-fable-5-1` • Thinking: `medium` • Cost: `$0.00`_ <!-- mux-attribution: model=anthropic:claude-fable-5-1 thinking=medium costs=0.00 -->
…4075) ## Summary Reverts coder#4046 (merge commit ffa2780). macOS shortcut labels go back to the previous middle-dot form ("⌘·P", "⌘·⇧·P") and `DirectoryPickerModal` regains its hardcoded "⌘O" label. Windows/Linux output was never affected. ## Background Requested by Mike. This is a plain `git revert` of the squash-merged commit; the only difference from the pre-coder#4046 tree is the unrelated `SETTINGS_BACKUP_TOGGLE_PROJECTS` registry entry added later by coder#4043, which auto-merged cleanly. ## Validation - `src/browser/utils/ui/keybinds.test.ts` (35 tests) and `make static-check` pass locally. - Verified the two fully reverted files are byte-identical to `ffa2780f^`, and `keybinds.ts` differs from it only by the coder#4043 hunk. ## Risks Display-only; no keybind matching or registry changes. --- _Generated with `xum` • Model: `anthropic:claude-fable-5-1` • Thinking: `xhigh` • Cost: `$0.00`_ <!-- mux-attribution: model=anthropic:claude-fable-5-1 thinking=xhigh costs=0.00 -->
Summary
Adds an opt-in project bundle to Settings Backup: when enabled, a backup additionally carries the project list (path, display name, sanitized git remote) and each project's memory files, and a restore can reimport them — verbatim for projects registered at the recorded path, and through an explicit, token-bound approval flow for everything else.
Background
Settings backup previously synced only host-portable global settings; project registrations and per-project memories (
memory/project/<name>-<pathHash>/) were always left behind, so moving to a new machine lost all project memory. Project paths are host-specific, so a naive restore would be wrong — this PR makes the cross-machine flow explicit and safe instead.Implementation
<managedPath>/project-bundle/with its own manifest and byte budget. The coremanifest.jsonnever references it, so older builds preview/restore the same backup untouched (they simply ignore the sidecar), and a crafted core manifest still cannot smuggleproject-bundle/**paths past the allowlist.sha256token over the entry metadata + file checksums. Restore re-verifies each token against the checked-out payload; any drift fails withPROJECT_IMPORT_APPROVAL_REQUIREDand fresh candidates. No auto-clone, no auto-register: the user supplies an existing local directory, registration goes throughProjectService, and memory is written add-only (conflicts reported, never overwritten). Memory paths are re-keyed to the locally computed directory name so a foreign-OS path hash never leaks into the local store.sanitizeBackupGitRemote(plainhttps:///ssh:///scp-like only; credentials,ext::,file://, and relative paths dropped) and render as inert text. Bundle memory files run through the same secret scanner as global memory.Validation
make static-checkclean.git ls-treeshowsxum/project-bundle/{manifest.json,memory/...}while corexum/manifest.jsonlists only core files; disabling the toggle removes the bundle from the next push head.config.jsonand re-keyed memory under the locally computed dir with identical content.Risks
project-bundle/from the head (git history retains it); the settings UI warns about history retention.📋 Implementation Plan
Opt-in Project Bundle for Settings Backup (project memories + project reimport)
Context
The settings backup (
src/node/services/backup/) syncs a closed allowlist (AGENTS.md,mcp.jsonc,agents/*.md,skills/**,memory/global/**, projectedpreferences.json) to a user-chosen git repo. Project memories (<xumRoot>/memory/project/<name>-<sha256(absPath)[:12]>/) and project entries (config.jsonprojectsmap) are excluded today.Goal: an opt-in "project bundle" so a backup carries (a) project memories and (b) portable project entries (source path + git remote + memory dir per project, in
project-bundle/manifest.json), and restore can reimport projects on another machine — registering them at a user-chosen path and re-keying their memory dir to the new path's hash.Design stance (advisor-endorsed in prior discussion): this is an explicit project reimport flow backed by the backup, not raw project-config sync. Never auto-clone, never auto-register; unmatched memory is never written to live
memory/project/.Scope
In scope (v1):
includeProjectspersisted in backup settings; gates export and restore of project content symmetrically.project-bundle/sidecar (own manifest: project entries + memory files + checksums) for config-known user projects only; core manifest untouched for downgrade safety.Out of scope (deferred):
path,name,gitRemote,memoryDir.memory-meta.json(pins/stats) migration — that sidecar stays host-local (already a forbidden basename).Design
Repo layout: sidecar bundle outside the core manifest (downgrade-safe)
Verified code facts driving this:
parseManifestrunsassertAllowedPayloadPathon every core-manifest path (payload.ts:1611–1614 → 349–356), so an old build hard-fails preview/restore if new paths appear inmanifest.json.readBackupPayloadUncheckedreads only manifest-listed files and ignores extra files in the directory (payload.ts:1711–1724).writeBackupPayloadwipes the destination dir on every push (payload.ts:1508).managedPath→ bundle dropped from the latest tree (git history retains it; the next new-build push re-adds it). Documented as accepted downgrade behavior.writeBackupPayload(wipe + core files + core manifest) runs unchanged, then a newwriteProjectBundle(managedPath/project-bundle, bundle)writes the sidecar with its own path validator and budget. The core allowlist (isAllowedPayloadPath) is not widened: a crafted core manifest listingproject-bundle/**paths is still rejected byparseManifest, so bundle content can only enter through the bundle-validated reader.managedPath, gitRepo staging/sparse-checkout/commit machinery needs zero changes.Bundle manifest (single integrity root: project entries + file list)
{ "schemaVersion": 1, "projects": [ { "path": "/home/me/src/foo", // absolute source path (import hint ONLY, never a write target) "name": "foo", // getProjectDisplayName(path, config) "gitRemote": "[email protected]:me/foo.git", // origin URL; omitted if unavailable or hasUrlCredentials() "memoryDir": "foo-a1b2c3d4e5f6" } // ACTUAL source memory dir name (recorded, not recomputed) ], "files": [ // bundle-relative paths, sha256 verified on read { "path": "memory/project/foo-a1b2c3d4e5f6/notes.md", "sha256": "..." } ] }memoryDirvalidation is host-portable —projectMemoryDirNamebasename-parses the path, so recomputing it for a foreign-OS source path (Windows backup restored on POSIX) can legitimately differ. Validate instead:memoryDiris a safe single path segment (charset/portability rules), its hash suffix equalssha256(entry.path).slice(0, 12)(pure string hash, host-independent), and every bundle file of the entry lives undermemory/project/<memoryDir>/. Restore destinations are always computed locally viaprojectMemoryDirName(targetPath).Schema
BackupProjectBundleSchemainsrc/common/config/schemas/settingsBackup.ts; entry capMAX_BACKUP_PROJECT_ENTRIES = 256(mirrors MCP redaction caps).The bundle gets its own byte/count budget instance (same constants: 8MB/file, 64MB, 4096 files) so a large bundle can never starve core payload reads or block a core-only safety snapshot.
All non-system user projects are exported as entries, including zero-memory ones (the project list is half the feature); only memory dirs that contain files contribute
files. Excluded:projectKind === "system",_multi,_scratchkeys (src/common/constants/{multiProject,scratch}.ts).Bundle parsing/validation runs only when
includeProjectsis on. Toggle off ⇒ the sidecar is ignored entirely, even if malformed, so a bad bundle can never block a core-only restore. Toggle on ⇒ invalid bundle (schema, caps, checksum, memoryDir mismatch, disallowed path) isINVALID_BACKUP, fail closed — never silently skipped.Matching & reimport rules (restore)
includeProjects): after the core payload read, read + validateproject-bundle/manifest.jsonwhen present (own budget, bundle-scoped path validator, checksums, per-entry memoryDir validation per the portable rule above).entry.pathandprojectMemoryDirName(entry.path) === entry.memoryDiron this host → bundle memory files restore verbatim to<muxRoot>/memory/project/<entry.memoryDir>/**(overwrite allowed; these writes appear in restore previewchanges; performed under the memory mutation lock). If the hash suffix matches but the full dir name differs (foreign-OS source path), the entry is a reimport candidate — never rejected, never silently written.BackupProjectImport { sourcePath, name, gitRemote?, memoryFileCount, token }wheretoken = sha256(canonical JSON of [schemaVersion, normalized entry {path, name, gitRemote, memoryDir}, sorted [filePath, sha256] pairs])— the token binds the displayed entry and its content, so any repo change between preview and restore (files or entry metadata) invalidates it. Restore recomputes candidates from the currently checked-out payload; unknown/stale tokens ⇒ errorPROJECT_IMPORT_APPROVAL_REQUIREDcarrying fresh candidates (mirrors theCOMMAND_APPROVAL_REQUIREDround trip).targetPaths (not absolute, missing, not a directory, symlink) abort the restore before the safety snapshot and any mutation. After preflight passes and the snapshot exists, per-candidate runtime failures (dir vanished, registration race, file conflicts) are recorded in the result without aborting the restore or other candidates.projectImports: [{ token, targetPath }]on restore input), per candidate, sequenced to avoid lock nesting: re-verify the directory exists (ProjectService.createwould otherwise mkdir) → register viaProjectService.create(targetPath)(tolerating "already registered at that path") → then acquire the memory mutation lock, re-check conflicts under the lock, and write that entry's memory files tomemory/project/<projectMemoryDirName(targetPath)>/add-only: identical existing files are fine, differing existing files are skipped and reported as conflicts (no unpreviewed overwrite at a fresh target).withTargetMutationLock(muxRoot, memoryMutationLockKey(...))— the same lockMemoryServicemutations take (memoryService.ts:556–566) — so backup never becomes an uncoordinated memory writer. Never callProjectService.create/config.editConfigwhile holding the memory mutation lock (registration completes before the lock is taken).importedProjects(+ per-file conflicts/skips) so the user can undo manually.includeProjectsis off, are skipped (never written) and reported. Toggle-off reporting is existence-only ("project bundle present but disabled" via aproject-bundle/manifest.jsonexistence check — no parsing of a possibly malformed sidecar). Re-running restore later can still import them (payload persists in the repo).Security checklist (enforced in code, asserted in tests)
entry.memoryDir: safe single segment, hash suffix=== sha256(entry.path).slice(0,12), all entry files undermemory/project/<memoryDir>/; reject bundle otherwise (INVALID_BACKUP). (Not fullprojectMemoryDirNameequality — that recomputation is not host-portable; destinations are always locally computed.)project-bundle/**is still rejected byparseManifest, so bundle content cannot bypass bundle validation via the core restore path.entry.pathis display/matching data only; all filesystem writes derive from locally computed dir names, through the existingwriteCheckedFile/resolveContainedPathcontainment path.hasUrlCredentialsor aren't plainhttp(s)/ssh/scp-likegit@host:shapes (noext::, nofile://, no local paths); restore UI renders the remote as inert text (no link, never executed).memory/project/**marked recursively-collected for the secret scanner (isRecursivelyCollected, payload.ts:1326).Implementation phases
Phase 1 — Schemas + opt-in setting + toggle UI + export sidecar
src/common/config/schemas/settingsBackup.tsSettingsBackupInputSchema+includeProjects: z.boolean().optional()(lines ~205–225) — flows through all existing backup endpoints automatically and is persisted bypersistSettings.BackupProjectBundleSchema(+ entry schema,MAX_BACKUP_PROJECT_ENTRIES = 256), plus a remote-URL sanitizer helper (allowed shapes only; reuseshasUrlCredentials).src/node/services/backup/payload.ts(or a siblingprojectBundle.tsmodule reusing its internals)collectProjectBundle(muxRoot, entries)(per-entrymemory/project/<dirName>/**collection reusingcollectDirectorymachinery — never a blindmemory/projectsweep, so orphaned dirs of deleted projects stay local),writeProjectBundle(dir, bundle), bundle-scoped path validator (onlymanifest.json+memory/project/.+, same segment/hidden/forbidden/portability rules), own budget instance.createBackupPayload/writeBackupPayload/isAllowedPayloadPathstay unchanged; export composes: core write, then bundle write into<managedPath>/project-bundle.scanBackupFilesForSecretsover bundle files with recursive-collection semantics (extendisRecursivelyCollectedhandling to the bundle scan).src/node/services/backup/adapters.tsBackupPayloadStore.exportTo({ repositoryRoot, managedPath, includeProjects }); backupService passes the persisted setting explicitly.includeProjects: readconfig.projects, filter system projects, computememoryDirper project viaprojectMemoryDirName, fetch origin URL per project (smallexecFile("git", ["-C", path, "remote", "get-url", "origin"])helper, best-effort, sanitizer-filtered), build entries for all user projects.src/node/services/backup/backupService.ts:normalizeBackupSettingscarriesincludeProjects;push/preview/restorepass it to the payload store.BackupSection.tsxopt-in<Checkbox>in the setup form (house pattern, lines 537–587);DEFAULT_DRAFT/toDraft/draftsEqual(lines 53–65); conditional "Project list & project memories" row in the Included list (lines 589–609); privacy copy under the toggle: "Previously pushed backups keep project data in the repo's git history even after disabling." KeybindSETTINGS_BACKUP_TOGGLE_PROJECTS(Ctrl+Alt+P) inkeybinds.ts+KeybindsSection.tsx+actionsRefhandler (BackupSection.tsx:478–496).Gate 1: unit tests (payload/adapters) green, including the compat test (bundle-bearing export's core manifest parses cleanly and lists no bundle paths; core reader ignores the sidecar); dogfood export (Dogfooding §A) — pushed repo tree shows
project-bundle/content with toggle on, none with toggle off.Phase 2 — Bundle reader + preview candidates + matched-path restore + snapshot coverage
payload.ts/projectBundle.tsreadBackupPayload, invoked only whenincludeProjects: parse/validateproject-bundle/manifest.json(schema, caps, checksums, memoryDir segment + hash-suffix + file-containment validation per the portable rule above, bundle path validator, own budget); toggle off ⇒ sidecar never read.includeProjects&& payload has a bundle), snapshot the local project memory of registered projects (bundle format, own budget). Explicit limitation: the snapshot does not cover an import target's dir if that project wasn't registered pre-restore — imports are add-only, and the restore result lists created files for manual undo.adapters.ts— interface changes:previewRestore({ ..., includeProjects }),validateRestore/restore({ ..., includeProjects }); adapters supply the registered-project set fromconfig.src/common/orpc/schemas/backup.ts:BackupProjectImportSchema;preview.output+projectImports+ skipped-project summary;restore.output+skippedProjectFiles;BackupOperationErrorSchema.code+"PROJECT_IMPORT_APPROVAL_REQUIRED"+projectImportsfield.backupService.tspreview/restore: plumbing only; still no import execution.Gate 2: unit tests green; dogfood same-path round trip (Dogfooding §B) — memory restored verbatim for a registered same-path project; unmatched entries listed in preview, nothing written for them.
Phase 3 — Reimport execution (approval → register + re-key)
src/common/orpc/schemas/backup.ts:restore.input+projectImports: z.array(z.object({ token, targetPath })).nullish().backupService.tsProjectService(viaserviceContainer.ts).restore: recompute candidates from the checked-out payload; unknown/stale token or invalid targetPath ⇒ abort before snapshot/mutation (PROJECT_IMPORT_APPROVAL_REQUIREDwith fresh candidates / input error); after core payload restore succeeds: per candidate — re-verify dir exists,projectService.create(targetPath)(tolerate already-registered-at-targetPath), then take the memory mutation lock, re-check conflicts, add-only memory write re-keyed toprojectMemoryDirName(targetPath); record per-candidate results (imported / failed / conflicts); post-snapshot runtime failures never abort the restore.payload.ts: boundedwriteImportedProjectMemory(root, entry, targetDirName, files)usingwriteCheckedFile+ add-only conflict detection, so the write path stays inside the audited module.BackupSection.tsxapi.backup.restore; render per-candidate results (imported/conflict/failed) after restore.src/browser/stories/mocks/orpc.ts+BackupSection.stories.tsx: extend mocks/defaults; story asserting candidates render + approval flow; keep the Phone viewport story pattern.Gate 3: unit + UI tests green; dogfood cross-instance migration (Dogfooding §C) with screenshots + recording.
Phase 4 — Contract language, docs surface, validation
src/common/utils/tools/toolDefinitions.ts:2330,src/common/constants/memory.ts:8–9,24–25,src/node/services/tools/memory.ts:34,src/node/services/memoryService.ts:668–670. (memoryMeta.tslanguage stays — the sidecar genuinely never travels.)make static-check; targeted suites:payload.test.ts,backupService.test.ts,adapters.test.ts,backupService.integration.test.ts(+ new integration case: push with bundle → fresh root → restore with import at a different path → memory present under re-keyed dir, project registered).Testing plan (highlights)
../, hidden names, forbidden basenames, non-memory/project/paths, files outside their entry's memoryDir.parseManifest; core reader ignores the sidecar (simulates old-build restore).ext::,file://, local paths, unknown schemes.includeProjectspersisted + passed to the store; stale/unknown import token ⇒PROJECT_IMPORT_APPROVAL_REQUIREDwith fresh candidates; targetPath preflight (missing dir / non-dir / symlink) fails before snapshot creation; per-candidate failure isolation; result shapes._multi,_scratch,projectKind:"system"); all user projects exported incl. zero-memory; credentialed remote dropped; registered-project set plumbed; snapshot includes project memory only when restore may write project content.testHelpers.ts): push with bundle → fresh root → restore with import at a different path → memory under re-keyed dir, project registered; plus old-reader compat round trip.testsskill conventions; no tautological assertions (behavioral branches only).Dogfooding (evidence: screenshots + recordings + repo-tree listings at each gate)
Setup once:
dev-server-sandboxskill → isolatedXUM_ROOT+ port; create a scratch git project; seedmemory/project/<dir>/notes.md(via memory tool or direct file write matchingprojectMemoryDirName); create a local bare repo (git init --bare /tmp/backup-remote.git) as backup target (integration tests use real local repos, so file-path remotes work).git -C /tmp/backup-remote.git ls-tree -rshowingxum/project-bundle/manifest.json+xum/project-bundle/memory/project/<dir>/notes.mdand an unchanged corexum/manifest.json. Repeat with toggle off → next push's tree lacksproject-bundle/.XUM_ROOT, same backup repo, different project path e.g./tmp/sandbox2/proj-moved): clone the scratch project there manually; Settings → Backup → enable toggle → Preview shows the reimport card; enter target path, approve, Restore. Evidence: screenshots of candidate card + post-restore results; shell listing ofmemory/project/<newDirName>/notes.md; project visible in sidebar.agent-browser record stopcan hang and truncate, a known gotcha); attach all evidence viaattach_file.Acceptance criteria
project-bundle/(all non-system projects as entries, credential-free remotes, only their memory dirs as files) and the core manifest is unchanged in shape — old builds still preview/restore the core payload and ignore the bundle.projectMemoryDirName(targetPath), add-only (conflicts reported, never silently overwritten); import token staleness forces re-approval.INVALID_BACKUP; no partial writes. Foreign-OS full-dir mismatch with a correct hash suffix downgrades to a reimport candidate instead of failing.make static-check, backup unit+integration suites, Storybook stories for BackupSection.Net LoC estimate (product code only)
~950–1,250 net LoC (advisor-reviewed range): settingsBackup schemas + sanitizer ~110, payload.ts (bundle writer/reader/planner/import writes/tokens) ~420, adapters.ts ~110, backupService.ts (imports, locks, plumbing) ~140, oRPC schemas/router ~60, BackupSection.tsx (toggle, candidates, approvals, results) ~180, keybinds/serviceContainer/doc-comments ~30. (Tests/stories/mocks excluded.)
Risks / notes
ProjectService.create→config.editConfig) always completes before the memory mutation lock is taken; the memory lock wraps only file writes + under-lock conflict re-checks. No path ever holds the memory lock while entering config edits.Generated with
xum• Model:anthropic:claude-fable-5• Thinking:xhigh• Cost:$110.60