diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea100be..a494538 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -236,11 +236,63 @@ jobs: print("smoke ok", d["metric"], "questions", d["total_questions"]) PY + bench-cli-smoke: + name: Benchmark CLI Isolation Smoke + runs-on: ubuntu-24.04 + needs: [build] + steps: + - uses: actions/checkout@v4 + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y protobuf-compiler libclang-dev + + - name: Install Rust + uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.97" + + - name: Cache cargo registry + uses: Swatinem/rust-cache@v2 + with: + shared-key: "bench-cli-smoke" + + - name: Build daemon, CLI, and bench + run: cargo build -p memory-daemon -p memory-cli -p memory-bench + + - name: Live-backend locomo smoke (spawn-per-conversation) + run: | + export PATH="$PWD/target/debug:$PATH" + cargo run -p memory-bench -- locomo \ + --dataset benchmarks/fixtures/locomo-smoke.json \ + --backend cli \ + --scorer mock \ + --isolation daemon-per-conversation \ + --output /tmp/locomo-cli-smoke.json + python3 - <<'PY' + import json + d = json.load(open("/tmp/locomo-cli-smoke.json")) + assert d["conversations"] == 1, d + assert d["isolation"] == "per-conversation daemon", d + assert d["metric"] == "context_hit_rate", d + conv = d["per_conversation"][0] + assert "drain_wait_ms" in conv, conv + assert conv["drain_wait_ms"] >= 0 + print("cli smoke ok", d["isolation"], "drain_wait_ms", conv["drain_wait_ms"]) + PY + + - name: Isolation bleed test + env: + MEMORY_BENCH_LIVE: "1" + run: | + export PATH="$PWD/target/debug:$PATH" + cargo test -p memory-bench cli_isolated_daemons_do_not_bleed -- --nocapture # Summary job that depends on all other jobs ci-success: name: CI Success - needs: [fmt, clippy, test, build, doc, e2e, benchmark-smoke, release-guards] + needs: [fmt, clippy, test, build, doc, e2e, benchmark-smoke, bench-cli-smoke, release-guards] runs-on: ubuntu-24.04 if: always() steps: @@ -253,6 +305,7 @@ jobs: [[ "${{ needs.doc.result }}" != "success" ]] || \ [[ "${{ needs.e2e.result }}" != "success" ]] || \ [[ "${{ needs.benchmark-smoke.result }}" != "success" ]] || \ + [[ "${{ needs.bench-cli-smoke.result }}" != "success" ]] || \ [[ "${{ needs.release-guards.result }}" != "success" ]]; then echo "One or more jobs failed" exit 1 diff --git a/.planning/MILESTONES.md b/.planning/MILESTONES.md index 0dee9c5..088e657 100644 --- a/.planning/MILESTONES.md +++ b/.planning/MILESTONES.md @@ -8,11 +8,12 @@ and a repo whose backlog is public. **Spec:** `docs/plans/v3.2-prove-it-plan.md` -**Phases:** 59 Guardrails and Inventory (executing), 60 Real Numbers, 61 -Operate It, 62 Cross-encoder rerank (conditional on #39). +**Phases:** 59 Guardrails and Inventory (complete #45), 60 Real Numbers +(60-01 executing), 61 Operate It, 62 Cross-encoder rerank (conditional on #39). -**Known Gaps (now issues):** #39 LOCOMO run, #40 vector/topic quality, #41 -backfill, #42 install-service, #43 TOC rebuild, #44 cross-encoder. +**Known Gaps (issues labelled `v3.2`):** #39 LOCOMO run, #40 vector quality, +#47 topic quality, #41 backfill, #42 install-service, #43 TOC rebuild, +#48 uninstall/status, #44 cross-encoder. --- diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md index 6dbaeb2..171aab6 100644 --- a/.planning/PROJECT.md +++ b/.planning/PROJECT.md @@ -15,9 +15,9 @@ them provable. **Target work:** - Release pipeline guards (tag on main, crate version matches tag, all five platforms) — Phase 59 - Committed LOCOMO LLM-judge result on the real dataset — Phase 60 / #39 -- Quality fixtures for vector search and the topic graph — Phase 60 / #40 +- Quality fixtures for vector search (#40) and the topic graph (#47) — Phase 60 - Backfill, `install-service`, offline TOC rebuild, panic audit — Phase 61 / #41 #42 #43 -- Claude Code plugin registration + installer uninstall/status — Phase 61 +- Claude Code plugin registration + installer uninstall/status — Phase 61 / #48 - Cross-encoder rerank only if 60-02 says retrieval is the bottleneck — Phase 62 / #44 **Previous version:** v3.1.0 (Shipped 2026-09-01) — Make It True. No new @@ -37,8 +37,8 @@ The system implements a complete 6-layer cognitive stack with control plane, mul - Layer 6: Ranking Policy (salience, usage, novelty, lifecycle) + StaleFilter (time-decay, supersession) - Control: Retrieval Policy (intent routing, tier detection, fallbacks) + MemoryOrchestrator (RRF fusion, optional LLM rerank, explainability) - Dedup: InFlightBuffer + HNSW composite gate, configurable threshold, fail-open -- Installer: memory-installer crate with RuntimeConverter trait, 5 converters (Claude, Gemini, Codex, Copilot, generic skills), tool mapping tables -- Adapters: Claude Code, Gemini CLI, Copilot CLI, Codex CLI (via installer). OpenCode removed in v3.1 Phase 57 — the converter reported success and wrote nothing +- Installer: memory-installer crate with RuntimeConverter trait, converters for Claude, Gemini, Codex, Copilot, generic skills; tool mapping tables +- Adapters: Claude Code, Gemini CLI, Copilot CLI, Codex CLI (via installer). Supported surfaces are those four; registration for Gemini/Codex/Copilot is v3.3+ - Discovery: ListAgents, GetAgentActivity, agent-filtered topics - Testing: 1,205 workspace + 60 e2e cargo tests; 114 bats CLI tests; Tier 1 (Claude Code, Codex) gates PRs, Tier 2 (Gemini, Copilot) weekly - CI/CD: Dedicated E2E job + CLI matrix report; rust-toolchain pinned to 1.97 diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index f64a156..9900896 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -1,6 +1,6 @@ -# Requirements: Agent Memory v3.0 +# Requirements: Agent Memory -**Defined:** 2026-03-22 +**Defined:** 2026-03-22 (v3.0); extended 2026-09-01 (v3.2) **Core Value:** Agent can answer "what were we talking about last week?" without scanning everything ## v3.0 Requirements @@ -42,59 +42,105 @@ Requirements for the Competitive Parity & Benchmarks milestone. Each maps to roa - [x] **BENCH-07**: CI runs benchmark suite (non-blocking, skips LOCOMO without `--dataset` flag) - [x] **BENCH-08**: JSON + markdown report output for all benchmark types -## Future Requirements (v3.1+) +## v3.2 Requirements (Prove It) -- **ORCH-F01**: Cross-encoder reranking (requires new inference path in memory-embeddings) +Requirements for making v3.1's claims provable and operable. Each maps to +exactly one plan in `docs/plans/v3.2-prove-it-plan.md`. + +### Release pipeline (REL) + +- [x] **REL-01**: Tagged commit must be an ancestor of `origin/main` (59-01) +- [x] **REL-02**: `workspace.package.version` must equal the tag minus `v` (59-01) +- [x] **REL-03**: Any failed platform build → no GitHub release (59-01) +- [x] **REL-04**: Release body is the matching CHANGELOG section (59-01) + +### Benchmarks (BENCH) + +- [x] **BENCH-10**: Per-conversation isolation on `--backend cli` (60-01) +- [x] **BENCH-11**: Deterministic drain wait (poll checkpoints, no blind sleep) (60-01) +- [ ] **BENCH-12**: Committed `locomo_llm_judge` full-dataset result (60-02 / #39) +- [ ] **BENCH-13**: Layer switch `bm25|vector|hybrid` on the custom harness (60-03) + +### Quality evidence (QUAL) + +- [ ] **QUAL-01**: Semantic fixture set, ≥15 tests (60-03 / #40) +- [ ] **QUAL-02**: Topic clustering purity + ARI artifact (60-03 / #47) +- [ ] **QUAL-03**: README "Solid" rows cite committed artifacts (60-03) + +### Operate it (OPS) + +- [ ] **OPS-01**: `admin backfill-index` resumable, idempotent (61-01 / #41) +- [ ] **OPS-02**: `install-service`/`uninstall-service` macOS+Linux (61-02 / #42) +- [ ] **OPS-03**: Zero fallible `unwrap` on request paths (61-03) +- [ ] **OPS-04**: Hostile-input e2e over all RPCs (61-03) +- [ ] **OPS-05**: `admin rebuild-toc` real (61-04 / #43) + +### Installer (INST) + +- [ ] **INST-01**: Claude Code plugin registration (CREG/META) (61-05) +- [ ] **INST-02**: `memory-installer uninstall` (61-05 / #48) +- [ ] **INST-03**: `memory-installer status` (61-05 / #48) + +## Future Requirements (v3.3+) + +- **ORCH-F01**: Cross-encoder reranking — Phase 62 *if* 60-02 shows retrieval is the bottleneck (#44) - **CLI-F01**: REST/HTTP endpoint wrapping CLI commands - **CLI-F02**: Python SDK wrapping CLI binary - **BENCH-F01**: Continuous benchmark regression tracking in CI +- **REG-F01**: Gemini/Codex/Copilot plugin registration +- **INST-F01**: `--for all` / `--all` installer flags +- **OPS-F01**: Windows service install; true double-fork daemonization ## Out of Scope | Feature | Reason | |---------|--------| -| REST/HTTP endpoint | Future milestone — CLI-first for v3.0 | +| REST/HTTP endpoint | Future milestone — CLI-first | | Python SDK | Future milestone — wraps CLI | | Memory views UI | Future milestone | -| Cross-encoder reranking | Requires new inference path in memory-embeddings; extension point only | +| Cross-encoder reranking | Conditional Phase 62; extension point only until then | | Multi-agent shared memory changes | Shipped in v2.1 | ## Traceability +### v3.0 (complete) + | Requirement | Phase | Status | |-------------|-------|--------| -| ORCH-01 | Phase 51 | Complete | -| ORCH-02 | Phase 51 | Complete | -| ORCH-03 | Phase 51 | Complete | -| ORCH-04 | Phase 51 | Complete | -| ORCH-05 | Phase 51 | Complete | -| ORCH-06 | Phase 51 | Complete | -| ORCH-07 | Phase 51 | Complete | -| ORCH-08 | Phase 51 | Complete | -| CLI-01 | Phase 52 | Complete | -| CLI-02 | Phase 52 | Complete | -| CLI-03 | Phase 52 | Complete | -| CLI-04 | Phase 52 | Complete | -| CLI-05 | Phase 52 | Complete | -| CLI-06 | Phase 52 | Complete | -| CLI-07 | Phase 52 | Complete | -| CLI-08 | Phase 52 | Complete | -| CLI-09 | Phase 52 | Complete | -| CLI-10 | Phase 52 | Complete | -| BENCH-01 | Phase 53 | Complete | -| BENCH-02 | Phase 53 | Complete | -| BENCH-03 | Phase 53 | Complete | -| BENCH-04 | Phase 53 | Complete | -| BENCH-05 | Phase 53 | Complete | -| BENCH-06 | Phase 53 | Complete | -| BENCH-07 | Phase 53 | Complete | -| BENCH-08 | Phase 53 | Complete | +| ORCH-01..08 | Phase 51 | Complete | +| CLI-01..10 | Phase 52 | Complete | +| BENCH-01..08 | Phase 53 | Complete | + +### v3.2 + +| Requirement | Plan | Status | +|-------------|------|--------| +| REL-01 | 59-01 | Complete (#45) | +| REL-02 | 59-01 | Complete (#45) | +| REL-03 | 59-01 | Complete (#45) | +| REL-04 | 59-01 | Complete (#45) | +| BENCH-10 | 60-01 | In progress | +| BENCH-11 | 60-01 | In progress | +| BENCH-12 | 60-02 | Open (#39) | +| BENCH-13 | 60-03 | Open (#40) | +| QUAL-01 | 60-03 | Open (#40) | +| QUAL-02 | 60-03 | Open (#47) | +| QUAL-03 | 60-03 | Open | +| OPS-01 | 61-01 | Open (#41) | +| OPS-02 | 61-02 | Open (#42) | +| OPS-03 | 61-03 | Open | +| OPS-04 | 61-03 | Open | +| OPS-05 | 61-04 | Open (#43) | +| INST-01 | 61-05 | Open | +| INST-02 | 61-05 | Open (#48) | +| INST-03 | 61-05 | Open (#48) | **Coverage:** -- v3.0 requirements: 26 total -- Mapped to phases: 26 +- v3.0 requirements: 26 total, all complete +- v3.2 requirements: 19 total, 4 complete (REL), 15 open - Unmapped: 0 ✓ --- *Requirements defined: 2026-03-22* -*Last updated: 2026-03-22 after spec review* +*Last updated: 2026-09-01 — v3.2 IDs added from the adopted Prove It plan* + diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 27abccd..aa56a07 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -13,7 +13,7 @@ - ✅ **v2.7 Multi-Runtime Portability** — Phases 45-50 (shipped 2026-03-22) - **v3.0 Competitive Parity & Benchmarks** — Phases 51-53 + Phase 51.5 (shipped 2026-05-14; Phase 53 merged as #30) - ✅ **v3.1 Make It True** — Phases 54-58 (shipped 2026-09-01 as v3.1.0) -- **v3.2 Prove It** — Phases 59-62 (in progress; Phase 59 executing 2026-09-01) +- **v3.2 Prove It** — Phases 59-62 (Phase 59 complete 2026-09-01; Phase 60 executing) ## Phases @@ -230,7 +230,7 @@ Phases execute in numeric order: 51 -> 51.5 (merged out-of-band) -> 52 -> 53 | v2.7 Multi-Runtime Portability | 45-50 | 11/11 | Complete | 2026-03-22 | | v3.0 Competitive Parity | 51-53 + 51.5, 53.5 | 10/10 | Complete | 2026-05-14 | | v3.1 Make It True | 54-58 | 14/14 | Complete | 2026-09-01 | -| v3.2 Prove It | 59-62 | 3/13 | In progress | Phase 59 executing | +| v3.2 Prove It | 59-62 | 3/13 | In progress | Phase 59 complete; 60-01 executing | --- @@ -315,17 +315,17 @@ v3.1 made the claims true. v3.2 makes them provable: a real benchmark number, evidence behind every "Solid", and a daemon someone can run for a week. Nothing on this list is a new capability except Phase 62, which is conditional. -### Phase 59: Guardrails and Inventory (3/3 plans) — IN EXECUTION 2026-09-01 +### Phase 59: Guardrails and Inventory (3/3 plans) — COMPLETE 2026-09-01 (#45) - [x] 59-01: Release pipeline checks (ancestor of main, crate version, all five platforms, CHANGELOG notes) -- [x] 59-02: Orphan branch triage (`docs/plans/phase-59-orphan-branch-triage.md`) -- [x] 59-03: Planning truth (PROJECT.md, ROADMAP, STATE, GitHub issues #39–#44) +- [x] 59-02: Orphan branch triage (`docs/plans/phase-59-orphan-branch-triage.md`); OpenCode branch deleted +- [x] 59-03: Planning truth (PROJECT.md, ROADMAP, STATE, 8 GitHub issues labelled `v3.2`) -### Phase 60: Real Numbers (0/3) +### Phase 60: Real Numbers (0/3) — 60-01 executing -- [ ] 60-01: Live-backend isolation for `memory-bench locomo --backend cli` +- [ ] 60-01: Live-backend isolation for `memory-bench locomo --backend cli` (this PR) - [ ] 60-02: The run — maintainer, needs API key + documented machine (#39) -- [ ] 60-03: Vector and topic quality fixtures (#40) +- [ ] 60-03: Vector (#40) and topic (#47) quality fixtures ### Phase 61: Operate It (0/5) @@ -333,11 +333,11 @@ Nothing on this list is a new capability except Phase 62, which is conditional. - [ ] 61-02: `install-service` launchd/systemd (#42) - [ ] 61-03: Panic audit (`unwrap()`/`expect()` on request paths) - [ ] 61-04: Offline TOC rebuild (#43) -- [ ] 61-05: Installer register / uninstall / status (CREG/META from 59-02) +- [ ] 61-05: Installer register / uninstall / status (CREG/META from 59-02; #48) ### Phase 62: Cross-encoder rerank (conditional) (#44) - [ ] Only if 60-02 shows retrieval, not generation, is the bottleneck -*Updated: 2026-09-01 — v3.1.0 released; v3.2 Prove It adopted; Phase 59 executing* +*Updated: 2026-09-02 — Phase 59 complete (#45); expanded v3.2 plan adopted; 60-01 executing* diff --git a/.planning/STATE.md b/.planning/STATE.md index bb194ec..c9b258f 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -3,11 +3,11 @@ gsd_state_version: 1.0 milestone_name: Prove It status: executing stopped_at: null -last_updated: "2026-09-01T23:30:00.000Z" -last_activity: 2026-09-01 — v3.1.0 released; v3.2 adopted; Phase 59 Guardrails and Inventory executing +last_updated: "2026-09-02T01:40:00.000Z" +last_activity: 2026-09-02 — Phase 60-01 live-backend isolation verified (spawn-per-conversation, checkpoint drain, CLI smoke) progress: total_phases: 4 - completed_phases: 0 + completed_phases: 1 total_plans: 13 completed_plans: 3 percent: 23 @@ -20,15 +20,15 @@ progress: See: .planning/PROJECT.md (updated 2026-09-01) **Core value:** Agent can answer "what were we talking about last week?" without scanning everything -**Current focus:** v3.2 Phase 59 — Guardrails and Inventory. Release pipeline cannot repeat the stale-tag incident; March gsd/ line inventoried; planning docs match v3.1.0 shipped. +**Current focus:** v3.2 Phase 60-01 live-backend isolation ready to merge. Phase 59 Guardrails and Inventory is on `main` (#45). ## Current Position -Phase: 59 of 62 (Guardrails and Inventory) -Status: v3.1.0 shipped 2026-09-01 (5 of 5 platforms). v3.2 Prove It adopted. Phase 59 in execution. -Last activity: 2026-09-01 — issues #39–#44 opened; release guards + orphan triage + PROJECT.md rewrite +Phase: 60 of 62 (Real Numbers) — plan 60-01 verified locally +Status: v3.1.0 shipped 2026-09-01 (5 of 5 platforms). v3.2 Prove It adopted (expanded spec). Phase 59 complete. +Last activity: 2026-09-02 — 60-01 spawn-per-conversation + GetIndexCheckpoints drain + live CLI smoke + isolation bleed test -Progress: [██░░░░░░░░] 3/13 plans (Phase 59). Phases 60–62 not started. +Progress: [███░░░░░░░] 3/13 plans (Phase 59 complete). Phase 60-01 verified, awaiting merge. ## Out-of-band Work @@ -36,23 +36,26 @@ Progress: [██░░░░░░░░] 3/13 plans (Phase 59). Phases 60–62 | PR | What | Status | |---|---|---| -| _(this branch)_ | Phase 59 Guardrails and Inventory | Open | +| _(this branch)_ | Phase 60-01 live-backend isolation + remaining 59 gaps | Open | ### Open issues (the v3.2 backlog) | Issue | What | Phase | |---|---|---| | #39 | Real LOCOMO LLM-judge run | 60-02 | -| #40 | Vector and topic-graph quality fixtures | 60-03 | +| #40 | Vector quality fixtures | 60-03 / QUAL-01 | +| #47 | Topic-graph quality (purity + ARI) | 60-03 / QUAL-02 | | #41 | Backfill BM25/vector for pre-v3.1 events | 61-01 | | #42 | `install-service` (launchd/systemd) | 61-02 | | #43 | Offline TOC rebuild | 61-04 | +| #48 | Installer uninstall + status | 61-05 | | #44 | Cross-encoder rerank (conditional) | 62 | ### Recently Merged | PR | What | Merged | |---|---|---| +| #45 | Phase 59 Guardrails and Inventory | 2026-09-01 | | #38 | docs: correct the "no tags" claim and record the release blocker | 2026-08-31 | | #37 | chore(v3.1): release prep — version 3.1.0, changelog, working release archives | 2026-08-31 | | #36 | Phase 57 Shop Window & Positioning | 2026-08-31 | @@ -65,7 +68,8 @@ Progress: [██░░░░░░░░] 3/13 plans (Phase 59). Phases 60–62 ## Decisions - v3.2 scope: Prove It — no new capabilities except conditional Phase 62 -- Maintainer decisions 2026-09-01: cherry-pick March export/import + CREG/META by feature (not by branch); skip OpenCode converter; blog now / product posts after #39; daemonization is unit files not double-fork +- Maintainer decisions 2026-09-01 (all four accepted): cherry-pick March export/import + CREG/META by feature; skip OpenCode (branch deleted); blog now / product posts after #39; daemonization is unit files not double-fork; backfill is stopped-daemon CLI only +- Canonical spec: `docs/plans/v3.2-prove-it-plan.md` (expanded GSD form) - v3.1.0 first tag push shipped `acc7294` (Cargo.toml 2.7.0) for 17 minutes — Phase 59-01 exists because of that - March `gsd/phase-56-import-bootstrap` is 88 ahead / 20 behind; naïve merge regresses orchestrator and bench. Inventory: `docs/plans/phase-59-orphan-branch-triage.md` - HOLD comparison marketing until #39 lands a `locomo_llm_judge` artifact diff --git a/.planning/phases/60-real-numbers/60-01-PLAN.md b/.planning/phases/60-real-numbers/60-01-PLAN.md new file mode 100644 index 0000000..a9e9f9f --- /dev/null +++ b/.planning/phases/60-real-numbers/60-01-PLAN.md @@ -0,0 +1,102 @@ +--- +phase: 60-real-numbers +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - proto/memory.proto + - crates/memory-storage/src/db.rs + - crates/memory-service/src/ingest.rs + - crates/memory-client/src/client.rs + - crates/memory-client/src/lib.rs + - crates/memory-daemon/src/cli.rs + - crates/memory-daemon/src/commands.rs + - crates/memory-daemon/src/main.rs + - crates/memory-daemon/Cargo.toml + - crates/memory-bench/src/cli.rs + - crates/memory-bench/src/runner.rs + - crates/memory-bench/src/main.rs + - crates/memory-search/src/searcher.rs + - crates/memory-indexing/src/pipeline.rs + - .github/workflows/ci.yml + - docs/benchmarks.md +autonomous: true +requirements: [BENCH-10, BENCH-11] +must_haves: + truths: + - "`memory-bench locomo --backend cli` defaults to daemon-per-conversation isolation" + - "Two isolated daemons do not retrieve each other's turns" + - "Drain wait polls GetIndexCheckpoints; no std::thread::sleep in the cli-backend path" + - "Result JSON records isolation and per-conversation drain_wait_ms" + - "CI job bench-cli-smoke runs the 1-conversation fixture against a spawned daemon" + artifacts: + - path: proto/memory.proto + provides: GetIndexCheckpoints RPC + contains: rpc GetIndexCheckpoints + - path: crates/memory-bench/src/runner.rs + provides: IsolatedDaemon + wait_for_drain + contains: DaemonPerConversation + - path: .github/workflows/ci.yml + provides: bench-cli-smoke job + contains: bench-cli-smoke +--- + + +Live-backend LOCOMO isolation: spawn one daemon per conversation, poll GetIndexCheckpoints instead of sleeping, record drain_wait_ms. + + + + + + Task 1: GetIndexCheckpoints RPC + proto/memory.proto, crates/memory-storage/src/db.rs, crates/memory-service/src/ingest.rs, crates/memory-client/src/client.rs, crates/memory-client/src/lib.rs + + Add read-only GetIndexCheckpoints RPC. Storage exposes outbox_head(). Service reads index_bm25 / index_vector / index_combined checkpoints plus the outbox head. Client wraps the RPC. Missing checkpoints are omitted (fresh store is empty). + + + cargo test -p memory-service --lib ingest -- --test-threads=1 + cargo test -p memory-storage --lib test_outbox_head + + Empty store returns outbox_head=0 and no checkpoints; ingest + put_checkpoint("index_bm25") is visible via the RPC. + + + + Task 2: Daemon pid-file + query checkpoints + crates/memory-daemon/src/cli.rs, crates/memory-daemon/src/commands.rs, crates/memory-daemon/src/main.rs, crates/memory-daemon/Cargo.toml + + Add --pid-file on start/stop so spawned daemons do not clobber a user's PID file. Add `memory-daemon query checkpoints` that prints GetIndexCheckpoints as JSON {checkpoints, outbox_head}. + + + cargo test -p memory-daemon --lib test_cli_query_checkpoints test_cli_start_pid_file test_resolve_pid_file_override + + CLI parses `query checkpoints` and `--pid-file`; JSON shape matches the bench parser. + + + + Task 3: IsolatedDaemon + drain poll + crates/memory-bench/src/runner.rs, crates/memory-bench/src/cli.rs, crates/memory-bench/src/main.rs, crates/memory-bench/src/locomo.rs + + Isolation::{Shared, DaemonPerConversation}. Default for --backend cli is daemon-per-conversation. IsolatedDaemon: tempdir + free port + pid-file + health poll via query checkpoints. wait_for_drain polls until BM25 last_sequence >= outbox_head-1 (vector only if that checkpoint exists). poll_pause uses mpsc::recv_timeout, not thread::sleep. Record drain_wait_ms per conversation. --limit-questions for 60-02 dry-run. + + + cargo test -p memory-bench --lib + MEMORY_BENCH_LIVE=1 cargo test -p memory-bench cli_isolated_daemons_do_not_bleed -- --nocapture + + Unit tests cover drain_caught_up, parse_checkpoint_json, isolation labels, and (with MEMORY_BENCH_LIVE=1) two daemons do not bleed. + + + + Task 4: CI smoke + docs + .github/workflows/ci.yml, docs/benchmarks.md + + Add Linux-only bench-cli-smoke after build: locomo --backend cli --scorer mock --isolation daemon-per-conversation against locomo-smoke.json; assert isolation label and drain_wait_ms. Document spawn-per-conversation and the drain poll in docs/benchmarks.md. + + + grep -n bench-cli-smoke .github/workflows/ci.yml + grep -n "per-conversation daemon" docs/benchmarks.md + + CI job exists; docs describe isolation, drain poll, and the smoke command. + + + diff --git a/.planning/phases/60-real-numbers/60-CONTEXT.md b/.planning/phases/60-real-numbers/60-CONTEXT.md new file mode 100644 index 0000000..12d6435 --- /dev/null +++ b/.planning/phases/60-real-numbers/60-CONTEXT.md @@ -0,0 +1,23 @@ +# Phase 60: Real Numbers - Context + +**Gathered:** 2026-09-02 +**Status:** 60-01 in execution +**Source:** docs/plans/v3.2-prove-it-plan.md + +## Phase Boundary + +A committed `locomo_llm_judge` number and quality artifacts behind every +"Solid". 60-01 is the harness; 60-02 is the maintainer run; 60-03 is +vector/topic fixtures. + +## 60-01 decisions + +- Design A: spawn-per-conversation. Design B (AdminReset) rejected. +- `--isolation daemon-per-conversation` is the default for `--backend cli`. +- `--pid-file` on daemon start/stop so spawned daemons do not clobber a + user's PID file. +- GetIndexCheckpoints is read-only. Drain waits on BM25; vector is required + only when that checkpoint exists (the outbox pipeline currently registers + BM25 only). +- Poll interval is `mpsc::recv_timeout`, not `std::thread::sleep`. +- `--limit-questions` exists so 60-02 can dry-run. diff --git a/.planning/phases/60-real-numbers/60-VERIFICATION.md b/.planning/phases/60-real-numbers/60-VERIFICATION.md new file mode 100644 index 0000000..2d91ac1 --- /dev/null +++ b/.planning/phases/60-real-numbers/60-VERIFICATION.md @@ -0,0 +1,20 @@ +--- +phase: 60-real-numbers +verified: 2026-09-02 +status: 60-01-verified +--- + +# Phase 60: Real Numbers Verification + +## 60-01 (this PR) + +| # | Truth | Status | Evidence | +|---|-------|--------|----------| +| 1 | `--backend cli` isolation is per-conversation daemon | UNIT | `isolation_default_label`; locomo result `isolation` field | +| 2 | Two isolated daemons do not bleed | RUN | `cli_isolated_daemons_do_not_bleed` under `MEMORY_BENCH_LIVE=1` | +| 3 | Drain wait polls checkpoints | UNIT | `drain_caught_up_*`; `parse_checkpoint_json_roundtrip` | +| 4 | No `std::thread::sleep` in cli-backend path | CODE | `poll_pause` uses `recv_timeout` | +| 5 | CI `bench-cli-smoke` | CI | `.github/workflows/ci.yml` job after `build` | +| 6 | `drain_wait_ms` per conversation | CODE | `LocomoConversationResult.drain_wait_ms` | +| 7 | Query reader sees indexer commits | CODE | `TeleportSearcher::search` calls `reload()` | +| 8 | Sequence 0 advances BM25 checkpoint | UNIT | `test_process_batch_sequence_zero_advances_checkpoint` | diff --git a/CLAUDE.md b/CLAUDE.md index 6dfd468..fb2c3f6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,20 +93,22 @@ GitHub Actions workflows in `.github/workflows/`: ### Release Process -```bash -# Bump version -cargo set-version 0.2.0 +Do **not** tag `HEAD` of a local branch. Always tag an explicit SHA that is +already on `origin/main`. The full procedure, what the pipeline refuses, and +the zsh quoting trap (`^{commit}`, `#`) are in +[`docs/RELEASING.md`](docs/RELEASING.md). -# Commit and tag -git add -A && git commit -m "chore: release v0.2.0" -git tag -a v0.2.0 -m "Release v0.2.0" -git push origin main --tags +```bash +git fetch origin +SHA="$(git rev-parse origin/main)" +git tag -a vX.Y.Z "$SHA" -m "Release vX.Y.Z" +git push origin vX.Y.Z ``` -The release workflow automatically builds for: -- Linux x86_64 / ARM64 -- macOS Intel / Apple Silicon -- Windows x86_64 +Do **not** `git push origin main --tags`. The release workflow builds for +Linux x86_64 / ARM64, macOS Intel / Apple Silicon, and Windows x86_64, and +refuses to publish unless every platform succeeded and the crate version +matches the tag. ## GSD Workflow diff --git a/README.md b/README.md index d259cb1..63c2924 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,7 @@ is experimental. | Grips / provenance | **Solid** | Excerpts link back to the events they came from | | BM25 keyword search (Tantivy) | **Solid** | Exact tokens, no stemming (`jwt` does not match `JWTs`). Events indexed before v3.1 have empty `text_preview` and there is no backfill command — see [UPGRADING](docs/UPGRADING.md) and [#41](https://github.com/SpillwaveSolutions/agent-memory/issues/41) | | Vector search (HNSW + Candle) | **Solid** | Mechanism is wired; retrieval *quality* is not yet measured ([#40](https://github.com/SpillwaveSolutions/agent-memory/issues/40)). First daemon start downloads the embedding model; with no network the daemon warns and runs BM25-only | -| Topic graph | **Works** | Clustering quality is not benchmarked ([#40](https://github.com/SpillwaveSolutions/agent-memory/issues/40)) | +| Topic graph | **Works** | Clustering quality is not benchmarked ([#47](https://github.com/SpillwaveSolutions/agent-memory/issues/47)) | | Hybrid fusion + `RouteQuery` orchestration | **Works** | Wired end-to-end in Phase 54; explainability reports what actually ran | | LLM summarization / LLM rerank | **Experimental** | Needs an API key; fails open to the heuristic ranker and reports `rerank=heuristic` when it does | | Cross-project federated query | **Experimental** | Implemented; not performance-characterised | diff --git a/crates/memory-bench/src/cli.rs b/crates/memory-bench/src/cli.rs index 8635af9..42d2082 100644 --- a/crates/memory-bench/src/cli.rs +++ b/crates/memory-bench/src/cli.rs @@ -15,9 +15,13 @@ pub struct Cli { #[arg(long, global = true, default_value = "mock")] pub backend: String, - /// gRPC endpoint for `--backend cli`. + /// gRPC endpoint for `--backend cli` (ignored when isolation spawns a daemon). #[arg(long, global = true, default_value = "http://127.0.0.1:50051")] pub endpoint: String, + + /// Path to memory-daemon binary (used by `--isolation daemon-per-conversation`). + #[arg(long, global = true, default_value = "memory-daemon")] + pub daemon_bin: String, } /// Available benchmark subcommands. @@ -85,6 +89,12 @@ pub enum Commands { /// Path to baselines TOML file. #[arg(long, default_value = "benchmarks/baselines.toml")] baselines: String, + /// Isolation: `daemon-per-conversation` (default for `--backend cli`) or `shared`. + #[arg(long, value_parser = ["daemon-per-conversation", "shared"])] + isolation: Option, + /// Cap total questions across conversations (60-02 dry-run). + #[arg(long)] + limit_questions: Option, }, /// CI smoke: 1-conversation fixture + mock backend + mock judge. Smoke { diff --git a/crates/memory-bench/src/locomo.rs b/crates/memory-bench/src/locomo.rs index c686e89..292148f 100644 --- a/crates/memory-bench/src/locomo.rs +++ b/crates/memory-bench/src/locomo.rs @@ -90,6 +90,9 @@ pub struct LocomoConversationResult { pub score: f64, pub by_type: HashMap, pub questions: Vec, + /// Time spent polling index checkpoints after ingest. 0 on the mock path. + #[serde(default)] + pub drain_wait_ms: u64, } /// Aggregate across conversations. `metric` is the only name that may be @@ -432,6 +435,7 @@ pub fn evaluate_sample( score, by_type, questions, + drain_wait_ms: 0, } } @@ -442,6 +446,7 @@ pub fn aggregate_results( judge_label: &str, model: Option, temperature: Option, + isolation: &str, ) -> LocomoAggregateResult { let mut total_questions = 0; let mut total_correct = 0; @@ -475,13 +480,22 @@ pub fn aggregate_results( .collect(); let mut caveats = vec![ - "one isolated mock store per conversation (no cross-conversation bleed)".into(), + format!("isolation={isolation}"), format!( "metric is '{}' — do not quote as a published LOCOMO leaderboard number unless scorer is llm-judge with a pinned model", kind.metric_name() ), "dataset license is CC BY-NC 4.0; verify LICENSE.txt before commercial use".into(), ]; + if isolation.contains("shared") { + caveats.push( + "shared daemon: conversation N can retrieve conversation 1..=N-1; \ + numbers from this mode must not be committed" + .into(), + ); + } else { + caveats.push("one isolated store per conversation (no cross-conversation bleed)".into()); + } if kind == ScorerKind::Mock { caveats.push( "mock scorer is substring context_hit_rate over token-overlap retrieval; \ @@ -496,7 +510,7 @@ pub fn aggregate_results( temperature, model, dataset: dataset.to_string(), - isolation: "per-conversation temp store".into(), + isolation: isolation.to_string(), conversations: results.len(), total_questions, overall_score, @@ -665,7 +679,15 @@ mod tests { assert!( result.by_type.contains_key("temporal") || result.by_type.contains_key("multi_hop") ); - let agg = aggregate_results(&[result], ScorerKind::Mock, "fixture", "mock", None, None); + let agg = aggregate_results( + &[result], + ScorerKind::Mock, + "fixture", + "mock", + None, + None, + "per-conversation temp store", + ); assert_eq!(agg.metric, "context_hit_rate"); assert!(agg.caveats.iter().any(|c| c.contains("not comparable"))); } diff --git a/crates/memory-bench/src/main.rs b/crates/memory-bench/src/main.rs index 6c43098..00f0b0c 100644 --- a/crates/memory-bench/src/main.rs +++ b/crates/memory-bench/src/main.rs @@ -4,17 +4,30 @@ use std::path::Path; mod cli; use memory_bench::judge::{ApiJudge, Judge, MockJudge, ScorerKind}; -use memory_bench::runner::{BackendKind, MockStore, RunConfig}; +use memory_bench::runner::{BackendKind, IsolatedDaemon, Isolation, MockStore, RunConfig}; use memory_bench::{baseline, fixture, locomo, report, runner, scorer}; use scorer::BenchmarkReport; +fn resolve_bin(configured: &str) -> String { + let p = Path::new(configured); + if p.is_file() { + return configured.to_string(); + } + runner::find_bin(configured) + .map(|pb| pb.to_string_lossy().into_owned()) + .unwrap_or_else(|| configured.to_string()) +} + fn main() -> anyhow::Result<()> { let cli = cli::Cli::parse(); let backend = BackendKind::parse(&cli.backend)?; let config = RunConfig { - memory_bin: cli.memory_bin.clone(), + memory_bin: resolve_bin(&cli.memory_bin), + daemon_bin: resolve_bin(&cli.daemon_bin), endpoint: cli.endpoint.clone(), backend, + isolation: Isolation::Shared, + limit_questions: None, }; match cli.command { @@ -57,6 +70,8 @@ fn main() -> anyhow::Result<()> { top, compare, baselines: _baselines, + isolation, + limit_questions, } => { let kind = ScorerKind::parse(&scorer)?; if compare && kind == ScorerKind::Mock { @@ -65,10 +80,26 @@ fn main() -> anyhow::Result<()> { and must not share a table with published LLM-judge numbers" ); } + let isolation = match isolation.as_deref() { + Some(s) => Isolation::parse(s)?, + None if backend == BackendKind::Cli => Isolation::DaemonPerConversation, + None => Isolation::Shared, + }; + if backend == BackendKind::Cli && isolation == Isolation::Shared { + eprintln!( + "warning: --isolation shared: conversation N can retrieve 1..=N-1 \ + (cross-conversation bleed). Do not commit this number." + ); + } let judge: Box = match kind { ScorerKind::Mock => Box::new(MockJudge), ScorerKind::LlmJudge => Box::new(ApiJudge::from_env()?), }; + let config = RunConfig { + isolation, + limit_questions, + ..config + }; let aggregate = run_locomo(&dataset, judge.as_ref(), kind, top, &config)?; let json = serde_json::to_string_pretty(&aggregate)?; println!("{json}"); @@ -122,26 +153,39 @@ fn run_locomo( top: usize, config: &RunConfig, ) -> anyhow::Result { - let conversations = locomo::load_dataset(Path::new(dataset))?; + let mut conversations = locomo::load_dataset(Path::new(dataset))?; + if let Some(limit) = config.limit_questions { + let mut left = limit; + for conv in &mut conversations { + if left == 0 { + conv.qa.clear(); + } else if conv.qa.len() > left { + conv.qa.truncate(left); + left = 0; + } else { + left -= conv.qa.len(); + } + } + conversations.retain(|c| !c.qa.is_empty()); + } eprintln!( - "Loaded {} conversations from {} (backend={} scorer={})", + "Loaded {} conversations from {} (backend={} scorer={} isolation={})", conversations.len(), dataset, config.backend.as_str(), - kind.metric_name() + kind.metric_name(), + config.isolation.result_label(config.backend), ); let mut results = Vec::new(); for conv in &conversations { - // Fresh store per conversation — no shared-store bleed. match config.backend { BackendKind::Mock => { let store = locomo::ingest_sample_mock(conv); results.push(locomo::evaluate_sample(conv, &store, judge, top)); } BackendKind::Cli => { - locomo::ingest_sample_cli(conv, config)?; - results.push(evaluate_sample_cli(conv, config, judge, top)?); + results.push(run_locomo_cli_conversation(conv, config, judge, top)?); } } } @@ -162,18 +206,44 @@ fn run_locomo( &judge_label, model, temperature, + config.isolation.result_label(config.backend), )) } +fn run_locomo_cli_conversation( + conv: &locomo::LocomoSample, + config: &RunConfig, + judge: &dyn Judge, + top: usize, +) -> anyhow::Result { + match config.isolation { + Isolation::DaemonPerConversation => { + let daemon = IsolatedDaemon::spawn(&config.daemon_bin)?; + let mut isolated = config.clone(); + isolated.endpoint = daemon.endpoint.clone(); + locomo::ingest_sample_cli(conv, &isolated)?; + let drain_wait_ms = runner::wait_for_drain(&isolated.daemon_bin, &isolated.endpoint)?; + let mut result = evaluate_sample_cli(conv, &isolated, judge, top)?; + result.drain_wait_ms = drain_wait_ms; + daemon.stop()?; + Ok(result) + } + Isolation::Shared => { + locomo::ingest_sample_cli(conv, config)?; + let drain_wait_ms = runner::wait_for_drain(&config.daemon_bin, &config.endpoint)?; + let mut result = evaluate_sample_cli(conv, config, judge, top)?; + result.drain_wait_ms = drain_wait_ms; + Ok(result) + } + } +} + fn evaluate_sample_cli( sample: &locomo::LocomoSample, config: &RunConfig, judge: &dyn Judge, top: usize, ) -> anyhow::Result { - // Build a one-shot mock store from CLI retrieval *per question* by - // stuffing CLI hits into evaluate_sample would mix questions. Do it - // question-by-question and reuse locomo types. use locomo::{QuestionResult, TypeScore}; use std::collections::HashMap; @@ -236,6 +306,7 @@ fn evaluate_sample_cli( }, by_type, questions, + drain_wait_ms: 0, }) } diff --git a/crates/memory-bench/src/runner.rs b/crates/memory-bench/src/runner.rs index d41194b..cfa6414 100644 --- a/crates/memory-bench/src/runner.rs +++ b/crates/memory-bench/src/runner.rs @@ -7,8 +7,8 @@ use anyhow::{bail, Context, Result}; use serde_json::Value; use std::io::BufRead; use std::path::{Path, PathBuf}; -use std::process::Command; -use std::time::Instant; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; /// Which retrieval backend to drive. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -36,21 +36,65 @@ impl BackendKind { } } +/// How the CLI backend isolates conversations. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Isolation { + /// One shared daemon. Conversation N can see 1..=N-1. Debug only. + Shared, + /// Spawn `memory-daemon start --db-path --port ` per conversation. + DaemonPerConversation, +} + +impl Isolation { + pub fn parse(s: &str) -> Result { + match s { + "shared" => Ok(Self::Shared), + "daemon-per-conversation" => Ok(Self::DaemonPerConversation), + other => bail!("unknown isolation '{other}' (expected daemon-per-conversation|shared)"), + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Shared => "shared", + Self::DaemonPerConversation => "daemon-per-conversation", + } + } + + /// Value written to results.json `isolation`. + pub fn result_label(self, backend: BackendKind) -> &'static str { + match (backend, self) { + (BackendKind::Mock, _) => "per-conversation temp store", + (BackendKind::Cli, Self::DaemonPerConversation) => "per-conversation daemon", + (BackendKind::Cli, Self::Shared) => "shared daemon (cross-conversation bleed)", + } + } +} + /// Configuration for the benchmark runner. +#[derive(Debug, Clone)] pub struct RunConfig { /// Path to the memory binary (default: "memory"). pub memory_bin: String, + /// Path to the memory-daemon binary (isolation spawn). + pub daemon_bin: String, /// gRPC endpoint for CLI backend. pub endpoint: String, pub backend: BackendKind, + pub isolation: Isolation, + /// Cap total questions across conversations. + pub limit_questions: Option, } impl Default for RunConfig { fn default() -> Self { Self { memory_bin: "memory".to_string(), + daemon_bin: "memory-daemon".to_string(), endpoint: "http://127.0.0.1:50051".to_string(), backend: BackendKind::Mock, + isolation: Isolation::Shared, + limit_questions: None, } } } @@ -338,6 +382,261 @@ pub fn run_query_cli(query: &str, config: &RunConfig, top: usize) -> Result(); + let _ = rx.recv_timeout(POLL_INTERVAL); + drop(tx); +} + +/// Snapshot from `memory-daemon query checkpoints`. +#[derive(Debug, Clone)] +pub struct CheckpointSnapshot { + pub checkpoints: Vec, + pub outbox_head: u64, +} + +#[derive(Debug, Clone)] +pub struct CheckpointInfo { + pub index_type: String, + pub last_sequence: u64, + pub processed_count: u64, +} + +/// Drain is complete when BM25 (and vector, if present) has processed every +/// assigned outbox sequence. A missing vector checkpoint does not block: +/// the daemon's outbox pipeline currently registers only the BM25 updater. +pub fn drain_caught_up(snap: &CheckpointSnapshot) -> bool { + if snap.outbox_head == 0 { + return true; + } + let target = snap.outbox_head.saturating_sub(1); + let bm25 = snap.checkpoints.iter().find(|c| c.index_type == "bm25"); + let Some(bm25) = bm25 else { + return false; + }; + if bm25.processed_count == 0 || bm25.last_sequence < target { + return false; + } + match snap.checkpoints.iter().find(|c| c.index_type == "vector") { + Some(v) => v.processed_count > 0 && v.last_sequence >= target, + None => true, + } +} + +pub fn fetch_checkpoints(daemon_bin: &str, endpoint: &str) -> Result { + let output = Command::new(daemon_bin) + .args(["query", "--endpoint", endpoint, "checkpoints"]) + .output() + .with_context(|| format!("spawning `{daemon_bin} query checkpoints`"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!( + "query checkpoints failed at {endpoint}: status={} stderr={stderr}", + output.status + ); + } + let stdout = String::from_utf8_lossy(&output.stdout); + parse_checkpoint_json(&stdout).with_context(|| format!("parsing checkpoints JSON: {stdout}")) +} + +fn parse_checkpoint_json(stdout: &str) -> Result { + let v: Value = serde_json::from_str(stdout.trim())?; + let outbox_head = v + .get("outbox_head") + .and_then(|x| x.as_u64()) + .context("missing outbox_head")?; + let mut checkpoints = Vec::new(); + if let Some(arr) = v.get("checkpoints").and_then(|x| x.as_array()) { + for c in arr { + checkpoints.push(CheckpointInfo { + index_type: c + .get("index_type") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string(), + last_sequence: c.get("last_sequence").and_then(|x| x.as_u64()).unwrap_or(0), + processed_count: c + .get("processed_count") + .and_then(|x| x.as_u64()) + .unwrap_or(0), + }); + } + } + Ok(CheckpointSnapshot { + checkpoints, + outbox_head, + }) +} + +/// Poll GetIndexCheckpoints until BM25 is caught up, or 5 minutes. +pub fn wait_for_drain(daemon_bin: &str, endpoint: &str) -> Result { + let start = Instant::now(); + loop { + match fetch_checkpoints(daemon_bin, endpoint) { + Ok(snap) if drain_caught_up(&snap) => { + return Ok(start.elapsed().as_millis() as u64); + } + Ok(_) | Err(_) => { + if start.elapsed() > DRAIN_TIMEOUT { + bail!( + "index drain timed out after {}ms at {endpoint} \ + (BM25 checkpoint never reached outbox_head-1)", + DRAIN_TIMEOUT.as_millis() + ); + } + poll_pause(); + } + } + } +} + +/// Bind 127.0.0.1:0, return the port, drop the listener. +pub fn free_port() -> Result { + let listener = std::net::TcpListener::bind("127.0.0.1:0")?; + let port = listener.local_addr()?.port(); + drop(listener); + Ok(port) +} + +/// One daemon, one temp store, one free port. Drop/stop kills the child. +pub struct IsolatedDaemon { + child: Child, + pub endpoint: String, + _dir: tempfile::TempDir, + daemon_bin: String, + pid_file: PathBuf, +} + +impl IsolatedDaemon { + pub fn spawn(daemon_bin: &str) -> Result { + let dir = tempfile::tempdir().context("creating isolation tempdir")?; + let db = dir.path().join("db"); + std::fs::create_dir_all(&db)?; + let pid_file = dir.path().join("daemon.pid"); + let stderr_path = dir.path().join("daemon.stderr"); + let stderr_file = + std::fs::File::create(&stderr_path).context("creating daemon stderr log")?; + let port = free_port()?; + let mut child = Command::new(daemon_bin) + .args([ + "start", + "--db-path", + db.to_str().context("db path utf-8")?, + "--port", + &port.to_string(), + "--pid-file", + pid_file.to_str().context("pid path utf-8")?, + "--log-level", + "warn", + ]) + .stdout(Stdio::null()) + .stderr(Stdio::from(stderr_file)) + .spawn() + .with_context(|| format!("spawning `{daemon_bin} start`"))?; + + let endpoint = format!("http://127.0.0.1:{port}"); + let start = Instant::now(); + loop { + if let Some(status) = child.try_wait()? { + let log = std::fs::read_to_string(&stderr_path).unwrap_or_default(); + bail!("daemon exited before becoming healthy: {status}\n{log}"); + } + if fetch_checkpoints(daemon_bin, &endpoint).is_ok() { + break; + } + if start.elapsed() > HEALTH_TIMEOUT { + let log = std::fs::read_to_string(&stderr_path).unwrap_or_default(); + let _ = child.kill(); + bail!("daemon did not become healthy at {endpoint} within 300s\n{log}"); + } + poll_pause(); + } + + Ok(Self { + child, + endpoint, + _dir: dir, + daemon_bin: daemon_bin.to_string(), + pid_file, + }) + } + + pub fn stop(mut self) -> Result<()> { + let _ = Command::new(&self.daemon_bin) + .args([ + "stop", + "--pid-file", + self.pid_file.to_str().unwrap_or_default(), + ]) + .status(); + let deadline = Instant::now() + Duration::from_secs(15); + loop { + if self.child.try_wait()?.is_some() { + break; + } + if Instant::now() > deadline { + let _ = self.child.kill(); + let _ = self.child.wait(); + break; + } + poll_pause(); + } + Ok(()) + } +} + +impl Drop for IsolatedDaemon { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +/// Locate `memory` / `memory-daemon` for live isolation tests. +pub fn find_bin(name: &str) -> Option { + let env_key = match name { + "memory-daemon" => "MEMORY_BENCH_DAEMON_BIN", + "memory" => "MEMORY_BENCH_MEMORY_BIN", + _ => "", + }; + if !env_key.is_empty() { + if let Ok(p) = std::env::var(env_key) { + let pb = PathBuf::from(p); + if pb.is_file() { + return Some(pb); + } + } + } + let debug = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../target/debug") + .join(name); + if debug.is_file() { + return Some(debug); + } + let release = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../target/release") + .join(name); + if release.is_file() { + return Some(release); + } + if let Some(paths) = std::env::var_os("PATH") { + for dir in std::env::split_paths(&paths) { + let candidate = dir.join(name); + if candidate.is_file() { + return Some(candidate); + } + } + } + None +} + #[cfg(test)] mod tests { use super::*; @@ -402,6 +701,7 @@ mod tests { memory_bin: "/definitely/not/a/memory/binary".into(), endpoint: "http://127.0.0.1:1".into(), backend: BackendKind::Cli, + ..RunConfig::default() }; let err = ingest_session_cli(path.to_str().unwrap(), &cfg).unwrap_err(); let msg = err.to_string(); @@ -455,4 +755,196 @@ mod tests { // not panic. Accuracy is a mock number — asserted only as a pipeline. assert!(tests.len() >= 25); } + + #[test] + fn drain_caught_up_empty_outbox() { + let snap = CheckpointSnapshot { + checkpoints: vec![], + outbox_head: 0, + }; + assert!(drain_caught_up(&snap)); + } + + #[test] + fn drain_caught_up_requires_bm25() { + let pending = CheckpointSnapshot { + checkpoints: vec![], + outbox_head: 3, + }; + assert!(!drain_caught_up(&pending)); + + let incomplete = CheckpointSnapshot { + checkpoints: vec![CheckpointInfo { + index_type: "bm25".into(), + last_sequence: 0, + processed_count: 0, + }], + outbox_head: 3, + }; + assert!(!drain_caught_up(&incomplete)); + + let ready = CheckpointSnapshot { + checkpoints: vec![CheckpointInfo { + index_type: "bm25".into(), + last_sequence: 2, + processed_count: 3, + }], + outbox_head: 3, + }; + assert!(drain_caught_up(&ready)); + } + + #[test] + fn drain_caught_up_first_outbox_sequence() { + let ready = CheckpointSnapshot { + checkpoints: vec![CheckpointInfo { + index_type: "bm25".into(), + last_sequence: 0, + processed_count: 1, + }], + outbox_head: 1, + }; + assert!(drain_caught_up(&ready)); + } + + #[test] + fn drain_caught_up_vector_optional_unless_present() { + let bm25_only = CheckpointSnapshot { + checkpoints: vec![CheckpointInfo { + index_type: "bm25".into(), + last_sequence: 4, + processed_count: 5, + }], + outbox_head: 5, + }; + assert!(drain_caught_up(&bm25_only)); + + let vector_lagging = CheckpointSnapshot { + checkpoints: vec![ + CheckpointInfo { + index_type: "bm25".into(), + last_sequence: 4, + processed_count: 5, + }, + CheckpointInfo { + index_type: "vector".into(), + last_sequence: 0, + processed_count: 0, + }, + ], + outbox_head: 5, + }; + assert!(!drain_caught_up(&vector_lagging)); + } + + #[test] + fn parse_checkpoint_json_roundtrip() { + let snap = parse_checkpoint_json( + r#"{"checkpoints":[{"index_type":"bm25","last_sequence":1,"processed_count":2}],"outbox_head":2}"#, + ) + .unwrap(); + assert_eq!(snap.outbox_head, 2); + assert_eq!(snap.checkpoints[0].index_type, "bm25"); + assert!(drain_caught_up(&snap)); + } + + #[test] + fn isolation_parse_roundtrip() { + assert_eq!( + Isolation::parse("daemon-per-conversation").unwrap(), + Isolation::DaemonPerConversation + ); + assert_eq!(Isolation::parse("shared").unwrap(), Isolation::Shared); + assert!(Isolation::parse("reset-rpc").is_err()); + } + + #[test] + fn isolation_default_label() { + assert_eq!( + Isolation::DaemonPerConversation.result_label(BackendKind::Cli), + "per-conversation daemon" + ); + assert_eq!( + Isolation::Shared.result_label(BackendKind::Cli), + "shared daemon (cross-conversation bleed)" + ); + assert_eq!( + Isolation::DaemonPerConversation.result_label(BackendKind::Mock), + "per-conversation temp store" + ); + } + + #[test] + fn cli_isolated_daemons_do_not_bleed() { + if std::env::var("MEMORY_BENCH_LIVE").ok().as_deref() != Some("1") { + eprintln!("skip: set MEMORY_BENCH_LIVE=1 after building memory + memory-daemon"); + return; + } + let Some(daemon) = find_bin("memory-daemon") else { + eprintln!("skip: memory-daemon binary not found (CI bench-cli-smoke builds it)"); + return; + }; + let Some(memory) = find_bin("memory") else { + eprintln!("skip: memory binary not found"); + return; + }; + + eprintln!( + "live isolation: daemon={} memory={}", + daemon.display(), + memory.display() + ); + + let a = IsolatedDaemon::spawn(daemon.to_str().unwrap()).expect("spawn A"); + eprintln!("spawned A at {}", a.endpoint); + let b = IsolatedDaemon::spawn(daemon.to_str().unwrap()).expect("spawn B"); + eprintln!("spawned B at {}", b.endpoint); + + let cfg_a = RunConfig { + memory_bin: memory.to_string_lossy().into_owned(), + daemon_bin: daemon.to_string_lossy().into_owned(), + endpoint: a.endpoint.clone(), + backend: BackendKind::Cli, + isolation: Isolation::DaemonPerConversation, + limit_questions: None, + }; + let mut cfg_b = cfg_a.clone(); + cfg_b.endpoint = b.endpoint.clone(); + + let dir = tempfile::tempdir().unwrap(); + let path_a = dir.path().join("a.jsonl"); + let path_b = dir.path().join("b.jsonl"); + std::fs::write(&path_a, r#"{"content":"UNIQUE_ALPHA_TOKEN jwt rotation"}"#).unwrap(); + std::fs::write(&path_b, r#"{"content":"UNIQUE_BETA_TOKEN redis cache"}"#).unwrap(); + + ingest_session_cli(path_a.to_str().unwrap(), &cfg_a).unwrap(); + ingest_session_cli(path_b.to_str().unwrap(), &cfg_b).unwrap(); + eprintln!("ingested; draining A"); + wait_for_drain(&cfg_a.daemon_bin, &cfg_a.endpoint).unwrap(); + eprintln!("drained A; draining B"); + wait_for_drain(&cfg_b.daemon_bin, &cfg_b.endpoint).unwrap(); + eprintln!("drained B; searching"); + + let hits_b = run_query_cli("UNIQUE_ALPHA_TOKEN", &cfg_b, 5).unwrap(); + assert!( + hits_b + .ranked + .iter() + .all(|h| !h.text.contains("UNIQUE_ALPHA_TOKEN")), + "daemon B must not see A's events: {:?}", + hits_b.ranked + ); + let hits_a = run_query_cli("UNIQUE_ALPHA_TOKEN", &cfg_a, 5).unwrap(); + assert!( + hits_a + .ranked + .iter() + .any(|h| h.text.contains("UNIQUE_ALPHA_TOKEN")), + "daemon A must see its own token: {:?}", + hits_a.ranked + ); + + a.stop().ok(); + b.stop().ok(); + } } diff --git a/crates/memory-client/src/client.rs b/crates/memory-client/src/client.rs index 1a15fc8..6e80da5 100644 --- a/crates/memory-client/src/client.rs +++ b/crates/memory-client/src/client.rs @@ -8,13 +8,13 @@ use tracing::{debug, info}; use memory_service::pb::{ memory_service_client::MemoryServiceClient, BrowseTocRequest, Event as ProtoEvent, EventRole as ProtoEventRole, EventType as ProtoEventType, ExpandGripRequest, - GetDedupStatusRequest, GetDedupStatusResponse, GetEventsRequest, GetNodeRequest, - GetRankingStatusRequest, GetRankingStatusResponse, GetRelatedTopicsRequest, GetTocRootRequest, - GetTopTopicsRequest, GetTopicGraphStatusRequest, GetTopicsByQueryRequest, - GetVectorIndexStatusRequest, Grip as ProtoGrip, HybridSearchRequest, HybridSearchResponse, - IngestEventRequest, RouteQueryRequest, RouteQueryResponse, TeleportSearchRequest, - TeleportSearchResponse, TocNode as ProtoTocNode, Topic as ProtoTopic, VectorIndexStatus, - VectorTeleportRequest, VectorTeleportResponse, + GetDedupStatusRequest, GetDedupStatusResponse, GetEventsRequest, GetIndexCheckpointsRequest, + GetIndexCheckpointsResponse, GetNodeRequest, GetRankingStatusRequest, GetRankingStatusResponse, + GetRelatedTopicsRequest, GetTocRootRequest, GetTopTopicsRequest, GetTopicGraphStatusRequest, + GetTopicsByQueryRequest, GetVectorIndexStatusRequest, Grip as ProtoGrip, HybridSearchRequest, + HybridSearchResponse, IngestEventRequest, RouteQueryRequest, RouteQueryResponse, + TeleportSearchRequest, TeleportSearchResponse, TocNode as ProtoTocNode, Topic as ProtoTopic, + VectorIndexStatus, VectorTeleportRequest, VectorTeleportResponse, }; use memory_types::{Event, EventRole, EventType}; @@ -339,6 +339,16 @@ impl MemoryClient { Ok(response.into_inner()) } + /// Read BM25/vector index checkpoints and the outbox head (Phase 60-01). + pub async fn get_index_checkpoints( + &mut self, + ) -> Result { + debug!("GetIndexCheckpoints request"); + let request = tonic::Request::new(GetIndexCheckpointsRequest {}); + let response = self.inner.get_index_checkpoints(request).await?; + Ok(response.into_inner()) + } + // ===== Observability Methods (Phase 42) ===== /// Get dedup gate status and metrics. diff --git a/crates/memory-client/src/lib.rs b/crates/memory-client/src/lib.rs index a44b9a0..6cc26d2 100644 --- a/crates/memory-client/src/lib.rs +++ b/crates/memory-client/src/lib.rs @@ -43,8 +43,9 @@ pub use client::{ pub use error::ClientError; pub use hook_mapping::{map_hook_event, HookEvent, HookEventType}; pub use memory_service::pb::{ - Event as ProtoEvent, ExplainabilityPayload, HybridSearchResponse, RetrievalResult, - RouteQueryResponse, VectorIndexStatus, VectorMatch, VectorTeleportResponse, + Event as ProtoEvent, ExplainabilityPayload, GetIndexCheckpointsResponse, HybridSearchResponse, + IndexCheckpointInfo, RetrievalResult, RouteQueryResponse, VectorIndexStatus, VectorMatch, + VectorTeleportResponse, }; // Re-export Event type for convenience diff --git a/crates/memory-daemon/Cargo.toml b/crates/memory-daemon/Cargo.toml index cd41dab..96d8abb 100644 --- a/crates/memory-daemon/Cargo.toml +++ b/crates/memory-daemon/Cargo.toml @@ -30,6 +30,7 @@ anyhow = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } serde = { workspace = true } +serde_json = { workspace = true } chrono = { workspace = true } toml = { workspace = true } shellexpand = "3.1" diff --git a/crates/memory-daemon/src/cli.rs b/crates/memory-daemon/src/cli.rs index 41ce1c7..23852a8 100644 --- a/crates/memory-daemon/src/cli.rs +++ b/crates/memory-daemon/src/cli.rs @@ -46,10 +46,20 @@ pub enum Commands { /// Override database path #[arg(long)] db_path: Option, + + /// PID file path. Defaults to `$XDG_RUNTIME_DIR/agent-memory/daemon.pid`. + /// Isolation harnesses pass a per-conversation path so they do not + /// clobber a user's running daemon. + #[arg(long)] + pid_file: Option, }, /// Stop the running daemon - Stop, + Stop { + /// PID file to signal. Must match the path used at start. + #[arg(long)] + pid_file: Option, + }, /// Show daemon status Status { @@ -212,6 +222,9 @@ pub enum QueryCommands { #[arg(long, default_value = "10")] limit: u32, }, + + /// Print BM25/vector checkpoints and the outbox head as JSON (Phase 60-01). + Checkpoints, } /// Admin subcommands @@ -701,7 +714,40 @@ mod tests { #[test] fn test_cli_stop() { let cli = Cli::parse_from(["memory-daemon", "stop"]); - assert!(matches!(cli.command, Commands::Stop)); + assert!(matches!(cli.command, Commands::Stop { pid_file: None })); + } + + #[test] + fn test_cli_stop_pid_file() { + let cli = Cli::parse_from(["memory-daemon", "stop", "--pid-file", "/tmp/am.pid"]); + match cli.command { + Commands::Stop { pid_file } => { + assert_eq!(pid_file.as_deref(), Some("/tmp/am.pid")); + } + _ => panic!("Expected Stop command"), + } + } + + #[test] + fn test_cli_start_pid_file() { + let cli = Cli::parse_from(["memory-daemon", "start", "--pid-file", "/tmp/am.pid"]); + match cli.command { + Commands::Start { pid_file, .. } => { + assert_eq!(pid_file.as_deref(), Some("/tmp/am.pid")); + } + _ => panic!("Expected Start command"), + } + } + + #[test] + fn test_cli_query_checkpoints() { + let cli = Cli::parse_from(["memory-daemon", "query", "checkpoints"]); + match cli.command { + Commands::Query { command, .. } => { + assert!(matches!(command, QueryCommands::Checkpoints)); + } + _ => panic!("Expected Query command"), + } } #[test] diff --git a/crates/memory-daemon/src/commands.rs b/crates/memory-daemon/src/commands.rs index d111b90..79063fc 100644 --- a/crates/memory-daemon/src/commands.rs +++ b/crates/memory-daemon/src/commands.rs @@ -42,7 +42,7 @@ use crate::cli::{ SchedulerCommands, TeleportCommand, TopicsCommand, }; -/// Get the PID file path +/// Get the default PID file path fn pid_file_path() -> PathBuf { directories::BaseDirs::new() .map(|dirs| { @@ -63,22 +63,26 @@ fn pid_file_path() -> PathBuf { .join("daemon.pid") } +fn resolve_pid_file(override_path: Option<&str>) -> PathBuf { + override_path + .map(PathBuf::from) + .unwrap_or_else(pid_file_path) +} + /// Write PID to file -fn write_pid_file() -> Result<()> { - let pid_path = pid_file_path(); +fn write_pid_file(pid_path: &Path) -> Result<()> { if let Some(parent) = pid_path.parent() { fs::create_dir_all(parent)?; } - fs::write(&pid_path, std::process::id().to_string())?; + fs::write(pid_path, std::process::id().to_string())?; info!("Wrote PID file: {:?}", pid_path); Ok(()) } /// Remove PID file -fn remove_pid_file() { - let pid_path = pid_file_path(); +fn remove_pid_file(pid_path: &Path) { if pid_path.exists() { - if let Err(e) = fs::remove_file(&pid_path) { + if let Err(e) = fs::remove_file(pid_path) { warn!("Failed to remove PID file: {}", e); } else { info!("Removed PID file"); @@ -87,9 +91,8 @@ fn remove_pid_file() { } /// Read PID from file -fn read_pid_file() -> Option { - let pid_path = pid_file_path(); - fs::read_to_string(&pid_path) +fn read_pid_file(pid_path: &Path) -> Option { + fs::read_to_string(pid_path) .ok() .and_then(|s| s.trim().parse().ok()) } @@ -570,6 +573,7 @@ pub async fn start_daemon( port_override: Option, db_path_override: Option<&str>, log_level_override: Option<&str>, + pid_file_override: Option<&str>, ) -> Result<()> { if background { anyhow::bail!( @@ -737,8 +741,10 @@ pub async fn start_daemon( None }; + let pid_path = resolve_pid_file(pid_file_override); + // Write PID file - write_pid_file()?; + write_pid_file(&pid_path)?; // Parse address let addr: SocketAddr = settings @@ -797,17 +803,18 @@ pub async fn start_daemon( .await; // Cleanup - remove_pid_file(); + remove_pid_file(&pid_path); result.map_err(|e| anyhow::anyhow!("Server error: {}", e)) } /// Stop the running daemon by sending SIGTERM. -pub fn stop_daemon() -> Result<()> { - let pid = read_pid_file().context("No PID file found - daemon may not be running")?; +pub fn stop_daemon(pid_file_override: Option<&str>) -> Result<()> { + let pid_path = resolve_pid_file(pid_file_override); + let pid = read_pid_file(&pid_path).context("No PID file found - daemon may not be running")?; if !is_process_running(pid) { - remove_pid_file(); + remove_pid_file(&pid_path); anyhow::bail!("Daemon not running (stale PID file removed)"); } @@ -835,7 +842,7 @@ pub fn stop_daemon() -> Result<()> { pub fn show_status() -> Result<()> { let pid_path = pid_file_path(); - match read_pid_file() { + match read_pid_file(&pid_path) { Some(pid) if is_process_running(pid) => { println!("Memory daemon is running (PID {})", pid); println!("PID file: {:?}", pid_path); @@ -1088,6 +1095,29 @@ pub async fn handle_query(endpoint: &str, command: QueryCommands) -> Result<()> fields, limit, } => handle_search(endpoint, query, node, parent, fields, limit).await?, + + QueryCommands::Checkpoints => { + let resp = client + .get_index_checkpoints() + .await + .context("GetIndexCheckpoints failed")?; + let checkpoints: Vec = resp + .checkpoints + .iter() + .map(|c| { + serde_json::json!({ + "index_type": c.index_type, + "last_sequence": c.last_sequence, + "processed_count": c.processed_count, + }) + }) + .collect(); + let payload = serde_json::json!({ + "checkpoints": checkpoints, + "outbox_head": resp.outbox_head, + }); + println!("{}", serde_json::to_string(&payload)?); + } } Ok(()) @@ -3305,6 +3335,12 @@ mod tests { .contains("agent-memory")); } + #[test] + fn test_resolve_pid_file_override() { + let path = resolve_pid_file(Some("/tmp/am-test.pid")); + assert_eq!(path, PathBuf::from("/tmp/am-test.pid")); + } + #[test] fn test_status_no_daemon() { // Just verify it doesn't panic diff --git a/crates/memory-daemon/src/main.rs b/crates/memory-daemon/src/main.rs index 89e03fc..ef11f01 100644 --- a/crates/memory-daemon/src/main.rs +++ b/crates/memory-daemon/src/main.rs @@ -38,6 +38,7 @@ async fn main() -> Result<()> { background, port, db_path, + pid_file, } => { start_daemon( cli.config.as_deref(), @@ -45,11 +46,12 @@ async fn main() -> Result<()> { port, db_path.as_deref(), cli.log_level.as_deref(), + pid_file.as_deref(), ) .await?; } - Commands::Stop => { - stop_daemon()?; + Commands::Stop { pid_file } => { + stop_daemon(pid_file.as_deref())?; } Commands::Status { verbose, endpoint } => { show_status()?; diff --git a/crates/memory-indexing/src/pipeline.rs b/crates/memory-indexing/src/pipeline.rs index 286eec5..b429fd0 100644 --- a/crates/memory-indexing/src/pipeline.rs +++ b/crates/memory-indexing/src/pipeline.rs @@ -36,12 +36,13 @@ impl ProcessResult { /// Add a result for an index type. pub fn add_result(&mut self, index_type: IndexType, result: UpdateResult) { self.total_processed += result.processed; - if let Some(last_seq) = self.last_sequence { - if result.last_sequence > last_seq { - self.last_sequence = Some(result.last_sequence); - } - } else if result.last_sequence > 0 { - self.last_sequence = Some(result.last_sequence); + // Sequence 0 is a real outbox id. Using `last_sequence > 0` dropped the + // first event, so a one-entry drain never wrote a checkpoint. + if result.total() > 0 { + self.last_sequence = Some(match self.last_sequence { + Some(last_seq) => last_seq.max(result.last_sequence), + None => result.last_sequence, + }); } self.by_index.insert(index_type, result); } @@ -273,7 +274,12 @@ impl IndexingPipeline { if let Some(last_seq) = result.last_sequence { for (index_type, checkpoint) in &mut self.checkpoints { if let Some(idx_result) = result.by_index.get(index_type) { - if idx_result.last_sequence > checkpoint.last_sequence { + // Sequence 0 is a valid last_sequence. A fresh + // checkpoint is last_sequence=0/processed_count=0, so + // `>` would skip the first outbox entry forever. + if checkpoint.processed_count == 0 + || idx_result.last_sequence > checkpoint.last_sequence + { checkpoint .update(idx_result.last_sequence, idx_result.processed as u64); } @@ -448,6 +454,18 @@ mod tests { (Arc::new(storage), temp_dir) } + #[test] + fn test_add_result_records_sequence_zero() { + let mut result = ProcessResult::new(); + let mut update = UpdateResult::new(); + update.record_success(); + update.set_sequence(0); + result.add_result(IndexType::Bm25, update); + assert_eq!(result.last_sequence, Some(0)); + assert_eq!(result.total_processed, 1); + assert!(result.has_updates()); + } + #[test] fn test_pipeline_creation() { let (storage, _temp) = create_test_storage(); @@ -537,6 +555,35 @@ mod tests { assert!(result.committed); } + #[test] + fn test_process_batch_sequence_zero_advances_checkpoint() { + let (storage, _temp_dir) = create_test_storage(); + let outbox_entry = OutboxEntry::for_index("event-0".to_string(), 0); + let outbox_bytes = outbox_entry.to_bytes().unwrap(); + storage + .put_event(&ulid::Ulid::new().to_string(), b"test", &outbox_bytes) + .unwrap(); + + let mut pipeline = IndexingPipeline::new(storage.clone(), PipelineConfig::default()); + pipeline.add_updater(Box::new(MockUpdater::new(IndexType::Bm25, "bm25"))); + pipeline.load_checkpoints().unwrap(); + + let result = pipeline.process_batch(100).unwrap(); + assert!(result.has_updates()); + assert_eq!(result.total_processed, 1); + + let bytes = storage + .get_checkpoint("index_bm25") + .unwrap() + .expect("sequence-0 batch must persist a checkpoint"); + let cp = IndexCheckpoint::from_bytes(&bytes).unwrap(); + assert_eq!(cp.last_sequence, 0); + assert!( + cp.processed_count > 0, + "fresh checkpoint must record processed_count after seq 0" + ); + } + #[test] fn test_process_until_caught_up() { let (storage, _temp_dir) = create_test_storage(); diff --git a/crates/memory-search/src/searcher.rs b/crates/memory-search/src/searcher.rs index a14f0f6..cf7566a 100644 --- a/crates/memory-search/src/searcher.rs +++ b/crates/memory-search/src/searcher.rs @@ -111,6 +111,11 @@ impl TeleportSearcher { return Ok(Vec::new()); } + // The outbox indexer commits on a separate IndexWriter. Tantivy's + // OnCommitWithDelay only notifies readers on the same Index object, + // so this process-local reader must reload to see the latest commit. + self.reload()?; + let searcher = self.reader.searcher(); // Parse the text query diff --git a/crates/memory-service/src/ingest.rs b/crates/memory-service/src/ingest.rs index 22acd1f..4bbdb86 100644 --- a/crates/memory-service/src/ingest.rs +++ b/crates/memory-service/src/ingest.rs @@ -29,20 +29,21 @@ use crate::pb::{ CompleteEpisodeResponse, Event as ProtoEvent, EventRole as ProtoEventRole, EventType as ProtoEventType, ExpandGripRequest, ExpandGripResponse, GetAgentActivityRequest, GetAgentActivityResponse, GetDedupStatusRequest, GetDedupStatusResponse, GetEventsRequest, - GetEventsResponse, GetNodeRequest, GetNodeResponse, GetRankingStatusRequest, - GetRankingStatusResponse, GetRelatedTopicsRequest, GetRelatedTopicsResponse, - GetRetrievalCapabilitiesRequest, GetRetrievalCapabilitiesResponse, GetSchedulerStatusRequest, - GetSchedulerStatusResponse, GetSimilarEpisodesRequest, GetSimilarEpisodesResponse, - GetTocRootRequest, GetTocRootResponse, GetTopTopicsRequest, GetTopTopicsResponse, - GetTopicGraphStatusRequest, GetTopicGraphStatusResponse, GetTopicsByQueryRequest, - GetTopicsByQueryResponse, GetVectorIndexStatusRequest, HybridSearchRequest, - HybridSearchResponse, IngestEventRequest, IngestEventResponse, ListAgentsRequest, - ListAgentsResponse, PauseJobRequest, PauseJobResponse, PruneBm25IndexRequest, - PruneBm25IndexResponse, PruneVectorIndexRequest, PruneVectorIndexResponse, RecordActionRequest, - RecordActionResponse, ResumeJobRequest, ResumeJobResponse, RouteQueryRequest, - RouteQueryResponse, SearchChildrenRequest, SearchChildrenResponse, SearchNodeRequest, - SearchNodeResponse, StartEpisodeRequest, StartEpisodeResponse, TeleportSearchRequest, - TeleportSearchResponse, VectorIndexStatus, VectorTeleportRequest, VectorTeleportResponse, + GetEventsResponse, GetIndexCheckpointsRequest, GetIndexCheckpointsResponse, GetNodeRequest, + GetNodeResponse, GetRankingStatusRequest, GetRankingStatusResponse, GetRelatedTopicsRequest, + GetRelatedTopicsResponse, GetRetrievalCapabilitiesRequest, GetRetrievalCapabilitiesResponse, + GetSchedulerStatusRequest, GetSchedulerStatusResponse, GetSimilarEpisodesRequest, + GetSimilarEpisodesResponse, GetTocRootRequest, GetTocRootResponse, GetTopTopicsRequest, + GetTopTopicsResponse, GetTopicGraphStatusRequest, GetTopicGraphStatusResponse, + GetTopicsByQueryRequest, GetTopicsByQueryResponse, GetVectorIndexStatusRequest, + HybridSearchRequest, HybridSearchResponse, IndexCheckpointInfo, IngestEventRequest, + IngestEventResponse, ListAgentsRequest, ListAgentsResponse, PauseJobRequest, PauseJobResponse, + PruneBm25IndexRequest, PruneBm25IndexResponse, PruneVectorIndexRequest, + PruneVectorIndexResponse, RecordActionRequest, RecordActionResponse, ResumeJobRequest, + ResumeJobResponse, RouteQueryRequest, RouteQueryResponse, SearchChildrenRequest, + SearchChildrenResponse, SearchNodeRequest, SearchNodeResponse, StartEpisodeRequest, + StartEpisodeResponse, TeleportSearchRequest, TeleportSearchResponse, VectorIndexStatus, + VectorTeleportRequest, VectorTeleportResponse, }; use crate::query; use crate::retrieval::RetrievalHandler; @@ -367,6 +368,49 @@ impl MemoryServiceImpl { self.retrieval_service = Some(Arc::new(retrieval)); } + /// Read persisted BM25/vector checkpoints plus the outbox head. + /// + /// Missing checkpoints are omitted (a fresh store has none). The live-backend + /// LOCOMO harness polls this instead of sleeping. + pub fn read_index_checkpoints(&self) -> GetIndexCheckpointsResponse { + const KEYS: [(&str, &str); 3] = [ + ("index_bm25", "bm25"), + ("index_vector", "vector"), + ("index_combined", "combined"), + ]; + let mut checkpoints = Vec::new(); + for (key, index_type) in KEYS { + match self.storage.get_checkpoint(key) { + Ok(Some(bytes)) => { + let parsed = serde_json::from_slice::(&bytes).ok(); + let last_sequence = parsed + .as_ref() + .and_then(|v| v.get("last_sequence")) + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let processed_count = parsed + .as_ref() + .and_then(|v| v.get("processed_count")) + .and_then(|v| v.as_u64()) + .unwrap_or(0); + checkpoints.push(IndexCheckpointInfo { + index_type: index_type.to_string(), + last_sequence, + processed_count, + }); + } + Ok(None) => {} + Err(e) => { + tracing::warn!(key, error = %e, "failed to read index checkpoint"); + } + } + } + GetIndexCheckpointsResponse { + checkpoints, + outbox_head: self.storage.outbox_head(), + } + } + /// Convert proto EventRole to domain EventRole fn convert_role(proto_role: ProtoEventRole) -> EventRole { match proto_role { @@ -741,6 +785,16 @@ impl MemoryService for MemoryServiceImpl { } } + /// Read BM25/vector index checkpoints and the outbox head. + /// + /// Phase 60-01: the live-backend LOCOMO harness polls this instead of sleeping. + async fn get_index_checkpoints( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(self.read_index_checkpoints())) + } + /// Get topic graph status and statistics. /// /// Per TOPIC-08: Returns topic graph availability and stats. @@ -1546,4 +1600,58 @@ mod tests { let event = MemoryServiceImpl::convert_event(proto).unwrap(); assert!(event.agent.is_none()); // Empty string treated as None } + + #[tokio::test] + async fn test_get_index_checkpoints_empty_store() { + let (service, _temp) = create_test_service(); + let resp = service + .get_index_checkpoints(Request::new(GetIndexCheckpointsRequest {})) + .await + .unwrap() + .into_inner(); + assert_eq!(resp.outbox_head, 0); + assert!(resp.checkpoints.is_empty()); + } + + #[tokio::test] + async fn test_get_index_checkpoints_reads_bm25_and_outbox_head() { + let (service, _temp) = create_test_service(); + + let request = Request::new(IngestEventRequest { + event: Some(ProtoEvent { + event_id: ulid::Ulid::new().to_string(), + session_id: "session-123".to_string(), + timestamp_ms: chrono::Utc::now().timestamp_millis(), + event_type: ProtoEventType::UserMessage as i32, + role: ProtoEventRole::User as i32, + text: "checkpoint probe".to_string(), + metadata: HashMap::new(), + agent: None, + }), + }); + service.ingest_event(request).await.unwrap(); + + let checkpoint = serde_json::json!({ + "index_type": "bm25", + "last_sequence": 0, + "processed_count": 1, + "last_processed_time": 0, + "created_at": 0, + }); + service + .storage + .put_checkpoint("index_bm25", &serde_json::to_vec(&checkpoint).unwrap()) + .unwrap(); + + let resp = service + .get_index_checkpoints(Request::new(GetIndexCheckpointsRequest {})) + .await + .unwrap() + .into_inner(); + assert_eq!(resp.outbox_head, 1); + assert_eq!(resp.checkpoints.len(), 1); + assert_eq!(resp.checkpoints[0].index_type, "bm25"); + assert_eq!(resp.checkpoints[0].last_sequence, 0); + assert_eq!(resp.checkpoints[0].processed_count, 1); + } } diff --git a/crates/memory-storage/src/db.rs b/crates/memory-storage/src/db.rs index ea0c7e7..38c4743 100644 --- a/crates/memory-storage/src/db.rs +++ b/crates/memory-storage/src/db.rs @@ -93,6 +93,11 @@ impl Storage { Ok(0) } + /// Next outbox sequence that will be assigned. + pub fn outbox_head(&self) -> u64 { + self.outbox_sequence.load(Ordering::SeqCst) + } + /// Get next outbox sequence number fn next_outbox_sequence(&self) -> u64 { self.outbox_sequence.fetch_add(1, Ordering::SeqCst) @@ -1082,6 +1087,23 @@ mod tests { assert!(entries.is_empty()); } + #[test] + fn test_outbox_head_tracks_next_sequence() { + let (storage, _temp) = create_test_storage(); + assert_eq!(storage.outbox_head(), 0); + + let event_id = ulid::Ulid::new().to_string(); + let outbox_entry = memory_types::OutboxEntry::for_index(event_id.clone(), 1000); + storage + .put_event(&event_id, b"test event", &outbox_entry.to_bytes().unwrap()) + .unwrap(); + + assert_eq!(storage.outbox_head(), 1); + let entries = storage.get_outbox_entries(0, 10).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].0, 0); + } + #[test] fn test_get_outbox_entries_after_event() { let (storage, _temp) = create_test_storage(); diff --git a/docs/RELEASING.md b/docs/RELEASING.md index cac601f..923ad21 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -23,9 +23,14 @@ grep -A2 '\[workspace.package\]' Cargo.toml # version must equal the tag minus # Tag that SHA, not a local name that might have drifted: SHA="$(git rev-parse origin/main)" git tag -a vX.Y.Z "$SHA" -m "Release vX.Y.Z" +git rev-parse 'vX.Y.Z^{commit}' # must equal $SHA git push origin vX.Y.Z ``` +zsh: quote the peel (`'vX.Y.Z^{commit}'`). `^` is history expansion, and +with `interactivecomments` a bare `#` starts a comment — both will silently +rewrite the command. bash users can copy the block as-is. + Pushing a tag matching `v[0-9]+.[0-9]+.[0-9]+` starts [`.github/workflows/release.yml`](../.github/workflows/release.yml). diff --git a/docs/benchmarks.md b/docs/benchmarks.md index d7fee64..bc46aa7 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -171,6 +171,31 @@ cargo run -p memory-bench -- all --backend mock --output benchmarks/results/cust `--backend cli` shells out to a running `memory` daemon; `memory add` / `memory search` failures abort the run (a dead daemon is not accuracy 0.0). +## LOCOMO live backend (Phase 60-01) + +`--backend cli` on `memory-bench locomo` defaults to +`--isolation daemon-per-conversation`: for each conversation the harness +creates a tempdir, spawns `memory-daemon start --db-path --port +--pid-file /daemon.pid`, waits until `memory-daemon query checkpoints` +answers, ingests, then **polls GetIndexCheckpoints** until the BM25 +checkpoint covers the outbox head (timeout 5 minutes). Result JSON records +`"isolation": "per-conversation daemon"` and per-conversation +`drain_wait_ms`. + +`--isolation shared` is local debugging only. It prints a bleed caveat and +must not be committed. + +There is no `std::thread::sleep` standing in for drain. Poll interval is a +channel `recv_timeout`. + +```bash +# CI / local live-backend smoke (1 conversation, mock judge, spawned daemon) +cargo run -p memory-bench -- locomo \ + --dataset benchmarks/fixtures/locomo-smoke.json \ + --backend cli --scorer mock \ + --isolation daemon-per-conversation +``` + Metrics: | Metric | What it is | @@ -218,8 +243,13 @@ cargo run -p memory-bench -- smoke --output benchmarks/results/locomo-smoke.json # Full dataset, still not a LOCOMO score cargo run -p memory-bench -- locomo --dataset locomo-data --scorer mock -# The only path that may be labeled locomo_llm_judge -cargo run -p memory-bench -- locomo --dataset locomo-data --scorer llm-judge --output benchmarks/results/locomo-$(date -u +%F).json +# Live daemon, isolated per conversation (the 60-02 path) +cargo run -p memory-bench -- locomo --dataset locomo-data --backend cli --scorer llm-judge \ + --isolation daemon-per-conversation --output benchmarks/results/locomo-$(date -u +%F).json + +# Cost-capped dry run +cargo run -p memory-bench -- locomo --dataset locomo-data --backend cli --scorer llm-judge \ + --limit-questions 200 --output benchmarks/results/locomo-$(date -u +%F)-partial.json ``` `memory add --timestamp RFC3339 --session-id ID --role user|assistant` exists diff --git a/docs/plans/v3.2-prove-it-plan.md b/docs/plans/v3.2-prove-it-plan.md index 5159a79..cadcf9c 100644 --- a/docs/plans/v3.2-prove-it-plan.md +++ b/docs/plans/v3.2-prove-it-plan.md @@ -1,366 +1,712 @@ -# v3.2 "Prove It" — Review and Plan of Action +# v3.2 "Prove It" — Detailed Plan **Date:** 2026-09-01 **Verified against:** `main` @ `d6c8ac7`, release `v3.1.0` (run 33458631284) -**Status:** Adopted 2026-09-01 — GSD milestone v3.2, Phase 59 executing -**Issues:** #39 (LOCOMO), #40 (vector/topic quality), #41 (backfill), #42 (install-service), #43 (TOC rebuild), #44 (cross-encoder / 62) +**Status:** Adopted 2026-09-01 — canonical GSD spec (Claude artifact +d559ad58 / commit `18d6638`). Phase 59 landed as +[#45](https://github.com/SpillwaveSolutions/agent-memory/pull/45) +(`2ecc3c1`). Phase 60-01 executing. +**Issues (8, labelled `v3.2`):** #39 LOCOMO (60-02) · #40 vector quality +(60-03) · #47 topic quality (60-03) · #41 backfill (61-01) · #42 +install-service (61-02) · #43 TOC rebuild (61-04) · #48 uninstall/status +(61-05) · #44 cross-encoder (62) +**Format:** follows `docs/plans/v3.1-make-it-true-plan.md` — every plan item +names the files it touches and carries acceptance criteria testable at PR time. --- -## 1. Where we are +## 0. Thesis -Everything in this section was checked against the repository, the release, -or the workflow history today, not recalled from planning docs. +v3.1 made the project's claims true. v3.2 makes them **provable and +operable**: a real benchmark number committed next to the competitor figures, +a quality artifact behind every "Solid" in the README status table, and a +daemon someone can run for a week without a terminal open. No new retrieval +capability ships unless a number says it is the bottleneck. -| Fact | Value | -|---|---| -| Latest release | `v3.1.0`, published 2026-09-01 01:45 UTC, **5 of 5 platforms** — first full-platform release in the repo's history | -| Code | 20 crates, 64,626 LOC Rust | -| Tests | 1,205 workspace + 60 e2e cargo tests green; 114 bats CLI tests in 16 files; Tier 2 weekly run passed once | -| Open GitHub issues | **0** — the backlog exists only in `.planning/` | -| Committed benchmark results | 2 files, both **mock backend + mock judge** (`custom-harness-mock.json`, `locomo-smoke.json`, 4 questions) | -| README status table | Solid ×6 · Works ×2 · Experimental ×2 · Not implemented ×3 | -| Unmerged work | `origin/gsd/phase-56-import-bootstrap` — 81 commits, "v3.1 Memory Export/Import", March 2026, never merged | - -### What v3.1 delivered +Same rules as v3.1, unchanged: execution-evidence (run-dependent +requirements cite a committed artifact), reachability (`cargo tree -i` shows +a shipped dependent), `task pr-precheck` green on every PR. -v3.1 was a truth milestone. It shipped no capability; it made the claims match -the code: the orchestrator is reachable, hybrid fusion actually fuses, -explainability reports what ran, the 64.6 s TOC figure was retracted, the -benchmark harness stopped calling a substring metric "LOCOMO", and the repo -gained a README, LICENSE, positioning doc, and a supported-surface tier. - -### What v3.1 deliberately left open +--- -From the README, CHANGELOG, and positioning doc's own words: +## 1. Where we are (verified today) -- No real-backend / real-judge LOCOMO run, so **no comparative claim anywhere** -- Vector retrieval quality and topic-graph clustering are **not benchmarked** -- No BM25 backfill for pre-v3.1 events -- Cross-encoder rerank, offline TOC rebuild, background daemonization: - **not implemented**, and now say so instead of pretending +| Fact | Value | How checked | +|---|---|---| +| Latest release | `v3.1.0`, 2026-09-01 01:45 UTC, **5/5 platforms** | release page, run 33458631284 | +| Code | 20 crates, 64,626 LOC Rust | `find crates -name '*.rs' \| xargs cat \| wc -l` | +| Tests | 1,205 workspace + 60 e2e cargo tests green; 114 bats tests / 16 files; Tier 2 weekly ran once, passed | local run on clean `target/`; Actions | +| GitHub issues | **0 ever** (open or closed) | API | +| Benchmark artifacts | 2, both mock backend + mock judge | `benchmarks/results/` | +| README status | Solid ×6 · Works ×2 · Experimental ×2 · Not implemented ×3 | README | +| Unmerged work | one nested line of 99 commits on `origin/gsd/phase-58-claude-registration-metadata` | `git rev-list --count origin/main..` | ### What was planned before this, and where it went -There is no GitHub issue holding a plan — the repo has never had an issue. -The earlier plans live in three places: - | Plan | Where | Status today | |---|---|---| -| **v3.1 "Make It True"** (PR #31, `docs/plans/v3.1-make-it-true-plan.md`) | merged | Shipped as v3.1.0. This document is its sequel | -| **March roadmap** — v3.1 *Memory Export/Import* (Phases 54–56), v3.2 *Plugin Installer & OpenCode Converter* (57–59), v3.3+ (`--for all`, `--all`, Gemini/Codex/Copilot registration) | `.planning/ROADMAP.md` on `origin/gsd/phase-58-claude-registration-metadata` | **Never merged.** Superseded 2026-03-26 when v3.1 was re-planned. Export/import: built. Claude Code registration + plugin metadata (CREG/META): built, all requirements checked. OpenCode converter: built, now obsolete. Uninstall + Status (UNINST/STAT): not started. None of it is on `main` — `memory-installer` has no registration, uninstall, or status today | -| **v3 design spec "does not include"** (`docs/superpowers/specs/2026-03-21-v3-competitive-parity-design.md`) | merged | REST/HTTP endpoint, Python SDK, memory views UI, cross-encoder rerank — all still future. Cross-encoder is Phase 62 below; the other three stay **v3.3+** because they are new capabilities and v3.2's job is proof and operability | - -The March line is one nested branch of 99 commits (import-bootstrap ⊂ -opencode-converter ⊂ claude-registration), not three efforts, and it diverges -from `main` in `memory-orchestrator` and `memory-bench` as well as the -installer — it was cut before v3.0's PRs were squash-merged. That is why -59-02 produces a conflict map before anyone cherry-picks. +| **v3.1 "Make It True"** — PR #31 | merged | Shipped as v3.1.0. This document is its sequel | +| **March roadmap** — v3.1 *Memory Export/Import* (54–56), v3.2 *Plugin Installer & OpenCode Converter* (57–59), v3.3+ (`--for all`, `--all`, Gemini/Codex/Copilot registration) | `.planning/ROADMAP.md` on the `gsd/` branches | **Never merged**; superseded 2026-03-26. Export/import built. Claude Code registration + plugin metadata built (all CREG/META boxes ticked). OpenCode converter built, now obsolete. Uninstall + Status not started. `main` has none of it | +| **v3 design spec "does not include"** | `docs/superpowers/specs/2026-03-21-v3-competitive-parity-design.md` | REST endpoint, Python SDK, memory views UI → **v3.3+**. Cross-encoder rerank → Phase 62 (conditional) | + +What the March line actually contains, by `git diff --stat origin/main...`: + +- `crates/memory-service/src/backup.rs` (+308), `import.rs` (+280), + `tests/import_round_trip.rs` (+130), deltas to `ingest.rs`/`query.rs` +- `proto/memory.proto` (+110): the export/import/streaming RPCs +- `crates/memory-storage/src/db.rs` (+35), `episodes.rs` (+41) +- `crates/memory-cli`: `commands/timeline.rs` (+212), `output.rs` (+265) +- `crates/memory-installer/src/converters/claude.rs` (+584 — registration), + `opencode.rs` (+779 — obsolete), `tests/e2e_converters.rs` (+117) +- A **parallel `memory-orchestrator`** (`orchestrator.rs`, `fusion.rs`, + `rerank.rs`, `expand.rs`…) — the branch was cut before #28 landed main's + orchestrator. This is a duplicate, not a modification. **Never cherry-pick + it.** --- ## 2. Findings -Severity: **blocker** stops the next thing you want to do · **high** will bite -within a milestone · **medium** debt worth scheduling. - -### F1 — The release process has no guardrails `blocker` - -Today a `git push origin v3.1.0` shipped a tag from a stale local ref. The -Release workflow built `acc7294` — `Cargo.toml` said `2.7.0`, none of Phases -54–57 present — labelled it "Release 3.1.0", and published it. It was public -for 17 minutes. Nothing in the pipeline checks that the tagged commit is on -`main`, that the crate version matches the tag, or that all five platforms -built (`release: if: always()` publishes whatever succeeded). - -The four-binary archive guard added in #37 ran for the first time today and -held. The rest of the pipeline has no equivalent. - -### F2 — 81 commits of unmerged work nobody is tracking `high` - -`origin/gsd/phase-56-import-bootstrap` holds a complete earlier "v3.1": -daily markdown export, structured JSONL backup with incremental support, -import/bootstrap with round-trip validation, and the codebase's first gRPC -streaming RPCs. It was finished 2026-03-24 and superseded two days later when -v3.1 was re-planned. The same line continues through -`gsd/phase-57-opencode-converter-registration` (obsolete — Phase 57 of the -*current* roadmap removed OpenCode) into -`gsd/phase-58-claude-registration-metadata`, which holds a finished Claude -Code plugin registration (`known_marketplaces.json`, `installed_plugins.json`, -`settings.json`, `.claude-plugin/plugin.json`) that `main` still lacks. - -This matters beyond hygiene: **an import path is a backfill path.** The BM25 -backfill gap (F5) may already be half-built on a branch nobody remembers. - -### F3 — The real benchmark is one command away and has never been run `blocker` - -The harness is done. `memory-bench locomo --backend cli --scorer llm-judge` -is wired end to end; `benchmarks/scripts/download-locomo.sh` fetches the real -dataset; the judge honors `OPENAI_API_KEY` or `ANTHROPIC_API_KEY` and records -the model id. The only committed results are mock/mock. - -Every comparison claim in the positioning doc is gated on this run by the -doc's own rule ("until one is committed next to the claim"). The Show HN -draft cannot answer the first question it will get. - -One known gap in the harness for a real run: with `--backend cli` all -conversations share the daemon unless it is restarted between them (the -harness's own caveat). Per-conversation isolation on the live backend is a -task, not an assumption. - -### F4 — Retrieval quality has evidence for one layer out of three `high` - -BM25 has fixtures with labelled relevant items (recall@k in the custom -harness). Vector search has **zero** quality tests — `grep` for recall, -precision, MRR, or nDCG across `memory-vector`, `memory-search`, and -`e2e-tests` finds only lifecycle code. The topic graph's clustering is -unbenchmarked by the README's own admission. The status table's "Solid" for -vector search is accurate about the mechanism and silent about whether it -returns the right things. - -### F5 — Operational gaps a real user hits in week one `high` - -- **No backfill.** Anyone with pre-v3.1 events has empty `text_preview` - forever, or starts a new store. `admin rebuild-bm25` is a prune. -- **No daemonization.** `--background` exits non-zero. Users need a - launchd/systemd unit or a terminal they never close. -- **No offline TOC rebuild.** If the rollup job misses, there is no repair. -- **Panic surface.** Phase 54-06 cut lock-poisoning sites from 27 to 5. There - are 266 `unwrap()`/`expect()` calls in non-test daemon and service code. - Most are surely on infallible paths; none have been audited as such. - -### F6 — The planning source of truth is stale `medium` - -`.planning/PROJECT.md` — which CLAUDE.md names as the home of architectural -decisions — says "Version: v3.0 (In Progress)", lists OpenCode among the -adapters, and lists "API-based summarizer wiring" as deferred though #27 -shipped it. `STATE.md` and `MILESTONES.md` are current; `PROJECT.md` is what -a new contributor reads first. And with zero GitHub issues, the entire backlog -is invisible to anyone outside the planning folder. - -### F7 — Launch timing is coupled to F3 `medium` - -The blog post is about process and stands on its own. The Show HN, r/rust, and -r/LocalLLaMA drafts are product posts, and the product's positioning doc -forbids the comparison those audiences will ask for first. +### F1 · Release pipeline has no guardrails — `blocker` + +Evidence: today's first `v3.1.0` push built `acc7294` (`Cargo.toml` = 2.7.0, +no Phase 54–57 code), published "Release 3.1.0", public for 17 minutes. +`.github/workflows/release.yml`: +- `Get version` (line 49) derives the version from the tag name only +- no check that `GITHUB_SHA` is an ancestor of `main` +- no check that `workspace.package.version` == tag +- `release: if: always() && !cancelled()` (line 181) publishes whatever + platforms succeeded; 7 of 13 historical runs were partial or failed +- `generate_release_notes: true` (line 230) produces a PR-title list, not + the CHANGELOG entry + +The one guard that exists — four binaries per archive, added in #37 — ran for +the first time today and held. + +### F2 · 99 commits of unmerged work — `high` + +Evidence: section 1. The export/import feature is finished and tested +(`import_round_trip.rs`); the Claude Code plugin registration is finished. +Neither is on `main`. **An import path is a backfill path** (F5). + +### F3 · The real benchmark has never been run — `blocker` + +Evidence: `crates/memory-bench/src/cli.rs:14` (`--backend mock|cli`), +`judge.rs:101–126` (`--scorer llm-judge` reads `OPENAI_API_KEY` / +`ANTHROPIC_API_KEY`, records model id), `main.rs:137–147` (cli backend +ingests via `memory add` and evaluates per question), +`benchmarks/scripts/download-locomo.sh`. Only `locomo-smoke.json` +(4 questions, mock/mock) is committed. + +Harness gap for a real run: `RunConfig` (`runner.rs:40`) carries one +`endpoint`; `run_locomo` (`main.rs:134`) loops conversations against it. The +mock path gets a fresh `MockStore` per conversation; the cli path shares one +daemon, so conversation N sees N−1's events — cross-conversation bleed the +harness's own caveat names. + +### F4 · Retrieval quality evidence exists for one layer — `high` + +Evidence: custom-harness fixtures (`benchmarks/fixtures/*.toml`) carry +`relevant = [...]` and report `recall_at_k` — but every committed fixture is +answerable by token overlap (`expected_contains = ["JWT"]` with `JWT` in the +source). No fixture requires semantic retrieval. `grep -rl +"recall\|ndcg\|mrr" crates/memory-vector crates/memory-search +crates/e2e-tests` → lifecycle code only. `memory-topics` exposes +`cluster()` and `create_topics()` (`extraction.rs:51,96`) with no quality +metric anywhere. + +### F5 · Operational gaps — `high` + +- **Backfill:** `IndexingPipeline::process_until_caught_up` + (`memory-indexing/src/pipeline.rs:307`) only drains the outbox forward + from `IndexCheckpoint`; there is no path that re-reads events already past + the checkpoint. `admin rebuild-bm25` (`memory-daemon/src/cli.rs:311`) is a + prune. +- **Daemonization:** `commands.rs:574` rejects `--background` with guidance + to use systemd/launchd; nothing generates the unit. +- **TOC rebuild:** `AdminCommands::RebuildToc` (`cli.rs:231`) exits + non-zero. +- **Panic surface:** 5 `lock().unwrap()` sites remain + (`vector_updater.rs:459`, `novelty.rs:709,745`, `sync.rs:43` + 1). A + line-based grep counts 266 `unwrap()/expect()` in daemon+service, but it + cannot exclude inline `#[cfg(test)]` modules (e.g. `retrieval.rs:978+` is + test code). **The real production count is unknown.** That is the first + task of 61-03. + +### F6 · Planning source of truth is stale — `medium` + +`.planning/PROJECT.md`: "Version: v3.0 (In Progress)"; adapter list includes +OpenCode (removed in Phase 57); "API-based summarizer wiring" under Deferred +though #27 shipped it (`commands.rs:394` `resolve_api_key`). Zero GitHub +issues means the backlog is invisible outside `.planning/`. + +### F7 · Launch is coupled to F3 — `medium` + +The blog post stands alone. The Show HN / r/rust / r/LocalLLaMA drafts are +product posts; the positioning doc's own rule forbids the comparison those +audiences ask first. + +--- + +## 3. Target state (milestone exit) + +1. One committed `locomo_llm_judge` result on the full dataset, real backend, + hardware/model/dataset-SHA recorded, next to the competitor figures with a + commensurability note. +2. Every "Solid" row in the README status table cites a committed quality + artifact, or is relabelled. +3. `memory-daemon install-service` + `admin backfill-index` + `admin + rebuild-toc` exist and are covered by e2e/bats; no `unwrap()` on a + request path user input can reach. +4. `PROJECT.md` accurate; known gaps are GitHub issues; no orphan `gsd/` + branches; release pipeline refuses a bad tag. --- -## 3. Where the project should be +## 4. Plan -By the end of v3.2, a stranger arriving from a Show HN link should find: +Owner is **agent** unless it needs credentials, hardware, or a judgment +call. Effort in agent sessions (one session ≈ one merged PR of v3.1 size). + +### Phase 59 — Guardrails and Inventory (3 plans · 3 sessions) + +#### 59-01 · Release pipeline checks + +**Why:** F1. Today's incident is reproducible by anyone with a stale local +tag. + +**Files:** `.github/workflows/release.yml`; new `docs/RELEASING.md`; +`CLAUDE.md` Release Process section. + +**Steps:** +1. Add a `verify` job before `build`, `runs-on: ubuntu-latest`, that: + - `git fetch origin main` and fails unless + `git merge-base --is-ancestor "$GITHUB_SHA" origin/main` + - reads `workspace.package.version` with `cargo metadata --no-deps + --format-version 1 | jq -r '.packages[0].version'` (or + `grep -m1 '^version' Cargo.toml`) and fails unless it equals + `${GITHUB_REF_NAME#v}`; on `workflow_dispatch` compares to the input + - fails unless `CHANGELOG.md` contains a heading for that version +2. `build: needs: verify`. +3. `release:` — replace `if: always() && !cancelled()` with `needs: [verify, + build]` and no `if:`. A failed platform fails the release. Keep the + four-binary check from #37. +4. Replace `generate_release_notes: true` with `body_path:` pointing at a + file the job extracts from the matching `CHANGELOG.md` section + (`awk '/^## \[?'"$VER"'/{f=1;next} /^## /{f=0} f'`). +5. `docs/RELEASING.md`: the explicit-SHA procedure — + `git tag -a vX.Y.Z -m "..."`, `git rev-parse vX.Y.Z^{commit}` + before push, what the guard will refuse and why. Note the zsh + `interactivecomments` trap. Update `CLAUDE.md` to point at it. + +**Acceptance:** +- [x] A tag on a commit not in `main` fails at `verify` before any build + starts (unit tests in `scripts/release-guards-test.sh`; live verify is + `workflow_dispatch` + `dry_run: true` — `v0.0.0-guardtest` does **not** + match the tag trigger `v[0-9]+.[0-9]+.[0-9]+`) +- [x] A tag whose version ≠ `Cargo.toml` fails at `verify` +- [x] A build-job failure on one platform yields **no** GitHub release + (`if: success()`, not `always()`) +- [x] The release body is the CHANGELOG section verbatim +- [x] `docs/RELEASING.md` exists and `CLAUDE.md` links it + +**Verify:** the guard-test tag runs above; `actionlint` clean; YAML parses. + +**Effort:** 1 session · **Owner:** agent (tag pushes for the test: maintainer) -1. **A number.** One committed LOCOMO LLM-judge result on the real dataset, - real backend, documented hardware and model, sitting next to the - competitor figures with an honest note on commensurability. -2. **Evidence for every "Solid".** Each retrieval layer has a quality fixture - and a committed result, or its label drops to "Works". -3. **Something they can run for a week.** Daemon starts at login, existing - events get indexed, and the daemon does not panic on the paths a user can - reach. -4. **A repo that looks alive.** Issues for the known gaps, an accurate - `PROJECT.md`, no orphan branches. +--- -Nothing on that list is a new capability. v3.2 is the second half of v3.1's -thesis: v3.1 made the claims true; v3.2 makes them **provable**. +#### 59-02 · Orphan branch triage + +**Why:** F2. Decide with a conflict map, not a feeling. + +**Files (read-only this plan):** the three `gsd/` branches; +output `docs/plans/march-branch-triage.md`. + +**Steps:** +1. For each of `backup.rs`, `import.rs`, `import_round_trip.rs`, the + `ingest.rs`/`query.rs`/`db.rs`/`episodes.rs` deltas, the proto additions, + the `memory-cli` timeline/output deltas, and `converters/claude.rs`: + `git diff origin/main...origin/gsd/phase-58-claude-registration-metadata + -- ` and record: applies clean / conflicts / superseded. +2. For the proto additions: check message and RPC names against + `proto/memory.proto` on `main` (no `Export*`/`Import*`/`Backup*` RPCs + exist today, so likely clean; the streaming RPC style needs review + against tonic version on `main`). +3. Explicitly mark the branch's `memory-orchestrator` and `memory-bench` + directories **do not port** (parallel implementations). +4. Write the recommendation: cherry-pick list with commit SHAs, in order, + for 61-01 (export/import) and 61-05 (registration); estimated conflict + count per file. +5. After maintainer decision: delete + `gsd/phase-57-opencode-converter-registration` immediately; delete the + other two once 61-01 and 61-05 merge. + +**Acceptance:** +- [x] `docs/plans/phase-59-orphan-branch-triage.md` lists every ported file with + clean/conflict status and the exact `git cherry-pick`/`git checkout + -- ` sequence +- [x] Maintainer decision recorded in the doc (port / rewrite / drop) +- [x] OpenCode branch deleted (`origin/gsd/phase-57-opencode-converter-registration`, + 2026-09-01). The other two `gsd/` branches stay until 61-01 and 61-05 merge. + +**Effort:** 1 session · **Owner:** agent; decision: maintainer --- -## 4. Plan +#### 59-03 · Planning truth and a public backlog + +**Why:** F6. + +**Files:** `.planning/PROJECT.md`, `.planning/ROADMAP.md`, +`.planning/STATE.md`, `.planning/REQUIREMENTS.md`; GitHub issues. + +**Steps:** +1. `PROJECT.md`: Current State → v3.1 shipped 2026-09-01; Current Milestone + → v3.2 (this plan); adapters list → Claude Code, Codex (Tier 1), Gemini, + Copilot (Tier 2); move "API-based summarizer wiring" to Validated with + the #27 reference; add "true daemonization" → superseded by + `install-service` (61-02) once decided. +2. `REQUIREMENTS.md`: replace the v3.0 block with v3.2 requirement IDs + (REL-01..04, BENCH-10..13, QUAL-01..03, OPS-01..05, INST-01..03) mapped + 1:1 to the plans below; keep the Future (v3.3+) list. +3. Open one GitHub issue per known gap, each linking its README row and its + plan ID: real LOCOMO run, vector quality, topic quality, backfill, + daemonization, TOC rebuild, cross-encoder, uninstall/status. Label + `v3.2`. +4. `ROADMAP.md`/`STATE.md`: add v3.2 phases 59–62 in the existing format. + +**Acceptance:** +- [x] Current State is v3.1 shipped / v3.2 executing; OpenCode is not a + supported adapter. Historical Validated rows in PROJECT.md still + mention OpenCode as v2.1/v2.4 facts — those are not current-state. +- [x] 8 open issues labelled `v3.2`, each with a plan ID (#39 #40 #41 #42 + #43 #44 #47 #48) +- [x] Every requirement ID in `REQUIREMENTS.md` appears in exactly one plan + +**Effort:** 1 session · **Owner:** agent + +--- + +### Phase 60 — Real Numbers (3 plans · 4 sessions + 1 maintainer run) + +#### 60-01 · Live-backend isolation for LOCOMO + +**Why:** F3. Without isolation the number is contaminated and cannot be +committed. + +**Files:** `crates/memory-bench/src/runner.rs` (`RunConfig`), +`main.rs` (`run_locomo`, `evaluate_sample_cli`), `locomo.rs` +(`ingest_sample_cli`), `cli.rs`; `crates/memory-daemon/src/cli.rs` (Start +args: `--port`, database path override already exist). + +**Design (pick A; B is fallback):** +- **A. Spawn-per-conversation.** `RunConfig` gains `daemon_bin: + Option`, `isolation: Isolation::{Shared, DaemonPerConversation}`. + For each conversation: create a `tempfile::tempdir()`, spawn + `memory-daemon start --db-path --port `, wait for + `memory-daemon status` healthy, run ingest + evaluate against that + endpoint, send `stop`, drop the dir. Result JSON records + `"isolation": "per-conversation daemon"`. +- **B. Reset RPC.** Add `AdminReset` gated by a `--allow-reset` daemon flag. + Rejected unless A proves infeasible: it adds a destructive RPC to the + daemon for the benefit of a benchmark. +- **Drain wait:** replace any sleep with polling `GetVectorIndexStatus` / + a new lightweight `GetIndexCheckpoints` RPC until BM25 and vector + checkpoints ≥ the outbox sequence after ingest; timeout 5 min with a + loud error. Record wait time per conversation in the result. + +**Steps:** +1. Implement A behind `--isolation daemon-per-conversation` (default for + `--backend cli`; `shared` remains for local debugging and prints the + bleed caveat). +2. Add the checkpoint poll. If no RPC exposes checkpoints, add + `GetIndexCheckpoints` to `proto/memory.proto` + `memory-service` — read + only, small. +3. Extend `smoke` so CI runs the 1-conversation fixture under `--backend + cli` with a spawned daemon and the mock judge (`ci.yml` new job + `bench-cli-smoke`, Linux only, after `build`). +4. Update `docs/benchmarks.md` "Run" and "Modes". + +**Acceptance:** +- [ ] `memory-bench locomo --backend cli --scorer mock --dataset + benchmarks/fixtures/locomo-smoke.json` completes with + `isolation: per-conversation daemon` in the output and a + `drain_wait_ms` per conversation +- [ ] A unit test asserts two conversations ingested under isolation A do + not retrieve each other's turns (mirror of + `mock_stores_do_not_bleed`, `runner.rs:346`) +- [ ] CI job `bench-cli-smoke` green +- [ ] No `std::thread::sleep` remains in the cli-backend path + +**Effort:** 1 session · **Owner:** agent + +--- + +#### 60-02 · The run + +**Why:** F3, F7. The single most valuable artifact the project can produce. + +**Prereqs:** 60-01 merged; `OPENAI_API_KEY` (or Anthropic); a machine you +are willing to name in the result; `--release` build. + +**Steps:** +1. `benchmarks/scripts/download-locomo.sh` → `locomo-data/locomo10.json`; + record its SHA-256. +2. Dry run, cost cap: `memory-bench locomo --dataset locomo-data --backend + cli --scorer llm-judge --limit-questions 200 --output + benchmarks/results/locomo-2026-MM-DD-partial.json` (add `--limit-questions` + in 60-01 if absent). Check the judge's recorded cost/tokens. +3. Full run to `benchmarks/results/locomo-2026-MM-DD.json`. Expect + ~2,000 questions; under ~$10 on `gpt-4o-mini`; 1–2 h wall clock + dominated by ingest + drain. +4. Commit the result. Update `docs/benchmarks.md` "Committed result" table + with hardware, profile (`--release`), model, temperature 0, dataset + SHA, isolation mode, and the per-type breakdown (single-hop / multi-hop / + temporal / adversarial) the harness already emits. +5. Update the positioning doc "Benchmarks: what we can and cannot say" and + the Claims Ledger row "Our own committed benchmark artifacts". +6. Decision gate (from v3.1 56-03, unchanged): publish the number whatever + it is; the only thing that can hold it is a methodology defect. + +**Acceptance:** +- [ ] `benchmarks/results/locomo-*.json` with `"metric": + "locomo_llm_judge"`, `"isolation": "per-conversation daemon"`, + non-null `model`, `hardware`, `dataset_sha256` +- [ ] `docs/benchmarks.md` and the positioning doc cite the file path +- [ ] Per-type scores present (feeds the Phase 62 gate) + +**Effort:** 1 maintainer session · **Owner:** maintainer (agent prepares +the exact commands and reviews the artifact) + +--- + +#### 60-03 · Vector and topic quality fixtures + +**Why:** F4. "Solid" needs an artifact. + +**Files:** `benchmarks/fixtures/semantic-001.toml` (new), +`benchmarks/fixtures/sessions/*.jsonl` (new sessions), `crates/memory-bench` +(a `--layers bm25|vector|hybrid` switch on the custom harness), +`crates/memory-topics` (a `metrics` module), `crates/e2e-tests/tests/ +topic_graph_test.rs`, README status table, positioning Claims Ledger. + +**Steps:** +1. **Semantic fixture set (≥ 15 tests).** Each test's `relevant` items + share meaning but not tokens with the query — "token expiry policy" → + session text says "JWT lifetime"; "container orchestration cutover" → + "EKS migration". Build the sessions so a BM25-only run scores recall@5 + < 0.4 on the set (that is the point) and record it. +2. Harness switch: `memory-bench run --category semantic --layers + bm25|vector|hybrid` maps to `TeleportSearch` / `VectorTeleport` / + `HybridSearch`. Commit three result files. +3. **Topic quality.** A labelled fixture of 60–100 short documents in 6–8 + known clusters; `memory-topics::metrics::{purity, adjusted_rand_index}` + over `cluster()` output. Commit `benchmarks/results/topics-quality.json`. +4. Status table: vector row cites the hybrid-vs-bm25 delta; topic row moves + from "Works · not benchmarked" to "Works · ARI x.xx" or stays "Works" + with the number. Positioning Claims Ledger gains both rows. + +**Acceptance:** +- [ ] `benchmarks/results/semantic-{bm25,vector,hybrid}.json` committed; + hybrid recall@5 > bm25 recall@5 on the semantic set (if not, that is a + finding and the README changes accordingly) +- [ ] `topics-quality.json` committed with purity and ARI +- [ ] README rows for vector and topic graph link the artifacts +- [ ] `cargo test -p memory-topics metrics` covers purity/ARI on a + hand-computed 3-cluster example + +**Effort:** 2 sessions · **Owner:** agent + +--- + +### Phase 61 — Operate It (5 plans · 6 sessions) + +#### 61-01 · Backfill + +**Why:** F5. Every pre-v3.1 store is stuck. + +**Files:** `crates/memory-indexing/src/pipeline.rs`, `checkpoint.rs`; +`crates/memory-daemon/src/cli.rs` (`AdminCommands::BackfillIndex`), +`commands.rs`; `crates/memory-storage` (event iteration by sequence); +`docs/UPGRADING.md`, README status table. If 59-02 kept +`memory-service/src/import.rs`, reuse its event replay. + +**Design:** `memory-daemon admin backfill-index --index bm25|vector|all +[--from-sequence N] [--batch 500] [--dry-run]`. Runs against a stopped +daemon (takes the RocksDB lock) or via a new admin RPC if the daemon is up — +choose stopped-only for v3.2 (simpler, and the daemon already has +`stop`). Algorithm: iterate events from `--from-sequence` (default 0) in +batches; for each batch call the `IndexUpdater` for the chosen index; +commit; write `IndexCheckpoint` = last sequence processed. Idempotent +(re-indexing an existing doc is an upsert in Tantivy and HNSW). Resumable +by reading the checkpoint on restart. Progress `n/total` on stderr every +batch. + +**Steps:** +1. `IndexingPipeline::backfill(from: u64, batch: usize, indexes: + &[IndexType]) -> Result` beside + `process_until_caught_up`. +2. CLI subcommand + wiring; refuse to run if the daemon lock is held, with + the message naming `memory-daemon stop`. +3. Fixture: a RocksDB store produced by the **v3.0** daemon (build + `68ab122`, ingest 50 events, commit the directory under + `crates/e2e-tests/fixtures/store-v3.0/`). +4. e2e: open fixture copy → `backfill-index --index all` → `TeleportSearch` + returns previews for all 50. +5. Docs: README BM25 row drops the "no backfill" note; `UPGRADING.md` v3.1 + section gains the command. + +**Acceptance:** +- [ ] e2e above passes; a second `backfill-index` run reports 0 new + documents +- [ ] `--dry-run` prints counts and writes nothing (checkpoint unchanged) +- [ ] Interrupting mid-run (test sends SIGINT after batch 1) and re-running + resumes from the checkpoint +- [ ] README and UPGRADING updated in the same PR + +**Effort:** 2 sessions · **Owner:** agent + +--- + +#### 61-02 · Daemon lifecycle via service units + +**Why:** F5. Decision 3. + +**Files:** `crates/memory-daemon/src/cli.rs` +(`Commands::{InstallService, UninstallService}`), `commands.rs`, new +`service.rs`; `tests/cli/claude-code/*.bats`, `tests/cli/codex/*.bats`; +`docs/setup/quickstart.md`. + +**Design:** `memory-daemon install-service [--port] [--db-path]` writes +`~/Library/LaunchAgents/com.spillwave.memory-daemon.plist` (macOS, +`launchctl bootstrap gui/$UID`) or +`~/.config/systemd/user/memory-daemon.service` (Linux, `systemctl --user +enable --now`). `uninstall-service` reverses it. Windows: exit non-zero with +guidance (out of scope; Tier 2 at best). `--background` keeps exiting +non-zero, now naming `install-service`. -Four phases plus the launch side quest. Phases are ordered by dependency: -guardrails before anything ships, numbers before anything is claimed, -operability before anyone is invited to try it. - -Effort is in agent sessions (one focused session ≈ one merged PR of the size -v3.1 produced). Owner is **agent** unless it needs credentials, hardware, or -a judgment call, then **maintainer**. - -### Phase 59 — Guardrails and Inventory (3 plans · ~3 sessions) - -Small, first, and it protects everything after it. - -**59-01 Release pipeline checks** · agent · 1 session -- In `release.yml`, before any build: fail unless the tagged commit is an - ancestor of `origin/main`; fail unless `Cargo.toml` `workspace.package.version` - equals the tag minus `v` -- Change `release: if: always() && !cancelled()` to require all `build` - jobs succeeded; a missing platform fails the release instead of publishing - three archives -- Generate release notes from the matching `CHANGELOG.md` section, not - GitHub's auto-list of PR titles -- Verify: push a deliberately wrong tag (`v9.9.9-test` on a non-main commit) - and confirm the run fails at the guard, then delete it -- Document the tag procedure in `docs/RELEASING.md` with the explicit-SHA - form: `git tag -a vX.Y.Z ` — today's incident was a bare `git tag -a` - colliding with a stale local ref - -**59-02 Orphan branch triage** · agent + maintainer decision · 1 session -- Produce a one-page inventory of the three `gsd/` branches: what each - contains, which files conflict with current `main`, and which parts are - still wanted -- Recommendation going in: **cherry-pick by feature**, not by branch — - export/import (JSONL backup, import/bootstrap, streaming RPCs) as the - foundation for 61-01, and Claude Code registration + plugin metadata - (CREG-01..06, META-01..03) for 61-05. Skip the OpenCode converter entirely -- Map conflicts first: the line diverges from `main` in - `memory-orchestrator` and `memory-bench`, not just the installer -- Delete all three `gsd/` branches once the kept features are on `main` -- Maintainer decides; agent executes in Phase 61 - -**59-03 Planning truth** · agent · 1 session -- Rewrite `PROJECT.md` Current State for v3.1 shipped; remove OpenCode from - the adapter list; move API summarizer from Deferred to shipped -- Open GitHub issues for F3, F4, F5's three gaps, and cross-encoder rerank, - each linking the relevant README row, so the backlog is public -- Add v3.2 to `ROADMAP.md` and `STATE.md` once the maintainer approves this - plan - -### Phase 60 — Real Numbers (3 plans · ~4 sessions + one maintainer run) - -The milestone's centre of gravity. - -**60-01 Live-backend isolation** · agent · 1 session -- `memory-bench locomo --backend cli` must give each conversation a fresh - store. Implement per-conversation daemon spawn with a temp `--data-dir` - (the harness already does this for mock), or an `admin reset` RPC guarded - behind a bench-only flag -- Wait for the outbox drain deterministically (poll the checkpoint, not - `sleep 60`) -- Verify: the 1-conversation smoke fixture runs under `--backend cli` in CI - with a real daemon, mock judge, and the result file records - `isolation: per-conversation daemon` - -**60-02 The run** · maintainer (needs API key + a documented machine) · 1 session -- `benchmarks/scripts/download-locomo.sh` -- `memory-bench locomo --dataset locomo-data --backend cli --scorer llm-judge --output benchmarks/results/locomo-2026-MM-DD.json` -- Budget: ~2,000 questions × (retrieve + answer + judge) — under $10 on - `gpt-4o-mini`; wall clock dominated by ingest and drain, expect 1–2 hours -- Record hardware, model id, temperature, dataset SHA in the result (the - harness already writes these fields) -- Commit the result and update `docs/benchmarks.md` "Committed result" - -**60-03 Vector and topic quality fixtures** · agent · 2 sessions -- Add a labelled semantic fixture to the custom harness: queries whose - relevant items share meaning but not tokens (`jwt` vs `JSON Web Token`) - so BM25 alone fails and vector must carry it; report recall@5 for - BM25-only, vector-only, and hybrid -- Add a topic-graph fixture: known clusters, report purity or adjusted Rand - index against the labels -- Commit results; downgrade any README row whose result does not support - "Solid" -- Update the positioning doc's Claims Ledger with each number and its - artifact path - -### Phase 61 — Operate It (5 plans · ~6 sessions) - -**61-01 Backfill** · agent · 2 sessions -- `memory-daemon admin backfill-index --index bm25|vector|all`: replay - events from RocksDB into the index, reset the index checkpoint, resumable, - idempotent; progress on stderr -- Build on the import/bootstrap code from 59-02 if the triage kept it -- Verify: e2e test ingests events with an old daemon build's schema - (fixture), runs backfill, and `memory search` returns previews -- Update README status row and `docs/UPGRADING.md` to say backfill exists - -**61-02 Daemon lifecycle** · agent · 1 session -- Do **not** implement double-fork. Ship `memory-daemon install-service` - that writes a launchd plist (macOS) or systemd user unit (Linux) and - loads it, plus `uninstall-service` -- Keep `--background` exiting non-zero, now pointing at `install-service` -- Verify in the bats suites on both Tier 1 platforms - -**61-03 Panic audit** · agent · 1 session -- Enumerate the 266 `unwrap()`/`expect()` sites in `memory-daemon` and - `memory-service`; classify each as provably-infallible, needs-`?`, or - needs-a-metric; convert the second class; finish the 5 remaining - lock-poisoning sites -- Verify: a fuzz-style e2e that sends malformed and boundary requests to - every RPC and asserts the daemon is still answering afterward - -**61-04 Offline TOC rebuild** · agent · 1 session -- Implement `admin rebuild-toc` for real: rebuild nodes from events for a - date range, replacing the "exits non-zero with guidance" stub -- Verify: delete TOC nodes in a fixture store, rebuild, assert byte-equal to - the scheduled rollup's output - -**61-05 Installer: register, uninstall, status** · agent · 1 session -- Land the March CREG/META work: `memory-installer install --agent claude` - registers the plugin so Claude Code discovers it on launch; version from - `.claude-plugin/plugin.json`; re-install idempotent -- Build the two March phases that were never started: `memory-installer - uninstall --agent ` (removes registry entries and files; no-op when not - installed) and `memory-installer status` (installed runtimes, versions, - paths) -- `--for all` / `--all` and Gemini/Codex/Copilot registration stay v3.3+ as - the March roadmap had them -- Verify: bats — install, `status` shows it, Claude Code loads the plugin, - `uninstall`, `status` says not installed, second `uninstall` exits 0 - -### Phase 62 — Cross-encoder rerank (conditional · 2 sessions) - -Only if the 60-02 result shows retrieval, not generation, is the bottleneck -(the harness's per-type breakdown will say). The extension point exists and -returns an explicit error today; keep it that way until a number says -otherwise. Do not build ahead of evidence — that is how v3.0 got here. +**Acceptance:** +- [ ] bats (both Tier 1 suites): `install-service` → `memory-daemon status` + healthy within 10 s → `uninstall-service` → status reports not + running; unit file removed +- [ ] Re-running `install-service` is idempotent (unit rewritten, no + duplicate) +- [ ] Quickstart shows `install-service` as the recommended path +- [ ] README row "Background daemonization" → "Via service unit" + +**Effort:** 1 session · **Owner:** agent + +--- + +#### 61-03 · Panic audit + +**Why:** F5. Get the real number, then fix the class that matters. + +**Files:** `crates/memory-service/src/{agents,retrieval,federated, +episodes,teleport_service,topics,novelty}.rs`, +`crates/memory-daemon/src/clod.rs`, `crates/memory-indexing/src/ +vector_updater.rs:459`, `memory-types/src/sync.rs:43`; new +`crates/e2e-tests/tests/hostile_input_test.rs`. + +**Steps:** +1. Real count: a `cargo clippy` run with + `-W clippy::unwrap_used -W clippy::expect_used` restricted to non-test + code (the lints already skip `#[cfg(test)]`). Commit the count in the PR + description. +2. Classify each site: (a) provably infallible — annotate with + `// SAFETY:`-style `// INFALLIBLE:` comment or convert to + `expect("")`; (b) fallible on a request path — convert to `?` + with a `Status::internal`/`invalid_argument`; (c) lock poisoning — + finish the 5 with `parking_lot` or `unwrap_or_else(PoisonError::into_inner)` + per the 54-06 policy already chosen. +3. Enable `#![warn(clippy::unwrap_used, clippy::expect_used)]` in + `memory-service` and `memory-daemon` lib roots so regressions are + visible (warn, not deny, so tests keep passing). +4. `hostile_input_test.rs`: for every RPC in `proto/memory.proto`, send + empty, oversized (1 MiB string), malformed-UTF-8, negative/overflow + numeric, and unknown-enum requests via the direct-handler pattern + (`tonic::Request`, per v2.2 decision); after each, a health RPC must + still answer. + +**Acceptance:** +- [ ] Clippy count of `unwrap_used + expect_used` in production code + recorded before/after; class (b) count is **0** after +- [ ] `hostile_input_test.rs` covers all 30 RPCs and passes +- [ ] Lock-poisoning grep returns 0 production sites + +**Effort:** 1 session · **Owner:** agent + +--- + +#### 61-04 · Offline TOC rebuild + +**Why:** F5. The stub is honest but the gap is real. + +**Files:** `crates/memory-daemon/src/cli.rs:231` +(`AdminCommands::RebuildToc`), `commands.rs`; `crates/memory-toc` (the +rollup jobs the scheduler runs — reuse, do not reimplement); +`crates/e2e-tests/tests/pipeline_test.rs`. + +**Design:** `admin rebuild-toc --from YYYY-MM-DD --to YYYY-MM-DD +[--dry-run]` runs the same day/week/month/year rollup code the scheduler +invokes, over events in range, replacing existing nodes for that range. +Stopped-daemon only, same lock rule as 61-01. + +**Acceptance:** +- [ ] e2e: build TOC via scheduler path → snapshot nodes → delete them → + `rebuild-toc` → nodes byte-equal to the snapshot (ids, ranges, + summaries) +- [ ] `--dry-run` reports the node count it would write +- [ ] README row "Offline TOC rebuild" → "Works" + +**Effort:** 1 session · **Owner:** agent + +--- + +#### 61-05 · Installer: register, uninstall, status + +**Why:** F2, and the March v3.2's still-valid half. + +**Files:** `crates/memory-installer/src/main.rs` (`Commands::{Uninstall, +Status}`), `converters/claude.rs` (port the registration from the branch: +`known_marketplaces.json`, `installed_plugins.json`, `settings.json` +`enabledPlugins`), `.claude-plugin/plugin.json` + `marketplace.json` +(port), `tests/e2e_converters.rs`, `tests/cli/claude-code/*.bats`. + +**Steps:** +1. Port CREG-01..06 + META-01..03 per the 59-02 sequence; drop every + OpenCode path. +2. `uninstall --agent claude|codex|gemini|copilot`: remove registry entries + (Claude) and installed files; no-op exit 0 when nothing is installed. +3. `status`: table of runtime · installed version · path · registered + (Claude only) · "not installed". +4. Gemini/Codex/Copilot stay convert-only (registration is v3.3+ REG-F01). + +**Acceptance:** +- [ ] bats: `install --agent claude` → `status` shows version+path+registered + → Claude Code launched headless lists the plugin → `uninstall` → + `status` "not installed" → second `uninstall` exits 0 silently +- [ ] `.claude-plugin/plugin.json` version is the single source for the + install path (META-03) +- [ ] Re-install is idempotent (CREG-06) + +**Effort:** 1 session · **Owner:** agent + +--- + +### Phase 62 — Cross-encoder rerank (conditional · 2 sessions) + +**Gate:** run only if 60-02's per-type breakdown shows retrieval is the +limiter — e.g. multi-hop and temporal recall@k high but judge accuracy low +means generation, not retrieval; the reverse means rerank might help. The +extension point (`memory-orchestrator` rerank trait, explicit +`NotImplemented`) stays as is until then. If the gate opens: local +cross-encoder via Candle in `memory-embeddings`, wired behind +`--rerank=cross`, measured on the same fixtures, committed before any README +change. **Do not build ahead of evidence.** + +--- ### Launch (side quest · maintainer) -- **Now:** publish the blog post. It is about the process and needs no - number. -- **After 60-02 lands:** repo description and topics, enable Discussions, - record the demo, then Show HN / r/rust / r/LocalLLaMA with the number in - the first paragraph. The drafts in `docs/launch/` need one edit each to - cite it. +- **Now:** blog post (process story, no number needed). +- **After 60-02:** repo description + topics (`ai-agents`, `memory`, + `rust`, `claude-code`, `local-first`), enable Discussions, record the + demo, then Show HN / r/rust / r/LocalLLaMA with the number in paragraph + one. Each draft in `docs/launch/launch-copy.md` needs one edit to cite + it. + +--- + +## 5. Requirement map + +| ID | Requirement | Plan | +|---|---|---| +| REL-01 | Tag must be an ancestor of `main` | 59-01 | +| REL-02 | Crate version must equal tag | 59-01 | +| REL-03 | Any failed platform build → no release | 59-01 | +| REL-04 | Release body from CHANGELOG | 59-01 | +| BENCH-10 | Per-conversation isolation on cli backend | 60-01 | +| BENCH-11 | Deterministic drain wait | 60-01 | +| BENCH-12 | Committed `locomo_llm_judge` full-dataset result | 60-02 | +| BENCH-13 | Layer switch bm25/vector/hybrid on custom harness | 60-03 | +| QUAL-01 | Semantic fixture set, ≥15 tests | 60-03 | +| QUAL-02 | Topic clustering purity + ARI artifact | 60-03 | +| QUAL-03 | README "Solid" rows cite artifacts | 60-03 | +| OPS-01 | `admin backfill-index` resumable, idempotent | 61-01 | +| OPS-02 | `install-service`/`uninstall-service` macOS+Linux | 61-02 | +| OPS-03 | Zero fallible `unwrap` on request paths | 61-03 | +| OPS-04 | Hostile-input e2e over all RPCs | 61-03 | +| OPS-05 | `admin rebuild-toc` real | 61-04 | +| INST-01 | Claude Code plugin registration (CREG/META) | 61-05 | +| INST-02 | `memory-installer uninstall` | 61-05 | +| INST-03 | `memory-installer status` | 61-05 | --- -## 5. Sequencing and estimates +## 6. Sequencing ``` -Phase 59 ████░░░░░░░░░░░░░░░░ 3 sessions guardrails, triage, planning truth -Phase 60 ░░░░██████░░░░░░░░░░ 4 sessions + maintainer run -Phase 61 ░░░░░░░░░░██████████ 6 sessions -Phase 62 ░░░░░░░░░░░░░░░░░░██ 2 sessions only if 60-02 says so +59-01 ──┐ +59-02 ──┼──▶ 61-01 (needs 59-02 decision) +59-03 ──┘ 61-05 (needs 59-02 decision) +60-01 ──▶ 60-02 (maintainer) ──▶ 62 gate +60-03 (independent) +61-02, 61-03, 61-04 (independent of each other; after 59-01 lands) ``` -59 and the agent half of 60 can overlap; 60-02 waits on 60-01; 61-01 waits -on the 59-02 decision. Roughly **13–15 agent sessions plus one maintainer -benchmark run**, which at v3.1's pace is two to three weeks. +| Phase | Sessions | Notes | +|---|---|---| +| 59 | 3 | can overlap with 60-01 and 60-03 | +| 60 | 4 + 1 maintainer run | 60-02 blocks the launch posts | +| 61 | 6 | 61-01/61-05 wait on 59-02 | +| 62 | 0 or 2 | gated | +| **Total** | **13–15 agent sessions + 1 maintainer run** | two to three weeks at v3.1 pace | -## 6. Risks +## 7. Risks | Risk | Mitigation | |---|---| -| The LOCOMO number is bad | Then it is bad and gets committed anyway; the positioning doc already says how to talk about it. A bad honest number beats the current no number. Phase 62 exists for this case | -| Cherry-picking 81 commits is worse than rewriting | 59-02 produces the conflict map first; the maintainer chooses with data. Rewrite is acceptable — the branch is a reference, not an obligation | -| Live-backend isolation is slow (10 daemon spawns) | Acceptable for a run that happens once per release. Do not optimise it before it exists | -| API cost surprise | Cap the judge at 200 questions on the first run, commit that, then run the full set | - -## 7. Success criteria - -- [ ] A tag that is not on `main`, or whose version disagrees with - `Cargo.toml`, cannot produce a release (59-01) -- [ ] `benchmarks/results/` contains one `locomo_llm_judge` result on the full - dataset, real backend, hardware and model recorded (60-02) -- [ ] Every "Solid" row in the README status table cites a committed quality - result (60-03) -- [ ] A pre-v3.1 store can be backfilled without data loss (61-01) -- [ ] `memory-daemon install-service` works on macOS and Linux Tier 1 (61-02) -- [ ] `memory-installer install/uninstall/status` round-trips on Claude Code - and the plugin is discovered without manual registry edits (61-05) -- [ ] No `unwrap()` on a request path that user input can reach (61-03) -- [ ] `PROJECT.md` describes the shipped system; the known gaps are GitHub - issues; no orphan `gsd/` branches (59-03, 59-02) -- [ ] `task pr-precheck` green on every PR (standing rule) - -## 8. Decisions needed from the maintainer - -Recorded 2026-09-01 (plan adopted as written; recommendations accepted): - -1. **The March branch line:** cherry-pick export/import and Claude Code - registration by feature. OpenCode converter is dropped. Conflict map: +| LOCOMO number is poor | Commit it anyway (56-03 gate). The positioning doc already frames it. Phase 62 exists for exactly this | +| Cherry-pick conflicts worse than expected | 59-02 maps them before anyone commits to porting; rewrite is a valid outcome | +| Spawn-per-conversation is slow | Once per release; acceptable. Do not optimise before it exists | +| Judge cost surprise | `--limit-questions 200` dry run first | +| Backfill on a large store takes hours | Batch + checkpoint make it resumable; document expected rate from the e2e fixture | +| `clippy::unwrap_used` warn is noisy | Warn not deny; the PR records the count, CI stays green | + +## 8. Milestone success criteria + +- [ ] REL-01..04: a wrong tag cannot produce a release (59-01) +- [ ] BENCH-12: one committed `locomo_llm_judge` result, full dataset, real + backend, provenance fields non-null (60-02) +- [ ] QUAL-03: every README "Solid" row links a committed artifact (60-03) +- [ ] OPS-01: v3.0 fixture store backfills and searches (61-01) +- [ ] OPS-02: service install round-trips on both Tier 1 platforms (61-02) +- [ ] OPS-03/04: zero fallible unwraps on request paths; hostile-input suite + green (61-03) +- [ ] INST-01..03: install/status/uninstall round-trip on Claude Code (61-05) +- [ ] `PROJECT.md` accurate; 8 `v3.2` issues; no `gsd/` branches (59-02, + 59-03) +- [ ] `task pr-precheck` green on every PR + +## 9. Out of scope (stays v3.3+) + +REST/HTTP endpoint · Python SDK · memory views UI · `--for all` / `--all` +installer flags · Gemini/Codex/Copilot registration · Windows service +install · true double-fork daemonization · consolidation hook · cross-project +unified memory · per-agent dedup scoping. + +## 10. Decisions (recorded 2026-09-01) + +Recommendations accepted as written: + +1. **March branch line:** cherry-pick export/import and Claude Code + registration **by feature**. OpenCode converter is dropped; its branch + `gsd/phase-57-opencode-converter-registration` is deleted. Conflict map: [phase-59-orphan-branch-triage.md](phase-59-orphan-branch-triage.md). Agent executes the cherry-picks in Phase 61, not wholesale merges. 2. **Launch timing:** blog now (maintainer); product posts after 60-02. 3. **Daemonization:** service unit files, not double-fork. See #42. +4. **Backfill mode:** stopped-daemon CLI only for v3.2. No admin RPC against + a running daemon this milestone. See #41. diff --git a/proto/memory.proto b/proto/memory.proto index d902d4f..9c394b6 100644 --- a/proto/memory.proto +++ b/proto/memory.proto @@ -67,6 +67,10 @@ service MemoryService { // Get vector index status and statistics rpc GetVectorIndexStatus(GetVectorIndexStatusRequest) returns (VectorIndexStatus); + // Read BM25/vector index checkpoints and the outbox head (Phase 60-01). + // Used by the live-backend LOCOMO harness to wait for drain without sleep. + rpc GetIndexCheckpoints(GetIndexCheckpointsRequest) returns (GetIndexCheckpointsResponse); + // Topic Graph RPCs (Phase 14 - TOPIC-08) // Get topic graph status and statistics @@ -695,6 +699,26 @@ message VectorIndexStatus { int64 size_bytes = 6; } +// Request for index checkpoints (Phase 60-01) +message GetIndexCheckpointsRequest {} + +// One index's crash-recovery checkpoint. +message IndexCheckpointInfo { + // "bm25" | "vector" | "combined" + string index_type = 1; + // Last outbox sequence processed + uint64 last_sequence = 2; + // Documents processed since checkpoint creation + uint64 processed_count = 3; +} + +// Response: checkpoints plus the outbox head (next sequence to assign). +message GetIndexCheckpointsResponse { + repeated IndexCheckpointInfo checkpoints = 1; + // Next outbox sequence that will be assigned + uint64 outbox_head = 2; +} + // ===== Topic Graph Messages (Phase 14 - TOPIC-08) ===== // Request for topic graph status