Skip to content

WP10a: sleep-time maintenance — offline dedup/summarize/evict with a human review queue#3

Merged
Wuesteon merged 14 commits into
mainfrom
wp10a-sleep-maintenance
Jul 16, 2026
Merged

WP10a: sleep-time maintenance — offline dedup/summarize/evict with a human review queue#3
Wuesteon merged 14 commits into
mainfrom
wp10a-sleep-maintenance

Conversation

@Wuesteon

Copy link
Copy Markdown
Owner

What this is

An offline "sleep-time" maintenance job that runs between sessions and cleans up stored memory — deduplicating exact restatements, staging summaries of old fact clusters, and demoting low-value facts to a cold tier — while preserving the ADD-only spine and as-of query semantics. Everything judgmental lands in a human review queue, approved either in the console (WP10b) or conversationally through Claude Code via MCP tools + a shipped review prompt.

Design: docs/superpowers/specs/2026-07-16-sleep-time-maintenance-design.md (rev 3, two-round adversarial verification record in §14). Plan: docs/superpowers/plans/2026-07-16-sleep-time-maintenance.md (9 tasks, all done).

Highlights

  • Store: batch() unit-of-work, busy_timeout, retire_duplicate (transitive chain invariant), set_tier, ledger/proposal CRUD; schema v2 via a user_version-gated migration with a checked-in v1 fixture + reopen test.
  • Ingest hooks (exact no-ops until maintenance first runs — first-run path byte-identical): duplicate-cascade closure and summary-staleness cascade, restoring ingest commutation (spec §3.1 condition 3).
  • Transforms: DEDUP-EXACT auto (value-preserving normalization only), EVICT auto-band, DEDUP-NEAR / SUMMARIZE / EVICT proposals; stage-proposals-first intra-run ordering; budget caps reported, never silent.
  • Runner: atomic lease (partial-unique-index INSERT in one BEGIN IMMEDIATE transaction), heartbeat + stale takeover, work thresholds, cursor that survives no-op runs, crash-resume convergence.
  • Lifecycle: CAS decide; one-transaction approve-and-apply with target re-validation (stale target ⇒ proposal expires, spine untouched — pinned by full-DB hash); 30-day timeout expiry; promotion explicit-only; unreviewed proposals expire, never auto-apply.
  • Retrieval: tier='hot' filter in default latest-mode only — as_of queries never filter tier; include_cold=True opt-out; byte-identical when nothing is cold.
  • Surfaces: lean-memory-maintain CLI (dry-run default) + cron recipe; memory_clear lease-refusal; 4 MCP tools with identical signatures on all three servers (core stdio, console stdio the plugin ships, console HTTP) via 4 new EngineGateway methods; review-memory-maintenance prompt + plugin/commands/review-memory.md (both forbid agent-initiated decisions); opt-in auto-spawn (LM_MAINT_AUTO=1, exact Popen primitives, fd 1 never inherited).

Verification

  • Core suite 281 passed, 1 skipped (baseline 140 — +141 new tests); console suite 138 passed. The four protected pins (test_spine, test_asof_sparse, test_functional_slot_supersession, test_search_now) and bench/ untouched.
  • All 13 spec §10 test-plan items executably pinned, including the headline apply-path as-of grid (store-predicate id-sets bit-identical for all T < t_a after approving one of each proposal kind) with a fault-injection meta-test proving the grid catches a backdated summary.
  • Process: each task implemented by a fresh subagent + independently spec/quality reviewed (two fix loops: lease-claim atomicity + no-op cursor preservation; console sanitizer fingerprint); final whole-branch review returned Ready to merge — 0 Critical, 0 Important; 19 accumulated minors triaged non-blocking, 4 recorded as WP10b carry-ins.

Merge note

Per the packet decision in docs/superpowers/workpackets.md, WP10a is post-launch work: merge is gated on WP1 (v0.1.3 tag + launch execution). The feature is default-off and touches nothing on the first-run path (pinned), so merging earlier is safe if the gate is consciously waived.

🤖 Generated with Claude Code

https://claude.ai/code/session_0198CpP3tf1SbtDGaRX2NWvJ

Wuesteon and others added 14 commits July 16, 2026 19:14
… claim packet

Spec carries a two-round adversarial verification record (46 findings vs
rev 1; independent 15-finding round vs rev 2 — the transitive
duplicate-chain resurrection blocker is fixed in rev 3). Registers WP10a/
WP10b in the roadmap, amends the consolidation anti-goal to hosted-only,
and claims WP10a (merge stays gated on WP1 launch).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198CpP3tf1SbtDGaRX2NWvJ
Foundation for the sleep-time maintenance job (design spec §4.0, §7.1).

- busy_timeout: SqliteStore gains busy_timeout_ms=1500 (kw-only); _connect
  issues PRAGMA busy_timeout. Maintenance opens stores with 5000 ms.
- batch(): BEGIN IMMEDIATE unit-of-work with commit suppression routed through
  a single _commit() helper (all existing mutators converted), one COMMIT at
  exit, ROLLBACK on exception, non-re-entrant. execute() only.
- retire_duplicate(loser, survivor): two-surface is_latest flip +
  superseded_by, valid_to untouched. Chain invariant maintained two ways —
  resolve survivor to live canonical (depth 1) and re-point existing open
  losers of the loser — so a transitive B->A->D retirement can't resurrect B.
  Rejects survivor==loser.
- set_tier / get_embedding (float32 round-trip, no re-embed) /
  iter_latest_facts (id high-water) / iter_slots_touched_since (new-facts-
  since-cursor -> DISTINCT slots; catches dups on quiet slots).

Store ABC updated with the six new abstract methods.

Tests: tests/test_store_maintenance_verbs.py (15, offline) — two-surface sync,
batch atomicity, get_embedding round-trip, cursor gap, transitive chain
invariant. Full suite 155 passed, 1 skipped; four pinned files untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First persisted-format change. Restructures _init_schema into a
user_version-gated migration: the always-run SCHEMA_SQL blob (all
IF-NOT-EXISTS) now carries the three maintenance tables
(fact_derivation, maintenance_run, maintenance_proposal) + indexes,
while the non-idempotent `ALTER TABLE fact ADD COLUMN record_kind`
lives ONLY in the `if user_version < 2:` branch, stamping 2 after and
never lowering a newer stamp. Threads record_kind through the Fact
dataclass, add_fact's INSERT, and _row_to_fact.

Adds pure ledger/proposal CRUD to the Store ABC + SqliteStore
(create_run/heartbeat_run/finish_run/get_live_run, stage_proposal/
get_proposal/list_proposals) — no decide/apply logic. The maintenance_run
INSERT is the atomic lease claim via the ux_run_live partial-unique index.

Ships the WP6 migration obligation: a checked-in tiny v1-format fixture
DB (built by a committed deterministic script) + a migration regression
test that opens it, upgrades once, and REOPENS cleanly — pinning the
ALTER-idempotence trap with a real second open. Updates the console
EXPECTED_SCHEMA_FINGERPRINT for the new CREATE TABLE lines.

Core: 169 passed, 1 skipped. Console: 125 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Restore the ingest-commutation condition (spec §3.1.3), fixing the two
empirically-demonstrated rev-1 wrong answers (resurrection, stale summary; §14).

Hook 1 — duplicate-cascade in supersede_fact (§4.0): after closing `old`, close
its OPEN retired duplicates (superseded_by=old, valid_to NULL) at the same V and
RETURN [old_id] + cascade-closed ids. Single level suffices — Task 1's chain
invariant keeps every open duplicate pointing directly at the live survivor. One
transaction. supersede_fact return type None → list[str] (ABC updated).

Hook 2 — summary-staleness cascade in _apply_supersession (§4.3): feed the FULL
closed set returned by every supersede_fact call (explicit targets PLUS
duplicate-cascade-closed rows — the load-bearing seam) into
find_summaries_derived_from; each still-latest derived summary flips is_latest=0,
valid_to=new.valid_at, invalidated_by=new.id (+vec mirror). memory.py stays free
of raw SQL.

New store methods (schema-v2 derivation surface): add_derivation,
find_summaries_derived_from, invalidate_summary — all double-guarded on
is_latest=1 so a re-fire is a no-op.

Both hooks are exact no-ops until maintenance has produced rows — first-run path
byte-unchanged (§10.10 pinned).

Tests: new tests/test_ingest_commutation.py (9) — resurrection, transitive
B→A→D, stale summary (vacuity-guarded), cascade-closed-duplicate seam, no-op pin.
Suite: 178 passed, 1 skipped; four named pins green untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New src/lean_memory/maintain/ package (design spec §3.6, §4.1–§4.4):

- MaintenanceConfig: frozen dataclass, all §3.6/§6.6 defaults; evict_threshold
  a documented conservative/tunable 0.15 (spec pins none); config_hash() =
  sha256 of canonical JSON.
- score.value(fact, now): §4.4 standing value; recency anchor VERBATIM the
  retriever's (last_access or valid_at), DECAY_LAMBDA imported (never re-derived)
  — backfills score stale exactly as the ranker de-ranks them.
- Summarizer seam: ExtractiveStubSummarizer (deterministic, pinned) +
  OllamaSummarizer behind an [llm] import guard; LM_FORCE_STUBS honored.
- Four transforms as pure Store-ABC functions: dedup_exact (AUTO — value-
  preserving normalization only, argmin(valid_at)/min-id survivor, cluster
  usage-stats merge, per-slot batch()), dedup_near + summarize + evict_propose
  (PROPOSE — zero spine writes), evict_auto (strict band). run_transforms
  enforces the §4.4 intra-run ordering: stage all proposals over the pre-
  transform snapshot first, then autos excluding staged targets; shared
  proposal budget with reported (never silent) truncation.

Store: add merge_usage_stats (ABC + SqliteStore) for the DEDUP-EXACT survivor
stats merge — plain UPDATE, batch-aware.

Tests (offline, +34): test_maintenance_transforms.py (config/score/summarizer/
normalization/dedup/evict/ordering/budget/zero-spine-delta) and
test_maintenance_asof_grid.py (§10.1 store-predicate invariance over a T grid
under autos+staging; §10.8 ranking-delta pin; no inverted intervals). Full
suite: 212 passed, 1 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MaintenanceRunner drives one maintenance run on one namespace store:
atomic lease claim (ux_run_live) with fresh-skip / stale-takeover /
lost-race paths; OR-combined work thresholds (facts, salience, age) with
a cheap below-threshold no-op; advance-before-write cursor high-water and
the iter_slots_touched_since cursor-gap fix; cross-run exclusion of facts
under review plus an identical-pending dedupe guard for crash idempotence;
heartbeats at phase and batch boundaries; takeover never rolls back.

run_transforms gains extra_exclude_ids + pending_signatures (threaded to
dedup_near/summarize as skip_signatures) — the runner's cross-run coupling,
no logic duplicated. 16 new tests (real subprocess lease race, cursor-gap
dedup, crash-resume convergence, heartbeat cadence); suite 228 passed, 1
skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three review findings on the maintenance runner:

1. Lease claim is now ONE transaction (spec §7.2): check → optional
   stale-abort → INSERT claim are wrapped in store.batch() (single
   BEGIN IMMEDIATE / COMMIT), IntegrityError caught OUTSIDE the batch
   after __exit__ rolls back. A failed claim now rolls back the
   stale-abort UPDATE too (prior run stays 'running', not stranded
   'aborted') — pinned by a new atomicity test.

2. A below-threshold no-op no longer advances the cursor: it carries
   the last work-doing run's cursor forward, so a duplicate landing on
   a quiet slot stays in the next real run's iter_slots_touched_since
   window and is eventually deduped — pinned by a new regression test.

3. Added a last_finished_run(namespace) read verb to the Store ABC +
   SqliteStore; the runner uses it instead of reaching into store._db.

Suite: 230 passed, 1 skipped (18 runner tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Proposal decide+apply lifecycle (maintain/lifecycle.py, new): approve|reject|edit|
promote with the §5 CAS, one-batch approve-and-apply (CAS -> re-validate targets ->
per-kind verbs -> applied_at), stale-target expiry that rolls the approve back so only
the expiry commits (spine untouched), lazy timeout expiry, and model/embedding work
computed before the batch window (§7.1). Store gains cas_decide_proposal /
mark_proposal_applied / expire_proposal.

Memory façade: maintain (dedicated 5000ms store, dry-run default = zero writes/no
lease), review_queue (grouped by entity, lazy timeout expiry), decide, promote, and
search(include_cold=). Runner + run_transforms + all five transforms gain a dry_run
flag (default False; Task 4/5 behavior byte-identical).

Retrieval: dense/sparse tier='hot' filter applied ONLY in default latest-mode
(as_of never filters tier; include_cold opts out); find_latest_in_slot deliberately
un-filtered so ingest supersession still sees cold facts.

Tests: proposal lifecycle (CAS double-decide across two handles, re-apply ->
already-applied, timeout + stale-target expiry with full-DB-hash spine invariance,
edited-approve human provenance + source=user re-score, summarize apply end-to-end +
old-summary supersession, evict+promote round-trip, dim-mismatch refusal, the §10.1
apply-path as-of grid re-run + a backdating fault-injection guard) and the
as_of x include_cold x tier matrix. Full offline suite: 251 passed, 1 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198CpP3tf1SbtDGaRX2NWvJ
Memory._maintenance_store() reuses the identical namespace-sanitize
expression, adding a third line matching the tripwire's pattern. Verified
semantics unchanged (same _SAFE_NS regex, same 'or "default"' fallback),
so the console config.py mirror stays valid — this is the tripwire's
designed verify-then-bump workflow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198CpP3tf1SbtDGaRX2NWvJ
Task 7 (spec §6.1, §7.1, §7.3).

CLI (new maintain/cli.py + pyproject script): --root [--namespace] [--apply]
[--auto-only] [--json]. Dry-run is the default (zero writes, no lease); --apply
runs the auto band + stages proposals; --auto-only stages nothing. Reuses
Memory.maintain() per namespace (dedicated 5000 ms store). Human report to its
own stdout; --json for machines. --root falls back to $LM_DATA_ROOT.

--auto-only threads down (default False) through run_transforms / runner.run /
Memory.maintain, skipping the propose phases — all existing behavior/tests
preserved.

memory_clear now refuses (returns an explanatory string, does not raise) while a
live fresh maintenance lease is held, via the shared runner.live_lease_is_fresh
(reuses _heartbeat_is_fresh + _STALE_FLOOR_S — staleness rule not duplicated); a
stale/absent lease clears as before. The runner stops cleanly at a batch boundary
when its namespace file is unlinked mid-run (os.path.exists on store.path →
internal _NamespaceCleared signal → RunReport aborted_file_gone; can't mask real
errors). §7.3 residual sliver documented in the tool docstring.

Tests (new test_maintenance_cli.py, 12): cross-process CLI --apply vs a live
in-process writer interleave with no unhandled 'database is locked'; dry-run
writes nothing; --auto-only stages nothing but merges; --json fields;
missing-root nonzero; memory_clear refusal fresh vs stale; runner mid-run
unlink stop. Full suite 263 passed, 1 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Implementation surfaced the decisive fact the earlier boundary fix missed:
both console MCP surfaces (observe_mcp.py stdio + routes/mcp.py HTTP) write
only through EngineGateway, so the spec's v1 MCP tools cannot ship without
the four gateway methods. Spec/plan/workpackets re-aligned; WP10b consumes.

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

The four sleep-time maintenance tools (§6.3) — memory_maintenance_run (dry-run
by default, symmetric with the CLI), memory_maintenance_status (model-free
ledger read), memory_review_queue, memory_review_decide — registered with
identical names/signatures on all three MCP surfaces: core stdio
(mcp_server.py), console stdio (observe_mcp.py), console HTTP mount
(routes/mcp.py). Both console surfaces reach the engine only through
EngineGateway's four new methods (maintain/review_queue/decide/promote), each
wrapping retry_busy + the per-namespace asyncio lock + the single worker thread
exactly like add/search (§8).

Status is provably model-free: a new maintain/mcp_support.py owns the raw SQL
for the ledger read + staleness check, so mcp_server.py never calls _mem() for
status (pinned by a test that makes the builder raise). The review-workflow
prompt (review-memory-maintenance) is registered on the console stdio server
and mirrored in plugin/commands/review-memory.md; both forbid the client agent
from deciding without an explicit user verdict (§6.4). Opt-in auto-spawn
(LM_MAINT_AUTO=1, default OFF) fires once per process on the first tool call
with the exact §6.5 Popen primitives — fd 1 is never inherited.

Parity pin updated to the exact six-tool set on both console surfaces; server
manifests reconciled and pinned against the servers' actual tool surface (the
v0.1.3 lesson). Both suites green: core 281 passed + 1 skipped, console 138.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
README "Sleep-time maintenance & review" section (user story, CLI, cron
recipe, Claude Code review workflow, safety story); CHANGELOG Unreleased
entry; ARCHITECTURE Phase 3 status table; WP10a packet row → implementation
complete, awaiting review + merge (merge still gated on WP1 launch).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Wuesteon
Wuesteon merged commit d93b326 into main Jul 16, 2026
6 checks passed
@Wuesteon
Wuesteon deleted the wp10a-sleep-maintenance branch July 16, 2026 16:01
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.

1 participant