Skip to content

feat(cli): lazy background worker — daemon-less consolidation, metered and detached - #3

Open
rabbiveesh wants to merge 6 commits into
masterfrom
claude/whats-next-13ypnl
Open

feat(cli): lazy background worker — daemon-less consolidation, metered and detached#3
rabbiveesh wants to merge 6 commits into
masterfrom
claude/whats-next-13ypnl

Conversation

@rabbiveesh

Copy link
Copy Markdown
Owner

Ships the lazy-background-work proposal: the CLI is its own scheduler — no daemon, no cron. Two features plus two adversarial review rounds each.

The worker tier (crates/memory-cli/src/worker.rs)

  • Trigger on writes: remember / promote / onboard --commit count toward a due-check (default: 5 writes + 10-min throttle, 24-h ceiling). When it trips, the CLI re-execs itself as a detached hidden mem __worker (own process group, stdio → .worker.log, rotated past ~1 MB).
  • Work: consolidate bounded by the pending backlog (capped at worker.max_targets); dream piggybacks weekly. Scope = the triggering invocation's active scope (the spec's "trigger is the user already being in the right place"), never store-wide.
  • Serialization: zero-dep pidfile lock with dead-holder//stale/future-mtime steal; a .worker-state.lock micro-lock serializes state read-modify-writes (µs hold, ~250 ms spin, then proceed-unlocked — the foreground can never hang).
  • Honest state: a failed or empty-scope pass keeps the backlog so it retries; a capped pass names its tail (capped at 12; cover the rest: mem consolidate --limit 40); recall/ask surface a one-line 🛠 background … summary exactly once.
  • Controls: mem worker (status), mem worker --now (forced pass), MEM_NO_BG=1 / worker.enabled=false (kill switch), knobs under [worker] in config.toml.

Instrumentation + backgrounded mem consolidate

  • Metrics ledger .worker-metrics.jsonl: one JSON line per pass — what happened (job, trigger, pending, targets, new edges, review flags, probes/bridges, ok/error) and what it cost (wall ms, chat calls + chat ms, embed texts). Costs come from new atomic counters in memory-embed, the single choke point all LLM traffic flows through; attempts count even on failure. mem worker shows per-job totals + the recent tail.
  • mem consolidate detaches by default, running the same locked/metered worker body with the caller's --limit (trigger=cli, no dream piggyback). --fg (or a disabled tier) keeps the synchronous path — also metered, drains the backlog it covers. A detached consolidate that loses the lock to an in-flight worker surfaces consolidate SKIPPED … rerun: mem consolidate --limit N instead of vanishing.
  • mem dream stays foreground but is metered, and resets the weekly piggyback cadence.

Review rounds (write→review→fix loop)

Each feature commit was followed by an adversarial review + verification round; the fix commits document what they caught, including: a failed pass permanently draining the retry backlog, a lost-update race on the state file, a clamp panic on max_targets=0, --limit 0 refactor regressions, a clock-skew hole in stale-lock detection, counter-test races, and the silent lock-held drop above. Accepted tradeoffs (store-global counter imprecision, double-steal race, log-rotation race) are documented in the design doc's "As built" section.

Testing

  • 31 unit tests in the touched crates (state round-trips, due-check matrix, lock exclusion/steal, drain semantics, metric ledger round-trip + aggregation, counter deltas); full workspace green.
  • Live smoke tests: kick-on-threshold, concurrent-kick lock exclusion, kill switch, failed-pass retry, one-time surfacing, detached + --fg consolidate, lock-held skip surfacing.
  • Not exercised here: a happy-path pass against a live ollama (this environment has none) — the LLM path reuses the pre-existing consolidate/dream machinery unchanged, but a real mem remember ×5 on a machine with ollama is the last-mile check.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WMTLGECmHZB6HKXADbWzsD


Generated by Claude Code

claude added 6 commits July 30, 2026 19:43
…dation

Ship the docs/design/lazy-background-work.md first cut (memory-cli/src/worker.rs):

- writes (remember / promote / onboard --commit) count toward a due-check
  (threshold + min/max interval, work-first); when it trips, the CLI re-execs
  itself as a detached hidden `mem __worker` (own process group, stdio ->
  .worker.log) that consolidates the pending backlog, bounded by
  worker.max_targets; dream piggybacks weekly on a healthy pass
- pidfile lock (zero-dep, stale-steal on dead holder or 6h age) serializes
  workers; idempotent edge commits make the rare double-worker harmless
- state sidecar .worker-state.json: epoch u64, atomic replace, writes_since
  drained by subtraction so mid-run writes stay pending; last_run advances on
  failed passes too (min_interval throttles retries), only a killed run
  re-triggers immediately; first-ever write anchors intervals (no surprise
  LLM pass on write #1)
- recall/ask surface a one-line '🛠 background …' summary exactly once;
  `mem worker` is the status surface, `mem worker --now` a forced pass;
  MEM_NO_BG=1 / worker.enabled=false switch the tier off
- consolidate/dream cores extracted as consolidate_pass/dream_pass, shared
  by the commands and the worker (no behavior change)
- scope decision (spec open question): the worker inherits the triggering
  invocation's cwd and consolidates the SAME active scope, not store-wide

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WMTLGECmHZB6HKXADbWzsD
…guard

Adversarial review of the first cut surfaced 8 findings; all addressed:

- [high] a failed/empty pass no longer drains writes_since — the backlog is
  drained only when the pass actually consolidated targets, so a down backend
  retries (min_interval-throttled) and out-of-scope writes catch up when the
  user is next active in their scope
- [med] state-file read-modify-writes (foreground count, surfacing, worker's
  final update) are serialized by a .worker-state.lock micro-lock: µs hold,
  ~250ms spin then proceed-unlocked so the foreground can never hang; fixes
  the lost-update race that could drop a run's summary, revert last_run, and
  fork a redundant second LLM pass
- [med] worker.max_targets=0 no longer panics (clamp on an inverted range);
  pass_limit() treats 0 as 1
- [low] cmd_consolidate/cmd_dream --limit 0 report as before the pass
  extraction (no false 'nothing in scope'/'need 2 beliefs'; review nudge kept)
- [low] a lock with a FUTURE mtime (clock step, VM snapshot) falls through to
  the pid-liveness check instead of blocking background work for hours
- [low] .worker.log rotates past ~1MB (one old generation kept)
- [low] documented that multi-word worker knobs are config.toml-only (the
  MEM_* env layer's _ separator can't address them)

Design doc updated to match (drain semantics, state lock, rotation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WMTLGECmHZB6HKXADbWzsD
Split the state-lock test: exclusion/free-on-drop stays portable, and a new
Linux-only test ages the lock file past the 10s abandonment bar (touch -d)
to prove an abandoned .worker-state.lock is stolen rather than spun on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WMTLGECmHZB6HKXADbWzsD
…teal

Round-2 verification of the review fixes surfaced two residuals:

- a capped pass (backlog > max_targets) drains the whole counter but only
  consolidates the newest chunk, and no cursor exists to catch the tail up
  later — instead of a false 'catches up across passes' promise, the run
  summary now names the tail and the command that covers it:
  'capped at 12; cover the rest: mem consolidate --limit 40' (docs aligned)
- .worker-state.lock abandonment now treats a FUTURE mtime (clock stepped
  back, restored VM) as abandoned, mirroring the WorkerLock fix — otherwise
  a crashed holder converts the µs micro-lock into a 250ms per-command spin
  for hours

Accepted as documented: --limit 0 cosmetic divergences in degenerate scopes;
the one-generation log-rotation race.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WMTLGECmHZB6HKXADbWzsD
…idate

Instrumentation: every consolidate/dream pass — background or foreground —
appends one line to $MEMORY_DIR/.worker-metrics.jsonl: what happened (job,
trigger, scopes, pending, targets, new_edges, review, probes, bridges, ok,
error) and what it cost (duration_ms, chat_calls, chat_ms, embed_texts).
Costs come from new process-local atomic counters in memory-embed — the one
choke point all LLM traffic flows through — snapshotted before/after each
pass; attempts count even on failure. mem worker prints lifetime totals per
job plus the recent tail; the ledger is the substrate for deeper analysis.

Backgrounding: mem consolidate now DETACHES by default, re-execing the same
hidden __worker body with the caller's --limit (trigger=cli, no dream
piggyback) — identical lock, log, state-drain, surfacing, and metrics as a
due-check kick. --fg (or a disabled tier: MEM_NO_BG / worker.enabled=false)
keeps the synchronous path, which is now also metered and drains the backlog
it covers. mem dream stays foreground but is metered, and a deliberate dream
resets the weekly piggyback cadence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WMTLGECmHZB6HKXADbWzsD
…ordance

Review round on the instrumentation commit:

- [med] a detached mem consolidate that loses the worker lock no longer
  vanishes after telling the user it's running: the skip is surfaced on the
  next recall/ask with the exact rerun command, and recorded as an ok:false
  ledger line (a skipped due-check kick stays silent — that work is fungible)
- [low] the inline --fg path now prints the same capped-backlog affordance
  as the worker path when it drains more writes than it consolidated
- [low] the two memory-embed counter tests merged into one sequential test —
  they raced each other's process-global atomics under parallel test runs
- sanitize also strips \r (terminal-garble nit)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WMTLGECmHZB6HKXADbWzsD
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.

2 participants