Context
Surfaced during my same-family review of #14161 (the KB resumable shadow-swap, Resolves #14146). #14161 is correct and approved — this is a rare, non-blocking edge found in the deeper cross-cutting pass, filed so the friction is not lost (it is a net-new source of the abandoned shadows that #14079 cleans up).
The leak
VectorService.embedViaShadowSwap (post-#14161) on a transient embed failure preserves the shadow + records a resume-marker via writeResumeState. That write is wrapped:
} else {
try {
await writeResumeState({dir: stateDir, fingerprint, shadowName, attempts});
} catch (preserveError) {
logger.error(`...Failed to record resume-state for '${shadowName}': ...`);
}
}If the marker write also fails (disk-full / permission glitch), the shadow collection is preserved on disk with no marker pointing at it. The next run:
readResumeState → null
decideResume → no-resume-shadow → fresh-build branch
if (resumeState?.shadowName) { await this.discardResumeShadow(...) } → no-op (resumeState is null)
- creates a new shadow → the prior shadow is now an unreferenced orphan
No external routine reclaims it: defragChromaDB deletes only its own atomically-scoped parkingName (verified — no *-shadow-* name-sweep), so the orphan persists until a manual defrag. Each double-failure leaks one shadow collection → feeds the #14079 Chroma bloat.
Why non-blocking
Requires a double failure (transient embed fail AND marker write fail). The marker is a tiny JSON to gitignored .neo-ai-data/kb-sync; its write fails ~only on disk-full, where the embed itself usually fails first. Slow leak, not a correctness bug — #14161's happy/transient/terminal paths are all correct.
Fix direction (pick one)
- (A) Write-ahead marker (preferred): write the resume-state before
embedChunks, so the marker is a reliable index of "a shadow exists." Then a fresh-build always finds + discardResumeShadows the prior one. Success path already clearResumeStates. One extra small write per embed.
- (B) Marker-less sweep: on the fresh-build branch,
listCollections() and discard any *-shadow-* not equal to the current resume-shadow. More aggressive; needs a guard so it never deletes a live resume-shadow named in the marker.
(A) is cleaner — it makes the marker the single source of truth for shadow existence, closing the window structurally rather than sweeping after the fact.
Acceptance Criteria
Refs #14161 (the resumable shadow-swap this hardens), #14146 (the P0 it resolved), #14079 (the bloat epic this prevents a new source of).
🤖 Authored by Ada (@neo-opus-ada · Claude Opus 4.8, Claude Code) · origin session f2c722bf-9fb0-4925-8fbc-a9a0788f459c. Surfaced via #14161 same-family review.
Context
Surfaced during my same-family review of #14161 (the KB resumable shadow-swap, Resolves #14146). #14161 is correct and approved — this is a rare, non-blocking edge found in the deeper cross-cutting pass, filed so the friction is not lost (it is a net-new source of the abandoned shadows that #14079 cleans up).
The leak
VectorService.embedViaShadowSwap(post-#14161) on a transient embed failure preserves the shadow + records a resume-marker viawriteResumeState. That write is wrapped:} else { try { await writeResumeState({dir: stateDir, fingerprint, shadowName, attempts}); } catch (preserveError) { logger.error(`...Failed to record resume-state for '${shadowName}': ...`); } }If the marker write also fails (disk-full / permission glitch), the shadow collection is preserved on disk with no marker pointing at it. The next run:
readResumeState→nulldecideResume→no-resume-shadow→ fresh-build branchif (resumeState?.shadowName) { await this.discardResumeShadow(...) }→ no-op (resumeState is null)No external routine reclaims it:
defragChromaDBdeletes only its own atomically-scopedparkingName(verified — no*-shadow-*name-sweep), so the orphan persists until a manual defrag. Each double-failure leaks one shadow collection → feeds the #14079 Chroma bloat.Why non-blocking
Requires a double failure (transient embed fail AND marker write fail). The marker is a tiny JSON to gitignored
.neo-ai-data/kb-sync; its write fails ~only on disk-full, where the embed itself usually fails first. Slow leak, not a correctness bug — #14161's happy/transient/terminal paths are all correct.Fix direction (pick one)
embedChunks, so the marker is a reliable index of "a shadow exists." Then a fresh-build always finds +discardResumeShadows the prior one. Success path alreadyclearResumeStates. One extra small write per embed.listCollections()and discard any*-shadow-*not equal to the current resume-shadow. More aggressive; needs a guard so it never deletes a live resume-shadow named in the marker.(A) is cleaner — it makes the marker the single source of truth for shadow existence, closing the window structurally rather than sweeping after the fact.
Acceptance Criteria
resumableEmbedding/VectorService.WorkVolumeBranchingspecs).Refs #14161 (the resumable shadow-swap this hardens), #14146 (the P0 it resolved), #14079 (the bloat epic this prevents a new source of).
🤖 Authored by Ada (@neo-opus-ada · Claude Opus 4.8, Claude Code) · origin session
f2c722bf-9fb0-4925-8fbc-a9a0788f459c. Surfaced via #14161 same-family review.