Context
Phase 2 of the operator-settled add_memory never-fail design (#12838). Phase 1 (PR pending, Resolves #12838) ships the JSONL write-ahead log: add_memory appends the full payload durably, keeps the graph write synchronous, and defers the Chroma embed to a transitional in-process fire-and-forget (MemoryService.embedPendingMemory, mirroring the buildMiniSummary precedent). That transitional drain is correct but not durable: a process restart mid-embed leaves the record pending until the same server process happens to retry — there is no retry/backoff, no batch drain, and no cross-restart owner for the backlog.
Split per the 1-PR-per-ticket mandate (pull-request-workflow.md §9.1): #12838 closes with Phase 1; this ticket carries the Phase-2 ACs verbatim.
Live latest-open sweep: checked latest 20 open issues at 2026-06-10T14:4xZ; no equivalent daemon ticket exists (only #12838 itself names this as Phase 2).
The Problem
After Phase 1, WAL records are reconciled (embed-marked) only by the transitional in-process embed. Failure shapes left open:
- Crash/restart mid-embed — the in-flight embed dies with the process; the record stays pending until... nothing. Recency recall still serves it via the WAL overlay, but semantic recall (
query_raw_memories) never sees it.
- Sustained embedder outage (the empirical anchor in
#12838: LM Studio saturation during KB/REM backfills) — every turn's embed fails softly; the pending backlog grows with no draining owner.
- No retry/backoff — a transient failure is retried only implicitly (never for that record).
The Architectural Reality
Phase 1 lands the complete store API the daemon needs (ai/services/memory-core/helpers/memoryWalStore.mjs):
readPendingWalRecords({dir, ids?, limit?}) — newest-first pending backlog; corrupt-line tolerant; each record carries {id, timestamp, metadata, document, segmentKey} — exactly the collection.add triple.
appendWalEmbedMarker({id, segmentKey}, {dir}) — reconciliation primitive (single-writer marker files; the Phase-1 transitional embed and this daemon are never concurrent writers, because step 2 below removes the transitional path in the same PR that starts the daemon).
pruneReconciledWalSegments({dir, retentionLimit, activeSegmentKey}) — retention; never prunes a pending record.
- Purge safety is already daemon-ready:
SessionService.purgeSession tombstones (embed-marks) purged-session pending records and MemoryService.embedPendingMemory carries the tenant-aware purged-session guard — the daemon reuses embedPendingMemory or replicates its guard semantics.
Config substrate: memoryWal.{dirProd,dirTest,useTestDatabase,retentionLimit,minFieldLength} leaves exist (ADR 0019 declarative leaf() + by-construction test isolation). Phase 2 adds its daemon leaves to the same block.
Placement (structural pre-flight, Stage-1 fast-path): ai/daemons/embed/daemon.mjs — sibling of ai/daemons/wake/daemon.mjs (+ kb-alerting, kb-gc, kb-reconciliation, orchestrator). NOT ai/scripts/ (PR #11008 misplacement anchor).
The Fix
- New orchestrator-managed daemon
ai/daemons/embed/daemon.mjs: poll readPendingWalRecords on a config-driven interval → embed batches via collection.add with retry/backoff + max-retries → appendWalEmbedMarker per success → pruneReconciledWalSegments per cycle.
- Remove the transitional inline embed call from
MemoryService.addMemory (keep embedPendingMemory as the shared embed primitive the daemon invokes, or hoist its guard logic into the daemon).
- Config leaves (ADR 0019, env-var-overridable, defaults + comments in
config.template.mjs): memoryWal.pollIntervalMs, memoryWal.batchSize, memoryWal.maxRetries (+ backoff base if not derived).
- Orchestrator ownership per the
#12065 SSOT pattern (task definition + supervision), mirroring how the wake daemon is managed.
Contract Ledger
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback |
Evidence |
ai/daemons/embed/daemon.mjs |
new (orchestrator-managed) |
Drains WAL → embeds with retry/backoff → marks → prunes |
WAL retains pending records across restarts |
sibling ai/daemons/wake/daemon.mjs |
MemoryService.addMemory deferred embed |
#12838 Phase 1 |
Transitional inline embed removed; daemon owns the drain |
WAL overlay keeps recency content-complete during lag |
embedPendingMemory (Phase-1 PR) |
memoryWal.* daemon leaves |
ADR 0019 reactive SSOT |
pollIntervalMs / batchSize / maxRetries env-overridable |
template defaults |
Phase-1 memoryWal block precedent |
| Purged-session safety |
Phase-1 purge guard + WAL tombstones |
Daemon never re-embeds purged records |
tenant-aware guard in embedPendingMemory |
SessionService.purgeSession (Phase-1 PR) |
Decision Record impact
aligned-with ADR 0019 (config leaves) + #12065 orchestrator-owned daemon pattern. Challenges no ADR.
Acceptance Criteria
Out of Scope
- The write-path itself (validation gate, WAL append, sync graph write, recency overlay) — shipped in
#12838 Phase 1.
- Embedding QoS/priority lanes — superseded territory (
#12487/#12509 closed); the decouple makes interactive QoS moot.
- Chat-model priority lane —
#12748.
- Corrupted-memory cleanup/carve-out —
#12830.
Avoided Traps
- ❌ Keep the transitional in-process embed alongside the daemon — two concurrent marker-writers would break the single-writer-per-file interleave guarantee of the WAL store. The daemon PR removes the transitional path atomically.
- ❌ Place the daemon in
ai/scripts/ — PR #11008 misplacement anchor; daemons live under ai/daemons/.
- ❌ In-daemon dedup via Chroma reads before each add — unnecessary:
collection.add is idempotent per id at worst-duplicate level, and the marker files are the cheap source of truth; read those.
Related
#12838 — Phase 1 (write path; the PR resolving it lands the store API + transitional embed this ticket replaces).
#12065 — orchestrator-as-SSOT daemon pattern.
#12123 — REM-run JSONL store precedent.
Origin Session ID: 5000ac5e-dabb-4f39-8a5c-e8ba55133f3d
Handoff Retrieval Hints:
query_raw_memories "embed daemon WAL drain retry backoff memoryWalStore pending records"
- Code anchors:
ai/services/memory-core/helpers/memoryWalStore.mjs (store API), MemoryService.embedPendingMemory (guard semantics + shared embed primitive), ai/daemons/wake/daemon.mjs (sibling pattern).
Authored by Claude Fable 5 (Claude Code), @neo-fable.
Context
Phase 2 of the operator-settled
add_memorynever-fail design (#12838). Phase 1 (PR pending,Resolves #12838) ships the JSONL write-ahead log:add_memoryappends the full payload durably, keeps the graph write synchronous, and defers the Chroma embed to a transitional in-process fire-and-forget (MemoryService.embedPendingMemory, mirroring thebuildMiniSummaryprecedent). That transitional drain is correct but not durable: a process restart mid-embed leaves the record pending until the same server process happens to retry — there is no retry/backoff, no batch drain, and no cross-restart owner for the backlog.Split per the 1-PR-per-ticket mandate (
pull-request-workflow.md §9.1):#12838closes with Phase 1; this ticket carries the Phase-2 ACs verbatim.Live latest-open sweep: checked latest 20 open issues at 2026-06-10T14:4xZ; no equivalent daemon ticket exists (only
#12838itself names this as Phase 2).The Problem
After Phase 1, WAL records are reconciled (embed-marked) only by the transitional in-process embed. Failure shapes left open:
query_raw_memories) never sees it.#12838: LM Studio saturation during KB/REM backfills) — every turn's embed fails softly; the pending backlog grows with no draining owner.The Architectural Reality
Phase 1 lands the complete store API the daemon needs (
ai/services/memory-core/helpers/memoryWalStore.mjs):readPendingWalRecords({dir, ids?, limit?})— newest-first pending backlog; corrupt-line tolerant; each record carries{id, timestamp, metadata, document, segmentKey}— exactly thecollection.addtriple.appendWalEmbedMarker({id, segmentKey}, {dir})— reconciliation primitive (single-writer marker files; the Phase-1 transitional embed and this daemon are never concurrent writers, because step 2 below removes the transitional path in the same PR that starts the daemon).pruneReconciledWalSegments({dir, retentionLimit, activeSegmentKey})— retention; never prunes a pending record.SessionService.purgeSessiontombstones (embed-marks) purged-session pending records andMemoryService.embedPendingMemorycarries the tenant-aware purged-session guard — the daemon reusesembedPendingMemoryor replicates its guard semantics.Config substrate:
memoryWal.{dirProd,dirTest,useTestDatabase,retentionLimit,minFieldLength}leaves exist (ADR 0019 declarativeleaf()+ by-construction test isolation). Phase 2 adds its daemon leaves to the same block.Placement (structural pre-flight, Stage-1 fast-path):
ai/daemons/embed/daemon.mjs— sibling ofai/daemons/wake/daemon.mjs(+ kb-alerting, kb-gc, kb-reconciliation, orchestrator). NOTai/scripts/(PR#11008misplacement anchor).The Fix
ai/daemons/embed/daemon.mjs: pollreadPendingWalRecordson a config-driven interval → embed batches viacollection.addwith retry/backoff + max-retries →appendWalEmbedMarkerper success →pruneReconciledWalSegmentsper cycle.MemoryService.addMemory(keepembedPendingMemoryas the shared embed primitive the daemon invokes, or hoist its guard logic into the daemon).config.template.mjs):memoryWal.pollIntervalMs,memoryWal.batchSize,memoryWal.maxRetries(+ backoff base if not derived).#12065SSOT pattern (task definition + supervision), mirroring how the wake daemon is managed.Contract Ledger
ai/daemons/embed/daemon.mjsai/daemons/wake/daemon.mjsMemoryService.addMemorydeferred embed#12838Phase 1embedPendingMemory(Phase-1 PR)memoryWal.*daemon leavespollIntervalMs/batchSize/maxRetriesenv-overridablememoryWalblock precedentembedPendingMemorySessionService.purgeSession(Phase-1 PR)Decision Record impact
aligned-withADR 0019 (config leaves) +#12065orchestrator-owned daemon pattern. Challenges no ADR.Acceptance Criteria
ai/daemons/embed/daemon.mjsdrains the JSONL WAL, embeds viacollection.addwith retry/backoff + max-retries, marks records done, and prunes per the retention bound. Test against a seeded JSONL (=#12838AC4).query_raw_memories); the WAL record is reconciled (=#12838AC5).addMemory; the WAL pending-overlay continues to keepquery_recent_turnscontent-complete during daemon lag (existing Phase-1 specs stay green).pollIntervalMs,batchSize,maxRetries) are env-var-overridable per ADR 0019; defaults inconfig.template.mjswith comments (completes#12838AC6).#12838AC7): Under a live heavy embed backfill,add_memorylatency stays bounded and never returnsMEMORY_ADD_ERROR, and the pending backlog drains once contention clears.Out of Scope
#12838Phase 1.#12487/#12509closed); the decouple makes interactive QoS moot.#12748.#12830.Avoided Traps
ai/scripts/— PR#11008misplacement anchor; daemons live underai/daemons/.collection.addis idempotent per id at worst-duplicate level, and the marker files are the cheap source of truth; read those.Related
#12838— Phase 1 (write path; the PR resolving it lands the store API + transitional embed this ticket replaces).#12065— orchestrator-as-SSOT daemon pattern.#12123— REM-run JSONL store precedent.Origin Session ID: 5000ac5e-dabb-4f39-8a5c-e8ba55133f3d
Handoff Retrieval Hints:
query_raw_memories "embed daemon WAL drain retry backoff memoryWalStore pending records"ai/services/memory-core/helpers/memoryWalStore.mjs(store API),MemoryService.embedPendingMemory(guard semantics + shared embed primitive),ai/daemons/wake/daemon.mjs(sibling pattern).Authored by Claude Fable 5 (Claude Code), @neo-fable.