plans/2026-08-18-chroma-windows.md
Status: ready to execute
Target: ONE PR against thedotmack/claude-mem:main
Related plan masters: #3610 [plan-22] Chroma Sidecar Contract, #3602 [plan-14] Child Process Ownership, #3603 [plan-15] Worker Port & Liveness
Chroma starts on Windows today. The cmd.exe >/< argument mangling (#2954, #3121) is
already fixed on main — ChromaMcpManager spawns uvx directly with no shell wrapper.
What is broken is process-tree ownership. Chroma runs as a 4-deep native chain:
worker → uvx.exe → uv.exe → python.exe → chroma-mcp
Windows has no POSIX process groups and Node's process.kill(pid, 'SIGTERM') force-terminates
exactly one PID. Every teardown path except one kills by PID, so the descendants survive. The
survivors then cause all three headline Windows bugs:
| Survivor effect | Issue | Symptom |
|---|---|---|
| Orphan inherits worker's listening socket | #3482 | port 37777 wedged, 834 health-check failures, hooks hard-block |
uv killed mid-build, temp dirs never reaped | #3540 | builds-v0/.tmp* leak — 144.21 GB / 696 dirs measured |
Foreign VIRTUAL_ENV inherited into uvx sandbox | #3552 | numpy ABI clash, semantic sync stops silently |
The correct fix already exists in the repo but is not shared:
ChromaMcpManager.killProcessTree() (src/services/sync/ChromaMcpManager.ts:1039-1154)
does POSIX descendant-walk + Windows taskkill /PID n /T /F. Nothing else uses it.
taskkill /PID <n> /T /F via execFileAsync — the Windows tree-kill. Precedent: ChromaMcpManager.ts:1044-1047.pgrep -P <pid> for POSIX descendant walk. Precedent: ChromaMcpManager.ts:1124-1154.process.platform === 'win32' — the detection idiom used repo-wide.path.join / pathToFileURL / fileURLToPath — already the norm.getSupervisor().registerProcess/unregisterProcess — src/supervisor/index.ts, Chroma already registers as 'chroma-mcp'.ANTI-PATTERNS — these do NOT exist, do not reach for them:
process.kill(pid, 'SIGTERM') expecting graceful shutdown on Windows. It is always a hard terminate.shell: true or wrapping uvx in cmd.exe. That re-introduces #2954/#3121.Implement: move the working implementation out of ChromaMcpManager into a shared module.
src/shared/kill-process-tree.ts exporting killProcessTree(pid, opts?) and collectDescendantPids(pid).src/services/sync/ChromaMcpManager.ts:1039-1154. Do not redesign it —
it is battle-tested (POSIX: leaves-then-root SIGTERM, 500ms settle, re-collect, SIGKILL union;
Windows: taskkill /T /F).ChromaMcpManager import the shared helper and delete its private copy.Verify:
grep -n "killProcessTree" src/services/sync/ChromaMcpManager.ts → import only, no local definition.npm run build clean.Guard: do not change the algorithm in this phase. Pure extraction, so the diff is reviewable.
Implement: replace single-PID kills with killProcessTree. Exact sites found by audit:
| File:line | Current | Why it fails on Windows |
|---|---|---|
src/supervisor/process-registry.ts:319 | process.kill(record.pid, 'SIGTERM') | cmd.exe wrapper dies, Claude child orphaned |
src/supervisor/process-registry.ts:353 | process.kill(record.pid, 'SIGKILL') | reaper kills 1 PID, then deletes registry anyway |
src/supervisor/process-registry.ts:482 | proc.kill('SIGKILL') | .cmd wrapper only |
src/supervisor/process-registry.ts:770 | process.kill(record.pid, 'SIGTERM') | dup-SDK cleanup orphans descendant |
src/supervisor/shutdown.ts:186 | process.kill(pid, signal) | root exits, survivor scan never reaches taskkill branch |
src/shared/worker-utils.ts:514 | process.kill(stalePidInfo.pid, 'SIGKILL') | highest value — version recycle leaves whole uvx→chroma chain alive holding the socket |
src/server/runtime/ServerService.ts:363 | process.kill(existing.pid, 'SIGTERM') | skips DB/queue/HTTP cleanup handlers |
worker-utils.ts:514 is the direct cause of #3482 — fix it even if the phase is otherwise trimmed.
Verify:
grep -rn "process\.kill(" src/ | grep -v kill-process-tree.ts → every remaining hit is POSIX-only or intentional; justify each in the PR body.npm run build clean; existing supervisor tests green.Implement: tree-killing uv mid-build is what orphans builds-v0/.tmp* (#3540).
taskkill /T /F
after a grace period. Reuse the existing 500ms settle idiom rather than inventing a new timer scheme.builds-v0 dir, gated to entries older than a
conservative threshold, run at Chroma start (not shutdown — shutdown may be a hard kill).src/shared/uvx-bin-dirs.ts for how uv dirs are located. Do NOT hardcode a path.Verify:
uv PID.Guard: no recursive delete of anything outside the resolved uv cache dir. Assert the resolved path is under the uv cache root before unlinking.
Implement: uvx --python 3.13 must not inherit a foreign Python (#3552).
VIRTUAL_ENV, PYTHONHOME, PYTHONPATH, CONDA_PREFIX, CONDA_DEFAULT_ENV from the env
passed to the Chroma child.getUvxPreflightEnv /
effectiveUvxEnv (ChromaMcpManager.ts and src/services/worker/dependency-preflight.ts:75).
These are two parallel implementations; sanitize both or unify them (DRY — prefer unify).Verify:
process.env.This is why these bugs keep shipping: .github/workflows/windows.yml is build-only
(runs-on: windows-2022, steps = install → build → one Bun resolver test). It never spawns Chroma.
Without this phase the PR is unverifiable — we are on macOS and cannot test Windows locally.
Implement: add a Windows job that:
setup-runtime.ts:200).chroma-mcp, uv.exe, or python.exe descendants remain
(tasklist / Get-CimInstance Win32_Process filtered by parent chain).builds-v0/.tmp* count did not grow.Steps 5 and 6 are the regression gates — they are the whole point.
Verify: the job must FAIL on main (proving it catches the bug) and PASS with phases 1-4 applied.
Demonstrate this in the PR description. If it passes on main, the test is wrong — fix the test.
fix/chroma-windows-process-tree off current main.gh): ChromaMcpManager.ts is edited by 4 open PRs —
#3541 (uv grace period), #3567 (env sanitization), #3286 (Job Object), #3292 (broad recovery).
None reference each other. This PR covers the #3541 and #3567 ground with a shared helper instead
of four independent edits. Credit them explicitly in the PR body.
Related open: #3309 (socket inheritance, currently CONFLICTING/DIRTY), #3416 (port rebind),
#3529 (windowsHide), #3321 (where.exe PATH).plugin/hooks/hooks.json "shell": "bash") — belongs to plan-master #3605.tree-sitter.exe lookup miss (src/services/smart-file-read/parser.ts:362) — real MAJOR bug, separate PR.~\ tilde expansion (src/shared/paths.ts:32) — real MAJOR bug, separate PR./dev/null vs NUL (P3, P4) — MINOR.