---
id: 12142
title: 'ai:defrag-memory targets the stale pre-ADR-0003 path, not the unified store'
state: CLOSED
labels:
  - bug
  - ai
assignees:
  - neo-opus-ada
createdAt: '2026-05-28T17:58:24Z'
updatedAt: '2026-05-29T00:09:36Z'
githubUrl: 'https://github.com/neomjs/neo/issues/12142'
author: neo-opus-ada
commentsCount: 1
parentIssue: null
subIssues: []
subIssuesCompleted: 0
subIssuesTotal: 0
blockedBy: []
blocking: []
closedAt: '2026-05-29T00:09:36Z'
---
# ai:defrag-memory targets the stale pre-ADR-0003 path, not the unified store

## Context

ADR 0003 (`learn/agentos/decisions/0003-chroma-topology-unified-only.md`) unified the Chroma topology: ONE daemon persists to a single dir (`.neo-ai-data/chroma/knowledge-base`, the orchestrator's `--path` in `ai/daemons/orchestrator/TaskDefinitions.mjs`). Both Knowledge Base and Memory Core are CLIENTS of that one daemon; Memory Core has no separate persist dir.

`ai/scripts/maintenance/defragChromaDB.mjs` exposes per-target defrag (`ai:defrag-kb`, `ai:defrag-memory`). Its `TARGETS` map adapts each subsystem's config into `{host, path, port, collections}`.

## Problem

The `memory-core` target adapter resolves the physical `path` from `engines.chroma.dataDir`:

- `ai/mcp/server/memory-core/config.template.mjs` → `dataDir: .neo-ai-data/chroma/memory-core` — a **stale, pre-ADR-0003 path** that no longer exists in the unified store.

**Observable behavior today:** `ai:defrag-memory` hard-exits with code 1 at the path-existence guard (`defragChromaDB.mjs:343-346`) because `.neo-ai-data/chroma/memory-core` does not exist. The command is **broken**, not a silent no-op.

**Latent (more dangerous) behavior if that dir DID exist:** the extract phase connects via `host`/`port` (lines 371-373) — i.e. to the live unified daemon, reading the RIGHT data — but every PHYSICAL operation keys off the stale `DB_PATH`: the pre-nuke snapshot copy (`defragChromaDB.mjs:363`), the initial/final size checks, and the orphan-segment cleanup all operate on the wrong directory. Connection (correct) and physical ops (wrong dir) diverge.

## Architectural Reality

In the unified store there is exactly ONE persist dir. `ai:defrag-kb` already targets it correctly (`knowledge-base` config `path: .neo-ai-data/chroma/knowledge-base`). The `memory-core` target must resolve its physical `path` to that SAME unified persist dir — the one the orchestrator launches the daemon with — because MC's collections live there, not in `engines.chroma.dataDir`.

## Prescription (single)

Resolve the `memory-core` defrag target's physical `path` to the unified persist dir (the orchestrator's Chroma `--path` SSOT), not `engines.chroma.dataDir`. Source it from the single source of truth rather than duplicating a hardcoded literal — no hidden fallback (`||` / `??` / `DEFAULT_*`); config is SSOT.

## Contract Ledger

| Surface | Consumer | Contract | Status |
|---|---|---|---|
| `ai:defrag-memory` CLI command | operator (manual); #12138 orchestrator recycle chain | Operates on the live unified persist dir; recreates MC collections; compacts the shared store safely (per #12140 segment-registry keep-set) | BROKEN — targets stale `memory-core` dir, hard-exits |
| `memory-core` `TARGETS` adapter `.path` | `defragChromaDB.mjs` physical ops (snapshot / size / orphan-clean) | Equals the unified persist dir | BROKEN |

## Acceptance Criteria

1. `ai:defrag-memory` resolves its physical `path` to the unified persist dir (the orchestrator daemon's `--path`), verified by a passing run against the live store (extract + snapshot + compaction all act on the unified dir).
2. The path is sourced from the SSOT — not a duplicated hardcoded literal and not a hidden `||` / `??` / `DEFAULT_*` fallback.
3. `ai:defrag-kb` behavior is unchanged (it already targets the unified dir).
4. Unit coverage for the `memory-core` adapter asserting `.path` equals the unified persist dir (the same SSOT `ai:defrag-kb` resolves), so the disjoint-path regression cannot silently return.

## Out of Scope

- Whether per-subsystem defrag targets remain meaningful when KB and MC share one physical store (separate question; not blocking this fix).
- The #12140 segment-registry keep-set (already shipped).

## Related

- Prerequisite sibling: #12140 (unified-store-safe orphan keep-set), PR #12141.
- Consumer: #12138 (Chroma recycle chain) will invoke defrag commands.
- ADR: `learn/agentos/decisions/0003-chroma-topology-unified-only.md`.

Origin Session ID: f6a4a820-1d95-40e7-910f-b47ad68f51f8

## Timeline

- 2026-05-28T17:58:26Z @neo-opus-ada added the `bug` label
- 2026-05-28T17:58:26Z @neo-opus-ada added the `ai` label
### @neo-opus-ada - 2026-05-28T18:05:44Z

**Input from Claude Opus (Claude Code):**

> ❋ ## Root cause (V-B-A, same session f6a4a820)
> 
> The defrag adapter is a **victim, not the defect locus** — it faithfully reads a stale config value. The real defect is in the Memory Core server config:
> 
> - `ai/mcp/server/memory-core/config.template.mjs:240-245` — `engines.chroma.dataDir` resolves to `.neo-ai-data/chroma/memory-core`, **but the adjacent docstring (lines 236-238) states** `engines.chroma` *"specifies the unified ChromaDB instance shared with the Knowledge Base."* Doc says unified; code points at the stale non-unified path. `host`/`port` on the same object ARE unified (`AiConfig.engines.chroma.host/port`); only the path diverges.
> 
> ### The SSOT gap
> 
> `host`/`port` are centralized in `AiConfig.engines.chroma` and read by both servers. The unified persist **path** is NOT — it is triplicated:
> 
> - `ai/mcp/server/knowledge-base/config.template.mjs:132` → `.neo-ai-data/chroma/knowledge-base`
> - `ai/mcp/server/memory-core/config.template.mjs:242` → `.neo-ai-data/chroma/memory-core` (stale)
> - `ai/daemons/orchestrator/TaskDefinitions.mjs` (chroma task `--path`) → `.neo-ai-data/chroma/knowledge-base`
> 
> ### Reshaped prescription (refines the body's Prescription + AC2)
> 
> The fix is **config-SSOT consolidation, not a defrag-adapter patch**. Extend the existing host/port pattern: add the unified persist path to `AiConfig.engines.chroma` (the single source) and have ALL consumers read it — KB config `path`, MC config `dataDir`, the orchestrator chroma task `--path`, and (transitively) both `defragChromaDB.mjs` adapters.
> 
> A minimal "change MC's `dataDir` literal to `.../knowledge-base`" would create a **third** hardcoded copy of the path and violate AC2 (no duplicated literal; config is SSOT) — explicitly the wrong shape.
> 
> Blast radius: 4 files, cross-substrate (both MCP server configs + orchestrator daemon + maintenance script). Single coherent prescription (no design ambiguity — the host/port precedent dictates the shape), so it stays one ticket / one PR; not an ideation candidate.
> 
> ### Adjacent, not overlapping — #12139
> 
> #12139 centralizes Chroma **lifecycle authority** (orchestrator owns daemon start/stop/recycle; removes server-side managed-mode). This ticket centralizes the persist-**path config value**. Distinct surfaces — coordinate, don't merge.
> 
> ### Sequencing
> 
> Should land **after** PR #12141 merges — both touch `defragChromaDB.mjs`, and #12141 is in review. Independent file regions (PR #12141 = orphan-cleanup helpers + step 6; this = the `TARGETS` map / config layer), so the coupling is minor, but merge-order keeps the review clean.

- 2026-05-28T20:03:49Z @neo-opus-ada cross-referenced by PR #12149
- 2026-05-28T20:56:14Z @tobiu referenced in commit `f518f50` - "feat(ai): orchestrator recycles chroma after max runtime (#12138) (#12149)

* feat(ai): add orchestrator.chroma.maxRuntimeMs config + getter (#12138)

Config scaffolding for the Chroma max-runtime recycle: orchestrator.chroma.maxRuntimeMs (24h default) + NEO_CHROMA_MAX_RUNTIME_MS env binding + Orchestrator.chromaMaxRuntimeMs getter (reads config verbatim, no hidden fallback). The poll() recycle logic that consumes it follows in this branch.

Co-Authored-By: neo-opus-ada <neo-opus-ada@neomjs.com>

* feat(ai): orchestrator recycles chroma after max runtime (#12138)

Long-lived chroma daemons accumulate process cruft + on-disk HNSW
fragmentation. The orchestrator now recycles the supervised chroma daemon
once its uptime exceeds orchestrator.chroma.maxRuntimeMs (default 24h, env
NEO_CHROMA_MAX_RUNTIME_MS): SIGKILL the over-age process (chroma ignores
SIGTERM), let the existing supervisor branch respawn it, then run the
unified-store-safe ai:defrag-kb against the fresh daemon once it is
connection-ready. Gated by chromaDaemonEnabled (no-op in cloud / externally
managed). MC defrag is intentionally deferred (no rebuild-from-source
fallback) to #12142.

Implementation:
- Orchestrator: isChromaRecycleDue predicate + probeChromaReady TCP readiness
  gate + poll() recycle/defrag branches driven by an in-memory pending-defrag
  flag (re-derived from uptime on orchestrator restart, so unpersisted).
- ProcessSupervisorService.killTask: SIGKILLs the tracked pid (no-op under
  UNIT_TEST_MODE) + markRecycled + records the outcome.
- TaskStateService.markRecycled: clears running/pid so the supervisor loop
  respawns the daemon (robust for adopted processes; deterministic in tests).
- TaskDefinitions: one-shot chromaDefrag task (node defragChromaDB.mjs
  --target knowledge-base).
- config.template.mjs: maintenance.defrag doc carve-out noting the recycle is
  the one orchestrator path that auto-spawns defrag.

Tests: 7 recycle specs (trigger / disabled / cloud-gated / within-ceiling /
ready / not-ready) + 3 killTask specs + the createInitialTaskState keys
update. Orchestrator 38, ProcessSupervisor 14, TaskState 4 — all green.

Co-Authored-By: neo-opus-ada <neo-opus-ada@neomjs.com>

* fix(ai): chroma recycle skips uninitialized lastRunAt=0 (#12138)

isChromaRecycleDue computed uptime as now-lastRunAt; a running task with the uninitialized lastRunAt=0 (test-harness default, or any never-stamped state) read as now-0 = huge uptime and spuriously recycled. With a real maxRuntimeMs present this fired inside unrelated orchestrator poll() tests, polluting their outcome/spawn assertions (green locally only because the stale local config.mjs lacks orchestrator.chroma; CI hydrates it from the template). Guard with lastRunAt > 0 — a genuinely running daemon always carries a real markStarted stamp. Adds a regression test for the running + lastRunAt=0 + maxRuntimeMs condition.

Co-Authored-By: neo-opus-ada <neo-opus-ada@neomjs.com>

* docs(ai): chroma recycle defrag is best-effort, not restart-durable (#12138)

Per @neo-gpt review of PR #12149: the post-recycle defrag uses a process-local pending flag, so a mid-recycle orchestrator restart (after killTask, before the defrag) skips it for that cycle. Corrected the inaccurate 're-derived from uptime' comment to state the best-effort contract — the next max-runtime recycle compacts the store; persisting the intent would add a pending-forever/endless-retry failure mode for an idempotent compaction step; the kill->restart (primary recycle value) is unaffected. Comment-only; no behavior change.

Co-Authored-By: neo-opus-ada <neo-opus-ada@neomjs.com>

---------

Co-authored-by: tobiu <tobiasuhlig78@gmail.com>"
- 2026-05-28T21:00:27Z @neo-opus-ada assigned to @neo-opus-ada
- 2026-05-28T21:59:56Z @neo-opus-ada cross-referenced by PR #12152
- 2026-05-28T22:09:50Z @neo-opus-ada referenced in commit `6c2c38d` - "docs(ai): drop decay-prone ticket-id refs from SSOT comments (#12142)

JS comments must not carry ticket IDs — they are tracking anchors that decay
into noise once merged (load-bearing refs belong in PR bodies, commit subjects,
and test.describe labels). Removes the five `#12142` refs that 591746914
introduced into the unified Chroma persist-dir SSOT comments (config.template,
KB/MC server configs, orchestrator TaskDefinitions) + the MC config.template
spec, preserving the evergreen WHY in each.

Comment-only; no behavior change. config.template + MC config specs green
(11 passed).

Co-Authored-By: neo-opus-ada <neo-opus-ada@neomjs.com>"
- 2026-05-28T23:43:53Z @neo-opus-ada cross-referenced by #12156
- 2026-05-28T23:52:38Z @neo-opus-ada referenced in commit `d1b5107` - "fix(ai): drop half-honored NEO_CHROMA_DATA_DIR override (#12142)

@neo-gpt's PR #12152 review caught a real contract split: the NEO_CHROMA_DATA_DIR
env override was honored by the KB/MC server configs + defragChromaDB, but the
orchestrator's chroma daemon --path is a fixed literal that ignores it. Under
override the maintenance scripts would target a dir the live daemon never persists
to — re-introducing the exact wrong-dir bug this PR set out to fix.

Remove the override surface: engines.chroma.dataDir is now a fixed canonical persist
dir (the default value is unchanged). All consumers — both server configs,
defragChromaDB, and the local orchestrator daemon-launch literal — resolve to the
same fixed path, so the split is structurally impossible. Tighten the docstring to
stop overclaiming daemon-aligned SSOT-ness.

Wiring the orchestrator --path through the resolved value (so a future override is
honored end-to-end across the daemon too) lands with the unified-store rename epic
#12153 (sub #12155).

Tests: the engines.chroma assertion drops the env-override branch; the TIER1_DEFAULTS
fixture pins the fixed path. config.template + KB/MC config.template specs green
(13 passed).

Co-Authored-By: neo-opus-ada <neo-opus-ada@neomjs.com>"
- 2026-05-29T00:09:36Z @tobiu referenced in commit `e1b3d7a` - "fix(ai): defrag-memory uses the unified Chroma persist-dir SSOT (#12142) (#12152)

* fix(ai): defrag-memory uses the unified Chroma persist-dir SSOT (#12142)

ai:defrag-memory targeted a stale, server-local '.neo-ai-data/chroma/memory-core'
dir (the MC config dataDir), but the unified store (ADR 0003) is one daemon + one
persist dir — the KB's '.neo-ai-data/chroma/knowledge-base'. So defrag-memory
operated on the wrong directory (and hard-exited when it was absent).

Add AiConfig.engines.chroma.dataDir as the single source of truth for the unified
persist dir (= the current live value; env NEO_CHROMA_DATA_DIR), and have the KB
config path + MC config dataDir + (transitively) the defrag adapters read it. MC's
dataDir now resolves to the unified store, so ai:defrag-memory targets the live
store. No consumer's resolved dir changes — the SSOT equals the prior KB value.

The orchestrator chroma --path is intentionally kept a relative literal (with a
cross-reference comment), NOT reading the SSOT: a deployment whose config.mjs is
stale (lacking the new key) would otherwise launch the daemon with
`--path undefined`. The server/defrag reads only affect the physical/maintenance
path, never the live daemon launch — so this scope is the safe one.

Deployment note: existing config.mjs copies must be re-hydrated from the template
to pick up engines.chroma.dataDir (the established template-addition migration).
Shadow-swap robustness for the (manual, snapshot-protected, #12140-safe) MC defrag
remains a tracked follow-up.

Tests: engines.chroma SSOT assertion (config.template.spec + TIER1_DEFAULTS
fixture); MC config.template.spec now asserts dataDir == the unified SSOT (was a
stale-path regression anchor). All affected config specs green (template-based,
harness-independent).

Co-Authored-By: neo-opus-ada <neo-opus-ada@neomjs.com>

* docs(ai): drop decay-prone ticket-id refs from SSOT comments (#12142)

JS comments must not carry ticket IDs — they are tracking anchors that decay
into noise once merged (load-bearing refs belong in PR bodies, commit subjects,
and test.describe labels). Removes the five `#12142` refs that 591746914
introduced into the unified Chroma persist-dir SSOT comments (config.template,
KB/MC server configs, orchestrator TaskDefinitions) + the MC config.template
spec, preserving the evergreen WHY in each.

Comment-only; no behavior change. config.template + MC config specs green
(11 passed).

Co-Authored-By: neo-opus-ada <neo-opus-ada@neomjs.com>

* fix(ai): drop half-honored NEO_CHROMA_DATA_DIR override (#12142)

@neo-gpt's PR #12152 review caught a real contract split: the NEO_CHROMA_DATA_DIR
env override was honored by the KB/MC server configs + defragChromaDB, but the
orchestrator's chroma daemon --path is a fixed literal that ignores it. Under
override the maintenance scripts would target a dir the live daemon never persists
to — re-introducing the exact wrong-dir bug this PR set out to fix.

Remove the override surface: engines.chroma.dataDir is now a fixed canonical persist
dir (the default value is unchanged). All consumers — both server configs,
defragChromaDB, and the local orchestrator daemon-launch literal — resolve to the
same fixed path, so the split is structurally impossible. Tighten the docstring to
stop overclaiming daemon-aligned SSOT-ness.

Wiring the orchestrator --path through the resolved value (so a future override is
honored end-to-end across the daemon too) lands with the unified-store rename epic
#12153 (sub #12155).

Tests: the engines.chroma assertion drops the env-override branch; the TIER1_DEFAULTS
fixture pins the fixed path. config.template + KB/MC config.template specs green
(13 passed).

Co-Authored-By: neo-opus-ada <neo-opus-ada@neomjs.com>

---------

Co-authored-by: tobiu <tobiasuhlig78@gmail.com>"
- 2026-05-29T00:09:36Z @tobiu closed this issue

