Thanks to visit codestin.com
Credit goes to github.com

Skip to content

🤖 feat: opt-in project bundle for settings backup - #4043

Merged
ThomasK33 merged 45 commits into
mainfrom
mux-backup-tp1m
Sep 2, 2026
Merged

🤖 feat: opt-in project bundle for settings backup#4043
ThomasK33 merged 45 commits into
mainfrom
mux-backup-tp1m

Conversation

@ThomasK33

Copy link
Copy Markdown
Member

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

  • Sidecar, not core: the bundle lives at <managedPath>/project-bundle/ with its own manifest and byte budget. The core manifest.json never references it, so older builds preview/restore the same backup untouched (they simply ignore the sidecar), and a crafted core manifest still cannot smuggle project-bundle/** paths past the allowlist.
  • Matched restore: a project registered at the recorded path with the locally computed memory dir restores its memory verbatim (previewed, snapshot-covered, under the memory mutation lock).
  • Unmatched → import approval: other projects surface as candidates bound to a sha256 token over the entry metadata + file checksums. Restore re-verifies each token against the checked-out payload; any drift fails with PROJECT_IMPORT_APPROVAL_REQUIRED and fresh candidates. No auto-clone, no auto-register: the user supplies an existing local directory, registration goes through ProjectService, 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.
  • Sanitization: recorded git remotes pass through sanitizeBackupGitRemote (plain https:///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.
  • Safety snapshots additionally capture registered project memory whenever a restore may write project content; project registrations themselves are documented as manual-undo.

Validation

  • 209 unit / 19 integration / 21 UI (jest) tests green; make static-check clean.
  • Live dogfooding across two isolated dev-server sandboxes sharing one bare repo:
    • Export: git ls-tree shows xum/project-bundle/{manifest.json,memory/...} while core xum/manifest.json lists only core files; disabling the toggle removes the bundle from the next push head.
    • Matched restore: locally drifted memory file restored byte-identical (sha256 matches the bundle checksum); safety snapshot preserved the drifted version.
    • Cross-instance reimport: fresh XUM_ROOT rendered the candidate card (name, source path, inert remote), approved import registered the project in config.json and re-keyed memory under the locally computed dir with identical content.

Risks

  • Restore paths touch the memory store and project registration; both are gated (matched: preview + snapshot + lock; unmatched: explicit token-bound approval + add-only writes), so the residual risk is moderate and concentrated in the new bundle code paths. Core backup behavior with the toggle off is unchanged — the bundle is skipped entirely and existing manifests round-trip as before.
  • Old builds pushing to a repo that contains a bundle will drop 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/**, projected preferences.json) to a user-chosen git repo. Project memories (<xumRoot>/memory/project/<name>-<sha256(absPath)[:12]>/) and project entries (config.json projects map) 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):

  • Opt-in toggle includeProjects persisted in backup settings; gates export and restore of project content symmetrically.
  • Export: a project-bundle/ sidecar (own manifest: project entries + memory files + checksums) for config-known user projects only; core manifest untouched for downgrade safety.
  • Restore: verbatim memory restore for projects registered at the identical path on the target; unmatched entries surfaced as per-project import approvals (content-bound token + user-supplied target path → register project + add-only re-keyed memory write).
  • Safety snapshot covers project memory whenever a restore may write it, so memory restores stay undoable.
  • UI: toggle (+ git-history privacy note), "Included" list entry, preview candidates section, per-candidate approval inputs, keybind, stories/mocks.
  • Contract-language updates ("host-local, never committed" → mention opt-in backup).

Out of scope (deferred):

  • Cloning from the recorded remote (we display it; user clones manually and points reimport at the checkout).
  • Syncing project settings (color, customInstructions, trusted, runtime settings) — import hints only: path, name, gitRemote, memoryDir.
  • Fuzzy matching (by remote URL/basename) of payload entries to differently-pathed registered projects — matching is exact-path or explicit user mapping.
  • memory-meta.json (pins/stats) migration — that sidecar stays host-local (already a forbidden basename).
  • Automatic rollback of project registration on failed/undone restores (results list imported projects for manual undo).

Design

Repo layout: sidecar bundle outside the core manifest (downgrade-safe)

Verified code facts driving this: parseManifest runs assertAllowedPayloadPath on every core-manifest path (payload.ts:1611–1614 → 349–356), so an old build hard-fails preview/restore if new paths appear in manifest.json. readBackupPayloadUnchecked reads only manifest-listed files and ignores extra files in the directory (payload.ts:1711–1724). writeBackupPayload wipes the destination dir on every push (payload.ts:1508).

<managedPath>/manifest.json                       # core schema v1 — UNCHANGED, never lists bundle files
<managedPath>/preferences.json, AGENTS.md, ...    # existing core content
<managedPath>/project-bundle/manifest.json        # bundle manifest v1 (projects[] + files[] + checksums)
<managedPath>/project-bundle/memory/project/<dir>/**
  • Old build restore/preview: reads only the core manifest → bundle silently ignored. ✔
  • Old build push: wipes managedPath → bundle dropped from the latest tree (git history retains it; the next new-build push re-adds it). Documented as accepted downgrade behavior.
  • New build export: two-step write — the existing writeBackupPayload (wipe + core files + core manifest) runs unchanged, then a new writeProjectBundle(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 listing project-bundle/** paths is still rejected by parseManifest, so bundle content can only enter through the bundle-validated reader.
  • Since the bundle lives inside 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": "..." }
  ]
}
  • memoryDir validation is host-portableprojectMemoryDirName basename-parses the path, so recomputing it for a foreign-OS source path (Windows backup restored on POSIX) can legitimately differ. Validate instead: memoryDir is a safe single path segment (charset/portability rules), its hash suffix equals sha256(entry.path).slice(0, 12) (pure string hash, host-independent), and every bundle file of the entry lives under memory/project/<memoryDir>/. Restore destinations are always computed locally via projectMemoryDirName(targetPath).

  • Schema BackupProjectBundleSchema in src/common/config/schemas/settingsBackup.ts; entry cap MAX_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, _scratch keys (src/common/constants/{multiProject,scratch}.ts).

  • Bundle parsing/validation runs only when includeProjects is 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) is INVALID_BACKUP, fail closed — never silently skipped.

Matching & reimport rules (restore)

  1. New-build reader (gated on includeProjects): after the core payload read, read + validate project-bundle/manifest.json when present (own budget, bundle-scoped path validator, checksums, per-entry memoryDir validation per the portable rule above).
  2. Matched entry — auto-restore without explicit import only when a registered target project exists at exactly entry.path and projectMemoryDirName(entry.path) === entry.memoryDir on this host → bundle memory files restore verbatim to <muxRoot>/memory/project/<entry.memoryDir>/** (overwrite allowed; these writes appear in restore preview changes; 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.
  3. Unmatched entry — becomes BackupProjectImport { sourcePath, name, gitRemote?, memoryFileCount, token } where token = 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 ⇒ error PROJECT_IMPORT_APPROVAL_REQUIRED carrying fresh candidates (mirrors the COMMAND_APPROVAL_REQUIRED round trip).
  4. Failure semantics: stale/unknown tokens and invalid 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.
  5. Import execution (projectImports: [{ token, targetPath }] on restore input), per candidate, sequenced to avoid lock nesting: re-verify the directory exists (ProjectService.create would otherwise mkdir) → register via ProjectService.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 to memory/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).
  6. All bundle memory writes (matched verbatim + imports) run under withTargetMutationLock(muxRoot, memoryMutationLockKey(...)) — the same lock MemoryService mutations take (memoryService.ts:556–566) — so backup never becomes an uncoordinated memory writer. Never call ProjectService.create/config.editConfig while holding the memory mutation lock (registration completes before the lock is taken).
  7. Project registration is a config mutation the safety snapshot does NOT revert. The restore result lists importedProjects (+ per-file conflicts/skips) so the user can undo manually.
  8. Entries the user did not approve, and bundle content when includeProjects is off, are skipped (never written) and reported. Toggle-off reporting is existence-only ("project bundle present but disabled" via a project-bundle/manifest.json existence 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)

  • Validate entry.memoryDir: safe single segment, hash suffix === sha256(entry.path).slice(0,12), all entry files under memory/project/<memoryDir>/; reject bundle otherwise (INVALID_BACKUP). (Not full projectMemoryDirName equality — that recomputation is not host-portable; destinations are always locally computed.)
  • Core allowlist unchanged: a core manifest listing project-bundle/** is still rejected by parseManifest, so bundle content cannot bypass bundle validation via the core restore path.
  • entry.path is display/matching data only; all filesystem writes derive from locally computed dir names, through the existing writeCheckedFile/resolveContainedPath containment path.
  • Import tokens bind the full normalized entry + content hashes, not just identity — no approve-then-swap of files or metadata.
  • No auto-clone/auto-register: imports require explicit per-entry token + targetPath from the user.
  • Export drops remotes that fail hasUrlCredentials or aren't plain http(s)/ssh/scp-like git@host: shapes (no ext::, no file://, 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).
  • Existing limits apply to the bundle via its own budget (8MB/file, 64MB, 4096 files/dirs, depth 24, hidden-name + forbidden-basename filters, hard-link tracker, Windows-portability + collision checks).

Implementation phases

Phase 1 — Schemas + opt-in setting + toggle UI + export sidecar

  1. src/common/config/schemas/settingsBackup.ts
    • SettingsBackupInputSchema + includeProjects: z.boolean().optional() (lines ~205–225) — flows through all existing backup endpoints automatically and is persisted by persistSettings.
    • Add BackupProjectBundleSchema (+ entry schema, MAX_BACKUP_PROJECT_ENTRIES = 256), plus a remote-URL sanitizer helper (allowed shapes only; reuses hasUrlCredentials).
  2. src/node/services/backup/payload.ts (or a sibling projectBundle.ts module reusing its internals)
    • New bundle pipeline, fully separate from the core payload: collectProjectBundle(muxRoot, entries) (per-entry memory/project/<dirName>/** collection reusing collectDirectory machinery — never a blind memory/project sweep, so orphaned dirs of deleted projects stay local), writeProjectBundle(dir, bundle), bundle-scoped path validator (only manifest.json + memory/project/.+, same segment/hidden/forbidden/portability rules), own budget instance.
    • Core createBackupPayload/writeBackupPayload/isAllowedPayloadPath stay unchanged; export composes: core write, then bundle write into <managedPath>/project-bundle.
    • Run scanBackupFilesForSecrets over bundle files with recursive-collection semantics (extend isRecursivelyCollected handling to the bundle scan).
  3. src/node/services/backup/adapters.ts
    • Interface change: BackupPayloadStore.exportTo({ repositoryRoot, managedPath, includeProjects }); backupService passes the persisted setting explicitly.
    • When includeProjects: read config.projects, filter system projects, compute memoryDir per project via projectMemoryDirName, fetch origin URL per project (small execFile("git", ["-C", path, "remote", "get-url", "origin"]) helper, best-effort, sanitizer-filtered), build entries for all user projects.
  4. src/node/services/backup/backupService.ts: normalizeBackupSettings carries includeProjects; push/preview/restore pass it to the payload store.
  5. Frontend toggle (needed for Gate 1 dogfooding): BackupSection.tsx opt-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." Keybind SETTINGS_BACKUP_TOGGLE_PROJECTS (Ctrl+Alt+P) in keybinds.ts + KeybindsSection.tsx + actionsRef handler (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

  1. payload.ts / projectBundle.ts
    • Bundle reader beside readBackupPayload, invoked only when includeProjects: parse/validate project-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.
    • Restore planning: partition bundle entries into matched (write plan) / import-candidate / skipped using a caller-provided registered-project set (path → dirName); matched writes execute under the memory mutation lock; token computation for candidates.
    • Safety snapshot: only when the incoming restore may write project content (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.
  2. adapters.tsinterface changes: previewRestore({ ..., includeProjects }), validateRestore/restore({ ..., includeProjects }); adapters supply the registered-project set from config.
  3. src/common/orpc/schemas/backup.ts: BackupProjectImportSchema; preview.output + projectImports + skipped-project summary; restore.output + skippedProjectFiles; BackupOperationErrorSchema.code + "PROJECT_IMPORT_APPROVAL_REQUIRED" + projectImports field.
  4. backupService.ts preview/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)

  1. src/common/orpc/schemas/backup.ts: restore.input + projectImports: z.array(z.object({ token, targetPath })).nullish().
  2. backupService.ts
    • Inject ProjectService (via serviceContainer.ts).
    • restore: recompute candidates from the checked-out payload; unknown/stale token or invalid targetPath ⇒ abort before snapshot/mutation (PROJECT_IMPORT_APPROVAL_REQUIRED with 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 to projectMemoryDirName(targetPath); record per-candidate results (imported / failed / conflicts); post-snapshot runtime failures never abort the restore.
  3. payload.ts: bounded writeImportedProjectMemory(root, entry, targetDirName, files) using writeCheckedFile + add-only conflict detection, so the write path stays inside the audited module.
  4. Frontend BackupSection.tsx
    • Preview section: "Projects to reimport" cards (name, source path, remote as inert text, memory file count).
    • Restore approvals: per-candidate checkbox + target-path text input (prefilled with source path), following the MCP command-approval block pattern (lines 766–794); tokens+paths passed in api.backup.restore; render per-candidate results (imported/conflict/failed) after restore.
  5. 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

  1. Update "host-local, never committed" phrasing to note the opt-in backup: 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.ts language stays — the sidecar genuinely never travels.)
  2. Storybook test-runner pass for changed stories; 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)

  • payload.test.ts:
    • Bundle serialization; entry cap; checksum rejection; memoryDir validation (unsafe segment or wrong hash suffix ⇒ rejected; foreign-OS full-dir mismatch with correct hash suffix ⇒ reimport candidate, not rejected and not auto-restored — e.g. Windows-style source path restored on POSIX); bundle path allowlist rejects ../, hidden names, forbidden basenames, non-memory/project/ paths, files outside their entry's memoryDir.
    • Compat: bundle-bearing export's core manifest lists no bundle paths and parses under parseManifest; core reader ignores the sidecar (simulates old-build restore).
    • Matched/unmatched partition; unmatched + unapproved never in the write plan; toggle-off export writes no bundle (and a re-export removes a previously pushed bundle from the tree); toggle-off restore skips bundle content — including a malformed sidecar, which must not block a core-only restore.
    • Token stability + staleness: changing one memory file's content invalidates the entry token.
    • Import writes: containment, add-only conflict detection (existing differing file skipped + reported; identical file OK), zero-memory entry imports as registration-only.
    • Remote sanitizer: drops credentialed URLs, ext::, file://, local paths, unknown schemes.
    • Scanner treats bundle memory as recursively-collected (non-doc credential-ish filenames flagged).
  • backupService.test.ts: includeProjects persisted + passed to the store; stale/unknown import token ⇒ PROJECT_IMPORT_APPROVAL_REQUIRED with fresh candidates; targetPath preflight (missing dir / non-dir / symlink) fails before snapshot creation; per-candidate failure isolation; result shapes.
  • adapters.test.ts: system-project filtering (_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.
  • integration: full round trip incl. different-path reimport (real git repos via 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.
  • Follow tests skill conventions; no tautological assertions (behavioral branches only).

Dogfooding (evidence: screenshots + recordings + repo-tree listings at each gate)

Setup once: dev-server-sandbox skill → isolated XUM_ROOT + port; create a scratch git project; seed memory/project/<dir>/notes.md (via memory tool or direct file write matching projectMemoryDirName); 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).

  • §A (Gate 1): In Settings → Backup (agent-browser): enable toggle, save, "Back up now". Evidence: screenshot of toggle + Included list + privacy copy; git -C /tmp/backup-remote.git ls-tree -r showing xum/project-bundle/manifest.json + xum/project-bundle/memory/project/<dir>/notes.md and an unchanged core xum/manifest.json. Repeat with toggle off → next push's tree lacks project-bundle/.
  • §B (Gate 2): Same sandbox, edit local memory file, "Preview changes" → restore changes list the memory file; "Restore" → file content reverted. Screenshot of preview grid incl. the "Projects to reimport" section for an unmatched entry.
  • §C (Gate 3): Second sandbox (fresh 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 of memory/project/<newDirName>/notes.md; project visible in sidebar.
  • Record a short agent-browser video of the §C flow (screenshots remain primary evidence — agent-browser record stop can hang and truncate, a known gotcha); attach all evidence via attach_file.

Acceptance criteria

  1. Toggle off (default): export byte-identical to today; restoring a bundle-bearing payload skips project content with a visible report.
  2. Toggle on: export adds 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.
  3. Disabling the toggle removes the bundle from the next pushed tree; UI states git history retains earlier pushes.
  4. Same-path registered project: memory restored verbatim (previewed); config untouched.
  5. Unmatched project: nothing written without explicit approval; approving with a target path registers the project and lands memory under projectMemoryDirName(targetPath), add-only (conflicts reported, never silently overwritten); import token staleness forces re-approval.
  6. Malicious bundle (unsafe memoryDir segment, wrong hash suffix, traversal, checksum mismatch, files outside their entry dir, oversized, >256 entries) ⇒ INVALID_BACKUP; no partial writes. Foreign-OS full-dir mismatch with a correct hash suffix downgrades to a reimport candidate instead of failing.
  7. Safety snapshot captures the pre-restore memory files of registered projects whenever project content may be written; project registration and import-created files at previously unregistered targets are not auto-reverted (results list them for manual undo), and the UI says so.
  8. All validation green: 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

  • Old-build push drops the bundle from the latest tree (destination wipe, payload.ts:1508); git history retains it and the next new-build push re-adds it. Accepted + documented; no data destruction.
  • Bundle uses its own budget so it cannot block core-only restores/snapshots; extremely memory-heavy setups (>4096 bundle files) fail export with an explicit error — acceptable v1.
  • Import ordering: registration before memory write, so a crash leaves a registered project without memory (benign) rather than orphaned live memory.
  • Lock discipline: registration (ProjectService.createconfig.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.
  • Windows-unportable memory filenames fail export with a clear error (same as skills today).
  • Project registration is not reverted by the safety snapshot (explicit in results/UI); full import rollback is deliberately deferred.

Generated with xum • Model: anthropic:claude-fable-5 • Thinking: xhigh • Cost: $110.60

@chatgpt-codex-connector

This comment has been minimized.

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

This comment has been minimized.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 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".

Comment thread src/node/services/backup/adapters.ts Outdated
Comment thread src/node/services/backup/backupService.ts Outdated
Comment thread src/node/services/backup/backupService.ts Outdated
Comment thread src/node/services/backup/adapters.ts Outdated
Comment thread src/node/services/backup/backupService.ts
Comment thread src/common/config/schemas/settingsBackup.ts Outdated
Comment thread src/node/services/backup/backupService.ts Outdated
Comment thread src/node/services/backup/adapters.ts Outdated
Comment thread src/node/services/backup/payload.ts
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

This comment has been minimized.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 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".

Comment thread src/browser/features/Settings/Sections/BackupSection.tsx Outdated
Comment thread src/node/services/backup/payload.ts Outdated
Comment thread src/node/services/backup/adapters.ts Outdated
Comment thread src/node/services/backup/backupService.ts Outdated
Comment thread src/node/services/backup/payload.ts Outdated
Comment thread src/node/services/backup/adapters.ts Outdated
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

This comment has been minimized.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 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".

Comment thread src/node/services/backup/backupService.ts Outdated
Comment thread src/node/services/backup/payload.ts Outdated
Comment thread src/node/services/backup/payload.ts
Comment thread src/node/services/backup/adapters.ts Outdated
Comment thread src/node/services/backup/backupService.ts Outdated
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

This comment has been minimized.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 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".

Comment thread src/node/services/backup/payload.ts
Comment thread src/node/services/backup/backupService.ts Outdated
Comment thread src/node/services/backup/adapters.ts Outdated
Comment thread src/node/services/memoryService.ts Outdated
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

This comment has been minimized.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 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".

Comment thread src/node/services/backup/adapters.ts Outdated
Comment thread src/node/services/backup/adapters.ts Outdated
Comment thread src/node/services/backup/adapters.ts Outdated
Comment thread src/browser/features/Settings/Sections/BackupSection.tsx
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex 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.

Comment thread src/common/config/schemas/settingsBackup.ts Outdated

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 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".

Comment thread src/node/services/backup/adapters.ts Outdated
Comment thread src/node/services/backup/backupService.ts Outdated
Comment thread src/node/services/backup/payload.ts Outdated
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 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".

Comment thread src/node/services/backup/payload.ts Outdated
Comment thread src/node/services/backup/backupService.ts Outdated
Comment thread src/browser/features/Settings/Sections/BackupSection.tsx Outdated
@chatgpt-codex-connector

This comment has been minimized.

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

This comment has been minimized.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 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".

Comment thread src/common/config/schemas/settingsBackup.ts
Comment thread src/node/services/backup/backupService.ts Outdated
Comment thread src/node/services/backup/adapters.ts
Comment thread src/browser/features/Settings/Sections/BackupSection.stories.tsx
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

This comment has been minimized.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 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".

Comment thread src/node/services/backup/backupService.ts Outdated
Comment thread src/node/services/backup/backupService.ts Outdated
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

This comment has been minimized.

@chatgpt-codex-connector

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.
@ThomasK33

ThomasK33 commented Sep 2, 2026

Copy link
Copy Markdown
Member Author

@codex review

Round 39 (2a04bfb): remote hints are bound to the config file's write generation (inode/mtime/size, changed by every save), so an identical-config re-registration during discovery also drops them.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 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".

Comment thread src/node/config/index.ts Outdated
@chatgpt-codex-connector

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.
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

Round 40 (cd6b392): every config save stamps a random writeId; the write generation combines it with the stat tuple so identical-content re-registrations are always distinguishable.

@chatgpt-codex-connector

This comment has been minimized.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 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".

Comment thread src/node/services/tools/xum_config_write.ts
Comment thread src/node/services/backup/backupService.ts Outdated
…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.
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

Round 41 (33e88c9): the config tool's rewrites refresh the write stamp via the shared writer; approved import targets are pinned by an open directory handle from planning through import, closing the inode-reuse ABA.

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.
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

Round 41 follow-up (673ebd9): test-only typecheck fix on top of 33e88c9 (typed casts in two root-repair assertions now include writeId). No product code changed since the previous trigger.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. More of your lovely PRs please.

Reviewed commit: 673ebd9bde

ℹ️ About Codex in GitHub

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

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

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector

This comment has been minimized.

@ThomasK33
ThomasK33 added this pull request to the merge queue Sep 2, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Sep 2, 2026
@ThomasK33
ThomasK33 added this pull request to the merge queue Sep 2, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Sep 2, 2026
@ThomasK33
ThomasK33 added this pull request to the merge queue Sep 2, 2026
Merged via the queue into main with commit d813fe1 Sep 2, 2026
35 of 38 checks passed
@ThomasK33
ThomasK33 deleted the mux-backup-tp1m branch September 2, 2026 13:19
asm pushed a commit to asm/mux that referenced this pull request Sep 2, 2026
…WiringLive; thin ServiceContainer; StreamManager runner param (coder#4061)

## Summary

Effect migration Phase 11, PR 5 of 6. **The desktop-only tail of the
service graph now builds as Effect Layers too**: six
`Layer.effectContext` **group layers** (`BrowserLive` ·
`DesktopBridgeLive` · `TerminalEditorLive` · `MiscDesktopLive` →
`OauthLive` · `WorkersLive`) construct the ~40 remaining desktop
services with their existing argument lists, `DesktopWiringLive` replays
the former `ServiceContainer` constructor's setter / listener /
global-registration statements **verbatim and in order** after
`CoreLive`, and the `ServiceContainer` constructor shrinks to
`makeAppRuntime(AppLive(stores))` plus field assignment from the built
context (`toORPCContext()` unchanged in shape;
`initialize()`/`dispose()`/`shutdown()` untouched, §5 order fixed).
`StreamManager` gains an optional trailing `runner: EffectRunner`: its
partial-write debounce forks through it and `AgentSession` hands the
same runner to its `RetryManager`, so both sleep on the app runtime's
`Clock` (a `TestClock` in tests).

Stacked on PR 1 #4049, PR 2 #4050, PR 3 #4051, PR 4a #4054, PR 4b #4057.
Plan: `<details>` at the bottom (§2.1/§2.2, §2.3 invariants, §3 "PR 5",
§5, §7).

## Implementation

- **`di/tags.ts`** — 47 new `Context.Service` tags (type-only imports;
§2.1 naming; `Tag` suffix where the bare name is not a `*Service` class
or would shadow — `WindowTag`, `QuickJSRuntimeFactoryTag`,
`DesktopSessionManagerTag`, …), grouped as `BrowserTags |
DesktopBridgeTags | TerminalEditorTags | MiscDesktopTags | OauthTags |
WorkerTags = DesktopTags`; `AppTags = CoreRootTags | CrossCuttingTags |
DesktopTags`.
- **`di/layers/desktop.ts`** — six `Layer.effectContext` group layers
(each yields its inputs, constructs several services in the
constructor's original order, returns a `Context`; the `R` annotation on
each group *is* its declared dependency set); `DesktopWiringLive`
(`Layer.effectDiscard`, yields every collaborator first, then the wiring
statements); composition below.
- **`di/layers/app.ts`** — `AppLive = DesktopLive ▹ CoreLive ▹
CoreOptionsFromDesktopLive ▹ CrossCuttingLive ▹ MemoryMetaLive ▹
runtimeSeams`. **`core.ts`** — `StreamManagerLive` passes `yield*
EffectRunnerTag` (5th ctor arg); `CoreInputTags` gains
`EffectRunnerTag`.
- **`serviceContainer.ts`** — constructor = build + 66 `this.x =
get(Tag)` lines; service imports type-only; the never-read private
`ptyService` field is gone (the PTY lives in the graph under `PTY` for
`TerminalService`). `initialize()`, `toORPCContext()`, `shutdown()`,
`dispose()` byte-identical.
- **`streamManager.ts`** — `constructor(…, eventSink = () => undefined,
runner: EffectRunner = defaultEffectRunner)`, `public readonly
effectRunner`; `schedulePartialWrite`'s `runSync(forkIn)`/`runFork` and
`interruptPartialWriteFiber`'s `runFork(Fiber.interrupt)` go through it;
both `Scope.close` sites stay `Effect.runFork` (async-close precedent).
**`agentSession.ts`/`retryManager.ts`** — `AgentSessionStreamManager`
gains `readonly effectRunner?: EffectRunner`; `new RetryManager(…,
this.streamManager.effectRunner)` (undefined → RetryManager's default,
so doubles and the `aiService` fallback are unchanged).
- **Tests** — `serviceContainer.test.ts`: exhaustive `Record<keyof
Omit<ORPCContext, "headers" | "effect/context" | "effect/wrap">, Tag>`
identity test (a new ORPC field without a tag fails to compile), a
desktop-wiring behavioral test (bindings, every `set*` collaborator,
idle-compaction outcome forwarding, SSH-prompt global registration
observed via `isInteractiveHostKeyApprovalAvailable()`, a timing
listener), and a `dispose()`/`shutdown()` order test via spies on the
public methods already spied today; the original assertions are
unchanged. `streamManager.test.ts`: the debounce fires on the injected
runner's `TestClock` (red-checked against the global-runtime fork).

## PR 5 notes

### Group DAG (re-derived from the constructor argument lists)

| Group | Services, in the former constructor's order | Declared
requirements (`R`) |
|---|---|---|
| `BrowserLive` | BrowserBridgeTokenManager →
AgentBrowserSessionDiscovery → BrowserControl → BrowserSessionStateHub →
BrowserBridgeServer | Config |
| `DesktopBridgeLive` | DesktopSessionManager → DesktopTokenManager →
DesktopBridgeServer | Config, Experiments, Workspace |
| `TerminalEditorLive` | PTY → Terminal → Editor → Tokenizer →
Instructions | Config, SecretsStore, Workspace, SessionUsage, AI,
Provider |
| `MiscDesktopLive` | QuickJSRuntimeFactory, SshPrompt, **Window**,
Backup, AgentPluginInstall, Project (needs SshPrompt), Update, Server,
MenuEvent, Voice, Coder (singleton), ServerAuth,
WorkspaceLifecycleHooks, WorktreeArchiveSnapshot | Config, SecretsStore,
ProvidersConfigStore, Experiments, Policy, Provider, MCPServerManager,
WorkspaceMcpOverrides |
| `OauthLive` | McpOauth → MuxGatewayOauth → MuxGovernorOauth →
CodexOauth → CoderOauth → CopilotOauth | Config, ProvidersConfigStore,
FileLeaseManager, MCPConfig, Provider, Policy, Telemetry, **Window** |
| `WorkersLive` | IdleCompaction → Heartbeat → Timeline → Refine (needs
Timeline) → AgentStatus (needs Tokenizer, Window) | Config,
**EffectRunner**, Experiments, History, ExtensionMetadata, Workspace,
Task, IdleDispatcher, Memory, MemoryMeta, AI, SessionUsage,
**Tokenizer**, **Window** |
| `DesktopWiringLive` | the former constructor's 12 wiring blocks,
verbatim, in order — incl. #4043's
`backupService.setProjectService/setMemoryNotifier` after the
`projectService.set*` lines (rebased) — runs after `CoreLive`, so core
listeners still precede desktop ones | Config, CrossCuttingTags,
CoreTags, DesktopTags |

```
DesktopBase  = Layer.mergeAll(MiscDesktopLive, BrowserLive, DesktopBridgeLive, TerminalEditorLive)   // true siblings
DesktopUpper = Layer.mergeAll(OauthLive, WorkersLive).pipe(Layer.provideMerge(DesktopBase))          // both need Base
DesktopLive  = DesktopWiringLive.pipe(Layer.provideMerge(DesktopUpper))
AppLive      = DesktopLive ▹ CoreLive ▹ CoreOptionsFromDesktopLive ▹ CrossCuttingLive ▹ MemoryMetaLive ▹ (AppFiberScope ▹ EffectRunner ▹ Stores)
```

"True siblings" was checked constructor by constructor (I6 table): no
base-group constructor takes or calls another desktop service, so their
relative build order is a don't-care. The two upper groups' edges
(`WindowService`, `TokenizerService`) are the only cross-group
dependencies and are expressed with `provideMerge`, never with
`mergeAll` argument order.

### I6 constructor side-effect audit (38 constructions moved here; full
38-row table with file:line cites in the first PR comment)

| Group | Constructors (args exactly as the former constructor passed
them) | Beyond capturing args | Order that matters |
|---|---|---|---|
| Browser | `BrowserBridgeTokenManager()` ·
`AgentBrowserSessionDiscoveryService({resolveWorkspaceCandidatePathsFn})`
· `BrowserControlService({discovery, resolveSessionEnvFn})` ·
`BrowserSessionStateHub({control})` · `BrowserBridgeServer({discovery,
tokenManager, stateHub})` | token manager: own unref'd cleanup
`setInterval` (as before); bridge server: unattached
`WebSocketServer({noServer})` | intra-group arg order only |
| DesktopBridge | `DesktopSessionManager({config, experimentsService,
workspaceService})` · `DesktopTokenManager()` ·
`DesktopBridgeServer({sessionManager, tokenManager})` | token manager:
own unref'd cleanup `setInterval` (as before) | intra-group arg order;
core `Workspace` (staging) |
| TerminalEditor | `PTYService()` · `TerminalService(config, pty,
secretsStore)` · `EditorService(config, workspaceService)` ·
`TokenizerService(sessionUsage, ai, provider)` ·
`InstructionsService(config, ai, tokenizer)` | none (the tokenizer
*worker* is created at `workerPool.ts` import time, not by the ctor —
see Observations) | intra-group arg order |
| Misc | `QuickJSRuntimeFactory()` · `SshPromptService()` ·
`WindowService()` · `BackupService(config, {gitRepo, payload})` ·
`AgentPluginInstallService(config, {isEnabled, mcpServerManager,
workspaceMcpOverridesService})` · `ProjectService(config, sshPrompt,
secretsStore)` · `UpdateService(config)` · `ServerService()` ·
`MenuEventService()` · `VoiceService(config, provider, policy,
providersStore)` · `coderService` · `ServerAuthService(config)` ·
`WorkspaceLifecycleHooks()` · `WorktreeArchiveSnapshotService(config)` |
`AgentPluginInstallService`: un-awaited startup journal reconcile +
module-level discovery gate (as before, from its own args);
`UpdateService`: `config.getUpdateChannel()` + un-awaited `initialize()`
(no-op outside Electron) | `Project` after `SshPrompt` (same group, in
order); `AgentPluginInstall` after core (staging) |
| OAuth | `McpOauthService(config, mcpConfig, window, telemetry)` ·
`MuxGatewayOauthService(providersStore, provider, window)` ·
`MuxGovernorOauthService(config, window, policy)` ·
`CodexOauthService(providersStore, provider, window)` ·
`CoderOauthService(providersStore, fileLeaseManager, provider, window,
policy)` · `CopilotOauthService(provider, window)` |
**`CoderOauthService` subscribes `providerService.onConfigChanged`**
(`coderOauthService.ts:373`) — a declared *core* dependency;
`AIService`'s own subscription (S3) still precedes it because every
desktop group builds above `CoreLive`; no other desktop ctor subscribes
to `providerService` | after Misc (`WindowService`) via `OauthLive ▹
DesktopBase` |
| Workers | `IdleCompactionService(config, history, extensionMetadata,
executeIdleCompaction, runner)` · `HeartbeatService(config,
extensionMetadata, workspace, task, idleDispatcher, runner)` ·
`TimelineService(config, history, experiments)` · `RefineService(config,
memory, memoryMeta, history, ai, experiments, {timeline, sessionUsage,
emitChatMessage, acquireTurnExclusion})` · `AgentStatusService(config,
history, tokenizer, extensionMetadata, workspace, window, ai,
{sessionUsage, requestAnalyticsIngest})` | none (scopes/fibers/intervals
start in `start()`; `subscribeToWorkspace` is a wiring statement) |
`Refine` after `Timeline` (same group); after Misc (`Window`) +
TerminalEditor (`Tokenizer`) via `WorkersLive ▹ DesktopBase` |

No moved constructor reads a setter-provided collaborator or registers
listeners on
`workspaceService`/`aiService`/`taskService`/`memoryConsolidationService`/`mcpServerManager`
(only `CoderOauthService` → `providerService`, above). Wiring statements
that used to sit *between* constructions now run after all of them; none
of the constructors that followed them read the wired state, so the
observable order of effects is unchanged.

### Split decision

Raw product diff is +1149 / −517 (8 files), above the plan's ~600-line
heuristic; I evaluated a 5a/5b split along group boundaries and kept one
PR: the surface is mechanical relocation (~330 wiring + ~250
constructor-call lines moved verbatim, 184 lines of tags — `git diff
--color-moved=dimmed-zebra origin/main --
src/node/services/serviceContainer.ts
src/node/services/di/layers/desktop.ts` dims them), a mergeable 5a would
need a throwaway hybrid constructor, and the wiring move would still
land whole in one half.

### Deviations from the plan / observations

- **`RetryManager` site.** The plan places the runner hand-off in
`streamManager.ts`; the constructor is in `agentSession.ts` — reached
via the optional `AgentSessionStreamManager.effectRunner` field.
- **Tokenizer worker starts ~1 s later in `xum server`/ACP (not
desktop).** `workerPool.ts` creates the tokenizer `Worker` at import
time; `main` reached it via `serviceContainer.ts`'s early
`TokenizerService` import (~0.9 s after spawn, before `effect`/`di/*`
loaded), now via `core.ts` → `aiService` → `historyService` →
`tokenizer` (~1.9 s). Total startup is unchanged (spawn → `initialize
completed` ≈ 2.55 s on both) and the desktop is unaffected
(`desktop/main.ts` imports `tokenizer` first), but a SIGTERM *during*
the worker's ≈19 s encoding load waits for its current module evaluation
on both trees (so a "1 s after init" probe read 0.6 s vs 1.6 s — strace:
the gap sits between `exit(0)` and the worker thread's exit, not in
`dispose()`, which is 70–120 ms on both). Steady-state shutdown is
unchanged (table). Left as is: pre-existing import-time worker creation,
unrelated to the composition root; an explicit warm-up call site is a
follow-up candidate, not a bug.
- `DesktopWiringLive` is a `Layer.effectDiscard` over an `Effect.gen`
body whose only yields are service tags (synchronous); no finalizers, no
forks (I5) — same shape as `CoreWiringLive`.

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

1. **Interruption posture** — moved forks:
`StreamManager.schedulePartialWrite` (`runner.runSync(forkIn(…,
resourceScope))` / whitebox `runner.runFork`) and
`interruptPartialWriteFiber` (`runner.runFork(Fiber.interrupt)`) —
unsupervised through the runner exactly as through the global runtime;
interrupted by the stream's resource-scope close and by re-arm,
unchanged. `RetryManager` forks through the injected runner; cancelled
by `cancel()`/`dispose()` as before. No forks in `di/layers/`.
2. **Uninterruptible teardown** — `dispose()`/`shutdown()` bodies
byte-identical; the §5 order is now asserted.
3. **No defect escapes** — no new Promise facades; `makeAppRuntime`
stays the one throw site (throwing-layer test passes through the deeper
graph).
4. **Spy-seam check** — `rg 'spyOn\('
src/node/services/serviceContainer.test.ts tests/ipc tests/ui`: every
target is a public method on an instance the container still exposes →
intercepted, since each field *is* the context instance (identity test).
Arity: only `StreamManager` gained a trailing optional param;
`AgentSessionStreamManager` gained an optional readonly field. Typecheck
of every test proves it.
5. **Sync-start** — the new debounce test pins that `partialWriteFiber`
is armed synchronously and fires on `TestClock.adjust`.
6. **I6** — table above. 7. **I3** — `memoryConsolidationService.ts` not
in the diff.

### Re-recorded gate numbers (R7/R8)

Sibling worktrees under one scratch dir with shared `node_modules`,
interleaved runs; `origin/main` = `1c81235c1` (4b) vs this branch. Host:
96 cores, CPU PSI `some avg60` ≈ 39–41 %.

| metric | origin/main (4b) | branch (PR 5) | note |
|---|---|---|---|
| `tsgo --noEmit` wall, 3 interleaved pairs (median, min–max) | 12.01 s
(11.47–14.04) | 11.94 s (11.82–14.71) | flat |
| `tsgo --extendedDiagnostics` (renderer) | types 1 930 432 · check
10.50 s | types 1 934 836 (+0.23 %) · check 9.43 s | noise |
| `new ServiceContainer(stores)` in-process, 3 runs × 15: **cold**
(first) median | 27 ms (24–29) | **35 ms** (35–44) | **+≈8 ms cold** —
Layer first-use for 7 more layers + 3 composition nodes (trend 12 → 18 →
23 → 27 → 35 ms across PR 3/4a/4b/main/PR 5; `main`'s 27 includes the
imperative desktop constructor) |
| … **warm** median (min) | 1.87 ms (1.11) | 2.18 ms (1.65) | +≈0.3 ms |
| `[startup] AppRuntime built` in `xum server` (10 runs) | 11 ms (core
only) | 16 ms (whole graph) | not comparable (main excludes the desktop
ctor) |
| spawn → `AppRuntime built` / → `initialize completed` (3 pairs) | 2.35
s / 2.55 s | 2.32 s / 2.55 s | unchanged |
| `ServiceContainer.initialize completed { totalMs }` (10 runs) | 245
(215–336) | 243 (215–279) | unchanged code |
| SIGTERM → exit, steady state (25 s after init; 5 pairs) | 151 ms
(134–185), exit 0 ×5 | 169 ms (149–179), exit 0 ×5 | noise; `[shutdown]
AppFiberScope closed` → explicit steps → `[shutdown] AppRuntime
disposed` in every transcript |

A chained (`provideMerge`-only) composition of the same six groups costs
the same (30–42 / 2.1–3.0 ms): the +8 ms is Effect first-use, not
sibling concurrency.

### Lessons for PR 6 (TestClock sweep + shutdown hardening + DI contract
docs)

- `StreamManager` takes a runner now: the partial-write debounce cases
in `streamManager.test.ts` can move to `makeTestEffectRunner()` (5th
ctor arg; the new test is the template); `RetryManager` gets its runner
from `streamManager.effectRunner`, so `agentSession` harness tests can
inject a TestClock through a stream-manager double.
- The tokenizer worker is created at import time (`workerPool.ts`);
`[shutdown]` timing probes must wait for its ≈19 s load or they measure
its module-evaluation tail — PR 6's per-step `[shutdown]` lines will
expose the `AppRuntime disposed` → `process.exit` gap.
- `Record<keyof Omit<ORPCContext, …>, Tag>` is the ORPC exhaustiveness
guard (exclude `effect/wrap` with `headers`/`effect/context`). Six group
layers + three composition nodes cost +8 ms cold / +0.3 ms warm — record
the per-layer first-use cost in the contract doc.

## Validation

- `make static-check` green. `bun test` gate (`streamManager*`,
`aiService`, `serviceContainer`, `coreServicesRoot`, `di/*`,
`retryManager`, `heartbeat`, `idleCompaction`, `cli/server`, `cli/cli`)
375/375; all 26 `agentSession*` suites 279/279; `bun test
src/node/services src/cli src/node/orpc src/node/acp` 7057 pass / 15
fail — the known host baselines (taskGitPatchEngine ×2,
WorkspaceTurnManager ×2, agent_skill_delete, BackupRepoCache ×9) + one
`attachmentService.completedReports` flake that passes in isolation on
both trees.
- `TEST_INTEGRATION=1 bun x jest tests` (tests/ipc + tests/ui): 669 pass
/ 77 fail / 49 skipped — all environment baselines: provider-backed
suites (`403 Forbidden` from the AI bridge / missing xAI key),
SSH/Docker rows, four `src/**/__tests__` bun:test files jest picks up,
and `terminal.test.ts` (1) · `sendModeDropdown.test.ts` (1) ·
`reportRelocation.test.ts` (1), which fail identically on **pristine
`origin/main`** (re-run in the foreground in the sibling worktree). CI
is the lane for the provider suites.
- **Dogfooding** (headless Coder host, `XUM_LOG_LEVEL=debug
DEV_SERVER_SANDBOX_ARGS=--clean-projects make dev-server-sandbox`): log
order `AppRuntime built { ms: 24 }` → `initialize starting` → six step
durations → `initialize completed { totalMs: 233 }`; no `ManagedRuntime
disposed`/defect lines. agent-browser: loaded the app
(`v0.28.3-nightly.148-28-g909dadd90`), added a scratch git repo as a
project, sent "Reply with exactly the single word: pong" → worktree
workspace created, model replied `pong`, Stats tab populated
(screenshot). **oRPC Effect path:** memory experiment enabled,
`memory.save` → `setPinned true` → `list` (`pinned: true`) → `setPinned
false` over `/orpc` (all `handlerGen` + runtime `effect/context`), then
pinned/unpinned from the Memory tab; `memory-meta.json` flipped `pinned`
true → false (screenshot). **Graceful quit:** SIGTERM → `[shutdown]
AppFiberScope closed { ms: 1 }` → `AgentStatusService stopped` →
`terminateAll()` → `[analytics-worker] Shutting down, closing DuckDB` →
`[shutdown] AppRuntime disposed { ms: 7 }` → nodemon `clean exit`, 191
ms, exit 0; plus the 5 steady-state pairs in the table (exit 0 ×10). Not
exercised headless: Electron `before-quit` (same `dispose()`;
`tests/e2e` in CI).

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

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


https://github.com/user-attachments/assets/0d3e4c76-82d2-4344-80ae-b1dba493ecaf

## Risks

- **Low–medium.** The one behavioral surface is the wiring relocation:
every statement is verbatim and in order, the constructors that used to
run between wiring lines are audited as not observing them (I6), the
`tests/ipc` behavioral gate matches pristine `main`, and the desktop
wiring test pins each collaborator edge. `dispose()`/`shutdown()` are
unchanged and their order is asserted.
- Startup: +≈8 ms cold construction; `initialize()` unchanged;
tokenizer-worker import-order shift in `xum server`/ACP (observation
above) — no functional change.

---

<details>
<summary>📋 Implementation Plan</summary>

# Effect migration — Wave 3 / Phase 11: ManagedRuntime + Layer
dependency injection

## 0. Summary

Replace the two hand-written composition roots (`createCoreServices` +
the `ServiceContainer` constructor) with an **Effect `Layer` graph**
built once per process by a **`ManagedRuntime`** ("AppRuntime"), while
keeping every service class, constructor signature, Promise facade,
private method, and test seam compatible. The runtime becomes (a) the
owner of the app-lifetime `Scope`, (b) the provider of
`"effect/context"` for oRPC Effect-native handlers, and (c) the source
of two runtime seams: an **`EffectRunner`** (context-bound,
*unsupervised* runner that lets clock-driven workers run on a
`TestClock`) and an **`AppFiberScope`** (a runtime-owned, *supervised*
scope whose close is awaited by `dispose()` — the slot the streamManager
engine core will occupy later).

Six stacked, independently mergeable PRs. Product PRs keep existing
tests unchanged; only the final test-modernization PR edits tests. Net
product LoC ≈ **+420** (per-PR estimates below). Service classes are
*not* rewritten — Layers are thin adapters around existing constructors;
cycle-breaking setter wiring moves into explicit "wiring layers" that
replay today's order.

Unlocks (not done here): streamManager ENGINE CORE conversion,
`TestClock` for timing suites, app-lifetime scopes.

## 1. Verified current state (evidence)

- **Roots.** `src/node/services/coreServices.ts:103-389`
(`createCoreServices`: 25 constructions, 12 `turnRequestBuilderBindings`
writes, ~14 setters) and `src/node/services/serviceContainer.ts:161-575`
(45 more constructions; `aiService.on(...)`/`workspaceService.on(...)`
analytics wiring at 474-574; global registrations
`setGlobalCoderService/setSshPromptService` at 469-471). `new
ServiceContainer(stores)` is called by `headlessEnvironment.ts:111`,
`tests/ipc/setup.ts`, `src/cli/server.ts:132`,
`src/node/acp/serverConnection.ts:155`, `src/desktop/main.ts:653`;
`src/cli/run.ts:661` and `src/cli/workflow.ts:376` call
`createCoreServices` directly. ⇒ two graph roots (App vs Core), five
process entry points, all constructing **synchronously**.
- **Startup.** `ServiceContainer.initialize()` (577-642) awaits six
`initialize()`s (no try/catch; failure propagates to `main.ts:1255-1265`
"Startup Failed" dialog + quit; `server.ts`/ACP log and exit), then sync
`start()`s idleCompaction/heartbeat/agentStatus, then two
fire-and-forget sweeps. All constructors are synchronous; two have side
effects on **declared constructor dependencies** only (`AIService` →
`streamManager.setEventSink`, `WorkspaceService` →
`backgroundProcessManager.on/aiService.on`).
- **Teardown.** `dispose()` (746-779) is explicit and hand-ordered
(`backgroundProcessManager.beginShutdown()` MUST be first — it is a
latch protecting persisted monitor records; bridges stop before sessions
close; `terminateAll` late; `timelineService.flush()` last).
`shutdown()` (718-732) is a *second* sequence fired concurrently by a
second `before-quit` listener (`main.ts:1321`). `main.ts:1296-1304`
races `dispose()` against 5 s then `app.quit()`; `cli/server.ts:227-268`
has a 5 s `process.exit(1)` force timer; `tests/ipc` cleanup calls
`dispose()` then `shutdown()`; `headlessEnvironment.dispose` never calls
`services.dispose()`.
- **Existing Effect surface.** 25 files import `effect`. Only
`Context.Service` tag: `MemoryMeta`
(`src/node/orpc/effectContext.ts:21`). `handlerGen`
(`@orpc/experimental-effect`) runs `Effect.runPromiseExit` per request
and `Effect.provide`s `opts.context["effect/context"]`.
`streamBridge.ts` runs streams on the global runtime. Scope-owning
workers: `heartbeatService.ts:134-243`,
`idleCompactionService.ts:86-122` (`Scope.makeUnsafe` +
`Effect.runSync(Scope.close(..))`, valid only because their fibers
suspend solely on the clock), `oauthFlowManager.ts:164`,
`streamManager.ts:4767/4054` (already `Effect.runFork(Scope.close(..))`
— the async-close precedent). `memoryConsolidationService.ts:667-703,
837-860`: check-and-reserve funnels with zero suspensions before
`inFlight.set`/`harvestInFlight.set`.
- **[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` …
asm pushed a commit to asm/mux that referenced this pull request Sep 2, 2026
## 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 -->
yermakoffivan pushed a commit to yermakoffivan/mux that referenced this pull request Sep 6, 2026
…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 -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant