feat(tinycortex): run the memory queue on the crate engine (W4) (#4781)

This commit is contained in:
Steven Enamakel
2026-07-10 20:27:41 -07:00
committed by GitHub
parent 8a6e780dc8
commit 9a7d91f833
19 changed files with 1757 additions and 2067 deletions
+10 -2
View File
@@ -41,7 +41,7 @@ The crate exposes every seam the plan expects, plus more. Host implements these:
| Trait | Path | Host adapter (W1) |
| --- | --- | --- |
| `EmbeddingBackend` | `store/vectors/embedding.rs:32` | `tinycortex/embeddings.rs` over `openhuman::embeddings` |
| `EmbeddingBackend` | `store/vectors/embedding.rs:32` | `tinycortex/embeddings.rs` over `openhuman::embeddings` *(amended: bridged to `tinyagents::harness::embeddings::EmbeddingModel` in W-EMB, plan §8.2)* |
| `Embedder` (retrieval/summarise) | `score/embed.rs:28` | same seam file |
| `ChatProvider` | `score/extract/llm.rs:70` | `tinycortex/chat.rs` over `memory::chat` / `inference` |
| `Summariser` (bucket-seal, structured) | `tree/summarise.rs:70` | `tinycortex/chat.rs` |
@@ -54,7 +54,9 @@ The crate exposes every seam the plan expects, plus more. Host implements these:
| `EntityOccurrenceIndex` | `graph/types.rs:44` | `tinycortex/sinks.rs` |
| `SelfIdentity` | `store/entity_index/store.rs:25` | host identity registry |
| `GoalsGenerator` | `goals/reflect.rs:57` | `tinycortex/chat.rs` |
| `SourceReader` | `sources/readers/mod.rs:1732` | local readers only; live sync stays host |
| `SourceReader` | `sources/readers/mod.rs:1732` | local readers only *(amended: live-sync readers join the crate in W-SYNC, plan §8)* |
| `SyncEventSink` *(new, W-SYNC)* | `sync/traits.rs` (planned) | `tinycortex/sync_sink.rs``MemorySyncStage` bus events |
| `SkillDocSink` *(new, W-SYNC)* | `sync/traits.rs` (planned) | forwards to `MemoryClient::store_skill_sync` (host-retained unified tier) |
| `ConversationEventBus` / `ChannelEventHandler` | `conversations/bus.rs:106/95` | `tinycortex/bus.rs` (translate to `DomainEvent`) |
Config seam: `MemoryConfig{workspace, embedding: EmbeddingConfig{dim,model,strict},
@@ -165,3 +167,9 @@ retrieval: RetrievalConfig{default_profile}, sync_budget: SyncBudgetConfig}` +
**No hard gap blocks W1 (seam scaffolding) or W4 (queue, only drift D2).** W3 is gated by G1;
W5 by G3/G6; W6/W7 by G2. These are recorded here and mirrored in the spec's deletion ledger.
**Amendment 2026-07-09 (plan §8):** the crate's sync data model (`SourceKind::Composio`,
`SyncBudgetConfig`, the TOML source registry with `upsert_composio_source` /
`memory_sync_defaults_for_toolkit`) — previously host-consumed only — becomes **engine-consumed by
W-SYNC**: the crate's own sync engine reads the registry and enforces the budget. New seam traits
`SyncEventSink` / `SkillDocSink` land with W-SYNC.1 (see roster above); no other new gaps.
+47 -13
View File
@@ -25,9 +25,19 @@ touched an engine-mapping memory module after the port line, and classifies each
| Host audit SHA | `7850cf363559bcbb7ba688cbc4fccdb6bd9ce754` (`main`, 2026-07-04) |
| TinyCortex submodule | `vendor/tinycortex``tinyhumansai/tinycortex` |
| TinyCortex audit SHA | `d1a8c7be2babc8fff7a72ed93861f459f3d6fa58` |
| **TinyCortex pinned SHA (current)** | `a8e10f7dd8ebdb9b0905e1380fefcc6bf5a65207` — audit SHA **+ #59** (native-dep alignment, §0.4) **+ #63/#64** (D2/D1 drift ports) merged; this is the live gitlink |
| TinyCortex crate version | `0.1.1` |
| **Port line (derived)** | **after 2026-06-25, before 2026-06-28** (see below) |
> **Execution status (2026-07-10). Drift closure COMPLETE — all rows CLOSED.**
> The submodule is pinned at `a8e10f7`. **D3 CLOSED** (folded into #59);
> **D2 CLOSED** — merged as **tinycortex#63** (`is_host_io_error`, `e352435`);
> **D1 CLOSED** — merged as **tinycortex#64** (rank-before-materialize, `a8e10f7`).
> Host gitlink bumped `33dda94 → a8e10f7` (`chore(vendor): bump tinycortex …
> (tinycortex#63, #64)`). With every drift row closed, **W4** (queue) and **W7**
> (conversations) are now unblocked per the gate rule. D4 (memory_sync corpus,
> W-SYNC) remains its own separate track.
### How the port line was located
The port commits in `vendor/tinycortex` are all dated **2026-06-29**, but that is the date the
@@ -59,16 +69,16 @@ then per-commit file lists intersected with engine-mapping modules, then content
| # | Host commit | Module | Change | Crate state (verified) | Upstream target |
| --- | --- | --- | --- | --- | --- |
| D1 | `007a99b62` (06-30) `perf(memory_conversations): rank before cloning hits in cross-thread search` | `memory_conversations/inverted_index.rs` | Rank matches on cheap borrowed keys (`(doc_id:u32, matched:usize, created_at:&str)`), truncate to `limit`, **then** materialize the KB-sized `CrossThreadHit`. Order-equivalent to score ranking. | **ABSENT.** `vendor/tinycortex/src/memory/conversations/inverted_index.rs:286301` builds the full `CrossThreadHit` (with `content.clone()`, `message_id.clone()`, `created_at.clone()`) for **every** matched doc, then `sort_by(score)` + `truncate`. Pre-fix clone-then-rank shape. | `conversations::inverted_index` — port the rank-before-materialize refactor + its `ranks_by_score_then_recency_before_truncating` test. |
| D2 | `d7bee77e3` (06-30) `fix(memory-queue): classify host-FS I/O errors to stop the tree_jobs Sentry flood` | `memory_queue/worker.rs` | Adds `is_host_io_error(&anyhow::Error) -> bool` classifying **persistent** host-FS failures (EIO/ENOSPC/EROFS) distinct from transient SQLite busy/I-O, so the worker backs off and reports Sentry **once** instead of ~10k events/50min (Sentry CORE-RUST-19J). | **PARTIAL.** `vendor/tinycortex/src/memory/queue/worker.rs:89107` has `is_sqlite_io_transient` (transient family) but **no** `is_host_io_error` (persistent host-FS family). | `queue::worker` — port the `is_host_io_error` predicate + its unit tests (EIO/ENOSPC/EROFS, context-layer, text fallback). **Only the predicate.** The Sentry-once emission and the `mark_storage_degraded` flag are host-owned (see D2-host below). |
| D3 | `c43f79641` (07-03) (within TinyAgents migration) | `memory_store/vectors/store.rs` | `count()` reads `COUNT(*)` as `i64` and converts via `usize::try_from(...).context(...)` instead of `row.get::<usize>` directly — robustness against platform `usize`/`i64` mismatch. | **ABSENT.** `vendor/tinycortex/src/memory/store/vectors/store.rs:370380` still does `let count: usize = ... row.get(0)` then `Ok(count)`. | `store::vectors::store` — small; port the `i64` + `try_from` guard. |
| D1 | `007a99b62` (06-30) `perf(memory_conversations): rank before cloning hits in cross-thread search` | `memory_conversations/inverted_index.rs` | Rank matches on cheap borrowed keys (`(doc_id:u32, matched:usize, created_at:&str)`), truncate to `limit`, **then** materialize the KB-sized `CrossThreadHit`. Order-equivalent to score ranking. | ✅ **CLOSED.** Ported + merged as **tinycortex#64** (`a8e10f7`): the rank-before-materialize refactor + `ranks_by_score_then_recency_before_truncating` test. Was pre-fix clone-then-rank at the port line. | — (closed) |
| D2 | `d7bee77e3` (06-30) `fix(memory-queue): classify host-FS I/O errors to stop the tree_jobs Sentry flood` | `memory_queue/worker.rs` | Adds `is_host_io_error(&anyhow::Error) -> bool` classifying **persistent** host-FS failures (EIO/ENOSPC/EROFS) distinct from transient SQLite busy/I-O, so the worker backs off and reports Sentry **once** instead of ~10k events/50min (Sentry CORE-RUST-19J). | ✅ **CLOSED.** Ported + merged as **tinycortex#63** (`e352435`): the `is_host_io_error` predicate + 2 unit tests (EIO/ENOSPC/EROFS × typed/context/text; negatives). **Predicate only** — the Sentry-once emission and `mark_storage_degraded` flag stay host-owned (see D2-host below). | — (closed) |
| D3 | `c43f79641` (07-03) (within TinyAgents migration) | `memory_store/vectors/store.rs` | `count()` reads `COUNT(*)` as `i64` and converts via `usize::try_from(...).context(...)` instead of `row.get::<usize>` directly — robustness against platform `usize`/`i64` mismatch. | ✅ **CLOSED.** Present at `vendor/tinycortex/src/memory/store/vectors/store.rs:371384` (`usize::try_from(count).context(...)`)folded into #59's `usize`→`i64`/`try_from` rusqlite-0.40 sweep and merged. | — (closed) |
**Open drift rows: D1, D2 (predicate), D3.** These are the only three engine behavior changes since the
port line. All three are small-to-moderate and independent.
**Drift rows: D1, D2 (predicate), D3** the only three engine behavior changes since the port line,
all small and independent, **now all CLOSED** (gitlink `a8e10f7`).
- D1 gates **W7** (long tail — conversations).
- D2 gates **W4** (queue).
- D3 gates **W3** (store + chunks).
- D1 gates **W7** (long tail — conversations). ✅ **CLOSED** — merged as tinycortex#64.
- D2 gates **W4** (queue). ✅ **CLOSED** — merged as tinycortex#63.
- D3 gates **W3** (store + chunks). ✅ **CLOSED** — already in the crate via #59.
### HOST-OWNED — same commits, layers that stay in OpenHuman (no upstream)
@@ -102,10 +112,10 @@ not as drift. See `tinycortex-api-gap-audit.md`.
| Engine module | Maps to crate | Open drift | Gates workstream | Status |
| --- | --- | --- | --- | --- |
| `memory_store` (chunks, content, vectors, kv, entity_index, safety) | `store/`, `chunks/` | **D3** (vectors count guard). `wiki_git`/`obsidian*` host-retained (not drift). | W3 | **OPEN** (D3) |
| `memory_store` (chunks, content, vectors, kv, entity_index, safety) | `store/`, `chunks/` | **D3** (vectors count guard) ✅ closed via #59. `wiki_git`/`obsidian*` host-retained (not drift). | W3 | **CLEAR** (D3 closed) |
| `memory_tree` (tree, retrieval, score, summarise) | `tree/`, `retrieval/`, `score/` | none (health/rpc/embed-compute are host-owned) | W5 | **CLEAR** |
| `memory_queue` | `queue/` | **D2** (predicate) | W4 | **OPEN** (D2) |
| `memory_conversations` | `conversations/` | **D1** (rank-before-clone) | W7 | **OPEN** (D1) |
| `memory_queue` | `queue/` | **D2** (predicate) — ✅ merged tinycortex#63 | W4 | **CLEAR** (D2 closed) |
| `memory_conversations` | `conversations/` | **D1** (rank-before-clone) — ✅ merged tinycortex#64 | W7 | **CLEAR** (D1 closed) |
| `memory_diff` | `diff/` | none (git-ledger captured) | W7 | **CLEAR** |
| `memory_entities` | `entities/` | none | W7 | **CLEAR** |
| `memory_graph` | `graph/` | none | W7 | **CLEAR** |
@@ -115,10 +125,34 @@ not as drift. See `tinycortex-api-gap-audit.md`.
| `memory_tools` (engine part) | `tool_memory/` | none | W7 | **CLEAR** |
| `memory_search` (`vector`, `scoring` engine parts; `tools` are host) | `retrieval/`, `score/` | none (churn only) | W5 | **CLEAR** (classify tools vs engine in W5) |
**Summary:** 3 open drift rows (D1, D2, D3), each small and independent, each a single-module
tinycortex PR. Nothing else drifted. `memory_search` is a mixed module not in the plan's move table —
**Summary:** 3 drift rows total, **all CLOSED** — **D3** via #59, **D2** via tinycortex#63,
**D1** via tinycortex#64; gitlink now `a8e10f7`. No engine drift remains open; **W4 and W7 are
unblocked**. Nothing else drifted. `memory_search` is a mixed module not in the plan's move table —
its `tools/` stay host (agent tools), its `vector`/`scoring` are engine (W5) — flagged for the gap audit.
## D4 — memory_sync corpus (2026-07-09 reclassification, W-SYNC)
The plan's §8 amendment moves the sync engine into the crate, so the blanket **HOST-OWNED
"live sync"** classification above is superseded for engine-mapping sync code. Unlike D1D3 there
is **no existing crate port to diff against** — the entire `memory_sync/` engine (plus the
`memory_sources` dispatcher/reconcile parts) is port scope. The port baseline for W-SYNC is taken
**fresh from host `main` at W-SYNC.1 branch time**, so ordinary drift cannot accumulate; this entry
exists to (a) record the reclassification and (b) pin the post-port-line commits that already
touched the corpus, so the W-SYNC.1 port provably includes them:
| # | Host commit | Files in corpus | Note |
| --- | --- | --- | --- |
| D4.1 | `c43f79641` (07-03) | `composio/providers/{sync_state,traits}.rs` | TinyAgents-cutover import churn |
| D4.2 | `27b00b539` (07-05) | `sources/rebuild.rs` | test-parity cleanup (1 line) |
| D4.3 | `653e6e143` (07-06) | `memory_sources/sync.rs` (+312) | self-heal drifted content-sha tokens + prune vanished folder items |
| D4.4 | `e456b7799` (07-07) | `memory_sources/{rpc,sync}.rs`, `canonicalize/email.rs`, `composio/providers/{gmail/post_process,notion/source,orchestrator}.rs`, `sources/github.rs` | orchestration-fixes wave |
**Gate:** W-SYNC.1 (crate scaffolding PR) requires this enumeration current as of its branch point
(re-run the scan: `git log --since=2026-06-25 --oneline -- src/openhuman/memory_sync
src/openhuman/memory_sources`); the W-SYNC.3 host flip requires D4 **CLOSED** — every listed commit
(and any accrued since) verifiably contained in the crate port or explicitly waived. Host-retained
parts (schedulers, bus subscribers, RPC wrappers, keychain/OAuth) remain HOST-OWNED as before.
## Closing the ledger (procedure)
For each open row:
+93 -3
View File
@@ -1,9 +1,16 @@
# TinyCortex Memory Migration — Plan & Audit
**Status:** draft plan — no code changes yet.
**Status:** W1W2 landed; W3 partial (chunks sub-store flipped). Amended 2026-07-09 (see §8) to a **sync-inclusive** scope.
**Anchor precedent:** the TinyAgents harness migration (#4249 / #4399 / #4473 and follow-ups).
**Target:** migrate large portions of the OpenHuman memory subsystem onto the `tinycortex` crate, vendored as a git submodule at **`vendor/tinycortex`** (`https://github.com/tinyhumansai/tinycortex`).
> **Amendment 2026-07-09 (§8 — W-SYNC + W-EMB).** The original seam contract kept live sync
> (`memory_sync/`) host-side and TinyCortex strictly network-free. That boundary is **revised**:
> TinyCortex becomes all-encompassing of the sync modules (Composio toolkit sync included) behind an
> optional `sync` cargo feature, and embeddings are inherited from `tinyagents::harness::embeddings`.
> Sections §1/§3 below are the original contract, kept for history; where they conflict with §8,
> **§8 wins**.
---
## 0. Ground truth (as audited)
@@ -63,7 +70,7 @@ TinyCortex lives at **`vendor/tinycortex`** as a git submodule. Any change to en
- **All RPC surfaces**: `memory/ops/`, `memory/schemas/`, `memory/schema/`, `memory/read_rpc/`, `rpc_models.rs` (controller framework types `ControllerSchema`/`RpcOutcome` are host-only). JSON-RPC method names and payload shapes must not change.
- **Agent tools**: `memory/tools/` and `memory/query/` (`Tool`/`ToolResult` impls, `SecurityPolicy` gating) — they become thin wrappers over crate retrieval primitives.
- **Live sync**: all of `memory_sync/` (Composio/MCP/OAuth/pollers), `memory/sync.rs` lifecycle + event-bus stage events.
- **Live sync**: all of `memory_sync/` (Composio/MCP/OAuth/pollers), `memory/sync.rs` lifecycle + event-bus stage events. *(Superseded by §8: the sync engine moves to the crate; only schedulers, credentials/OAuth, bus bridges, and RPC wrappers stay host.)*
- **Process glue**: `memory/global.rs` singleton + background queue worker; `memory/source_scope.rs` tokio task-locals; `memory/chat.rs` (LLM adapter over `openhuman::inference`); embeddings provider wiring.
- **Policy/UX**: `preferences.rs`, `remember.rs`, `tree_policy.rs`, `util/redact.rs`, config mapping (`Config``tinycortex::MemoryConfig`).
- **Namespace document/graph store** (until/unless deliberately upstreamed — TinyCortex explicitly excludes it today).
@@ -116,7 +123,7 @@ Per the tinyagents rules: **adapter first → prove parity → flip ownership
**W6 — Ingest.** `memory/ingest_pipeline.rs` + `memory/ingestion/` re-pointed to `tinycortex::ingest` + `score::extract` (LLM extraction via the seam's `ChatProvider`). The namespace document/graph store path stays host-side unless deliberately upstreamed (explicit decision in this workstream). `ingest_chat`/`ingest_document_with_scope` keep their signatures — 11 call sites (learning, agent harness, archivist) unchanged.
**W7 — Long tail.** `memory_diff``diff`, `memory_entities``entities`, `memory_graph``graph`, `memory_goals``goals`, `memory_archivist``archivist`, `memory_sources` registry/local readers→`sources`, tool-memory engine→`tool_memory`, conversation storage→`conversations`. Each is small and independent; each deletes its legacy module on flip. `memory_sync` explicitly **does not move** — it keeps writing through the crate's ingest/source contracts.
**W7 — Long tail.** `memory_diff``diff`, `memory_entities``entities`, `memory_graph``graph`, `memory_goals``goals`, `memory_archivist``archivist`, `memory_sources` registry/local readers→`sources`, tool-memory engine→`tool_memory`, conversation storage→`conversations`. Each is small and independent; each deletes its legacy module on flip. `memory_sync` engine parts **move via W-SYNC (§8)** — the original "does not move" rule is superseded.
**W8 — Test port + parity sweep + deletion-ledger close-out.** See §5. Ends with: deletion ledger fully executed, `gitbooks/developing/architecture.md` + a new `architecture/memory.md` seam doc written (the durable post-plan documentation, as `agent-harness.md` was for tinyagents), `AGENTS.md`/`CLAUDE.md` module tables updated, and the spec doc archived.
@@ -173,3 +180,86 @@ Ordering rule for this migration: **within each workstream, the implementation (
- All engine logic served by `tinycortex` at a tagged, crates.io-published version, submodule pinned in lockstep.
- JSON-RPC method names/payloads unchanged; existing user workspaces open and recall identically (golden-workspace parity green).
- Full suites green on both CI lanes; gitbooks/AGENTS.md updated; spec + ledgers archived.
- **(Amended)** `memory_sync/` engine deleted from the host per §8 (W-SYNC); host retains only
schedulers, credentials/OAuth, event-bus bridges, and RPC wrappers. `src/openhuman/embeddings/`
provider impls deleted per §8 (W-EMB); embeddings served by `tinyagents::harness::embeddings`.
---
## 8. Amendment (2026-07-09) — sync-inclusive scope + embeddings inheritance
Decisions (confirmed with the maintainer): TinyCortex becomes **all-encompassing of the sync
modules**; "Composio-related skills syncing" = the per-toolkit memory-sync providers
(`memory_sync/composio/providers/{gmail,slack,github,notion,linear,clickup}`); **full network in
crate** behind a cargo feature; Composio API key via env var + config field (host still resolves
from the `composio-direct` keychain slot in production); a **mocked + live test pair** gates the
Composio sync port; embeddings **fully inherited from TinyAgents**.
### 8.1 Revised seam contract — W-SYNC
**Moves into TinyCortex** (new cargo feature `sync`, off by default so the default build stays a
pure, network-free library):
| Host source | Crate destination |
| --- | --- |
| `memory_sync/traits.rs` (`SyncPipeline`/`SyncOutcome`/`SyncPipelineKind`) | `src/memory/sync/traits.rs` (init/tick take `&MemoryConfig` + a `SyncContext`) |
| `memory_sync/composio/providers/*` (all 6 toolkits + registry, orchestrator, sync_state, catalogs, user_scopes) | `src/memory/sync/composio/providers/*` |
| New Composio HTTP client (modeled on `src/openhuman/composio/client.rs`, minus keychain) | `src/memory/sync/composio/client.rs`**direct** (BYO key → backend.composio.dev) and **proxied** (base_url + bearer; OpenHuman-backend default) modes |
| `memory_sync/canonicalize/` | merged into the crate's existing `ingest/canonicalize` |
| `memory_sync/workspace/` scan logic (not the timers) | `src/memory/sync/workspace.rs` |
| `memory_sync/sources/{audit,rebuild}.rs` | `src/memory/sync/{audit,rebuild}.rs` |
| `memory_sync/sync_status/` SQL | `src/memory/sync/status.rs` (chunk schema lives in the crate) |
| `memory_sources/sync.rs::sync_source` dispatcher + `reconcile.rs::ensure_composio_sources` | `src/memory/sync/dispatch.rs` |
**Stays host-side:** scheduler loops (`composio/periodic.rs`, `workspace/periodic.rs`, file
watcher — crate exposes `sync::tick_all`/`tick_pipeline(id)`, host owns tokio timers, same pattern
as `queue::run_once`); credentials/OAuth (keychain slot `composio-direct`, RPC
`composio.{get_mode,set_api_key,clear_api_key}`, connection lifecycle); event bus (new
**`SyncEventSink`** trait in the crate, host adapter `src/openhuman/tinycortex/sync_sink.rs`
translates to `MemorySyncStage` bus events); RPC wrappers (`memory/ops/sync.rs`,
`memory/schemas/sync.rs`, `memory_sources/rpc.rs` — JSON-RPC names/payloads unchanged); the
UnifiedMemory writeback path via a new **`SkillDocSink`** trait (providers call
`MemoryClient::store_skill_sync`, which writes the host-retained namespace-document tier — the
crate must not depend on that tier); MCP transport (MCP pipeline stays host through W-SYNC.3).
**Config/key:** crate gains `SyncConfig { budget, composio: Option<ComposioSyncConfig> }` with
`ComposioSyncConfig { mode: Direct|Proxied, base_url, api_key: Option<SecretString>, bearer_token,
entity_id }`; `api_key` falls back to the `COMPOSIO_API_KEY` env var (tests only — the app resolves
from keychain and injects). `SecretString` redacts `Debug`/`Display` and skips serialization.
**Tests (gate):** (a) `vendor/tinycortex/tests/composio_sync_mock.rs` — wiremock-backed gmail
pipeline test, always-on in crate CI (pagination, cursor advance, taint = `external_sync`,
idempotent second tick, `SkillDocSink` capture, 401 without key leakage); (b)
`vendor/tinycortex/tests/composio_sync_live.rs``#[ignore]`, runs when `COMPOSIO_API_KEY` is set,
direct-mode gmail sync with structural assertions, budget-capped (~5 records).
**Ordering:** W-SYNC slots **after W6, before W7** (providers write through crate ingest; queue
jobs through crate queue). Phases: **W-SYNC.1** scaffolding (feature + config + client + traits +
gmail provider) → **W-SYNC.2** remaining providers + workspace/audit/rebuild/status → **W-SYNC.3**
host flip (delete `memory_sync` bodies; wire adapters/schedulers/RPC wrappers) → **W-SYNC.4**
cleanup (dedupe the agent-tools Composio client, remove `composio/{bus,periodic,providers}.rs`
shims, decide MCP).
**Drift:** the HOST-OWNED "live sync" classification in the drift ledger is superseded — see the
ledger's **D4** entry.
### 8.2 Embeddings inherited from TinyAgents — W-EMB
`tinyagents::harness::embeddings` becomes the canonical embeddings module:
- **W-EMB.1 (tinyagents PR):** extend `EmbeddingModel` with `name()`/`model_id()`/`signature()`
(default **byte-identical** to the host format `provider={name};model={model_id};dims={dims}`
parity P10 / #1574); port host providers (`ollama`, `cohere`, `voyage`, `cloud`, `noop`) +
`rate_limit.rs`/`retry_after.rs` into `harness::embeddings::providers/*`; merge host `openai.rs`
into the existing `OpenAiEmbeddingModel`.
- **W-EMB.2 (tinycortex PR):** add a `tinyagents` dependency; bridge/replace `EmbeddingBackend`
with `tinyagents::harness::embeddings::EmbeddingModel` (re-export or blanket impl).
- **W-EMB.3 (host PR):** dual submodule bump; `src/openhuman/tinycortex/embeddings.rs` constructs
tinyagents providers from `Config`; delete `src/openhuman/embeddings/` provider impls; keep
`factory.rs` (thin), `rpc.rs`, `schemas.rs`, catalog + config wiring host-side; signature-parity
seam tests in the same PR.
W-EMB is independent of W-SYNC and may run in parallel; land W-EMB.2 **before** the W-SYNC.3 flip
so `SyncContext` is defined against the tinyagents trait once. New risk: `tinycortex → tinyagents`
crate coupling — version bumps move in lockstep in both `[patch.crates-io]` blocks (root +
`app/src-tauri`).
+55 -17
View File
@@ -1,7 +1,11 @@
# TinyCortex Memory Migration — Spec (Phase 0.5 / 0.6)
**Status:** Phase 0 baseline. Anchors the migration to exact reviewed SHAs and ties together the
drift, gap, and parity ledgers. Modeled on `docs/tinyagents-migration-spec.md` + its deletion ledger.
**Status:** Phase 0 baseline **+ execution underway** — #59 (native-dep alignment) merged and the dep
is active (§0.4); W1 seam + W2 type re-export + W3-chunks partial landed; **drift closure COMPLETE**
(**D3** via #59, **D2** via tinycortex#63, **D1** via tinycortex#64; gitlink `a8e10f7`) — so **W4
(queue) and W7 (conversations) are unblocked**. Anchors the migration to exact reviewed SHAs and ties
together the drift, gap, and parity ledgers. Modeled on `docs/tinyagents-migration-spec.md` + its
deletion ledger.
**Companion plan:** [`tinycortex-memory-migration-plan.md`](tinycortex-memory-migration-plan.md)
**Ledgers:** [`tinycortex-drift-ledger.md`](tinycortex-drift-ledger.md) ·
@@ -14,7 +18,8 @@ drift, gap, and parity ledgers. Modeled on `docs/tinyagents-migration-spec.md` +
| --- | --- | --- |
| `tinyhumansai/openhuman` | `7850cf363559bcbb7ba688cbc4fccdb6bd9ce754` | host audit base (`main`, 2026-07-04) |
| `tinyhumansai/tinycortex` | `d1a8c7be2babc8fff7a72ed93861f459f3d6fa58` | crate audit base (v0.1.1) |
| `tinyhumansai/tinycortex` | `35f9518` (branch `chore/align-native-deps-for-openhuman`) | **first required upstream PR**native-dep alignment (§0.4); host may not activate the dep until this merges |
| `tinyhumansai/tinycortex` | `33dda943053e61ef585fc39647cf1854344b6323` | audit base **+ #59** (native-dep alignment, §0.4) merged |
| `tinyhumansai/tinycortex` | `a8e10f7dd8ebdb9b0905e1380fefcc6bf5a65207` | **current pinned gitlink****+ #63/#64** (D2/D1 drift closure) merged; all drift rows CLOSED |
Port line (derived by content, §0.1): **after 2026-06-25, before 2026-06-28** for engine features.
@@ -64,18 +69,19 @@ ledger. Re-export covers the data types (`MemoryEntry`, `MemoryCategory`, `Memor
| Crate edition | 2021 (matches host) ✅ |
| `[patch.crates-io] tinycortex = { path = "vendor/tinycortex" }` | pre-staged in **both** worlds (root + `app/src-tauri`) ✅ |
| CI submodule checkout | recursive on all build/test lanes; covers `vendor/tinycortex` ✅ (verify release lanes in W1, as tinyagents needed) |
| **rusqlite alignment** | ⚠️ **blocker resolved upstream.** Crate pinned `0.32` (bundled), host pins `=0.40.0` (bundled). Two `links = "sqlite3"` = hard Cargo error. Fixed in the crate PR (bump to `0.40` + `usize``i64`/`try_from`). |
| **git2 alignment** | ⚠️ **blocker resolved upstream.** Crate pinned `0.19`, host `0.21` (vendored-libgit2). Two `links = "git2"`. Fixed in the crate PR (bump to `0.21` + API deltas: `Tag::message`, `StringArray::Iter`, `Buf::as_str`). |
| **rusqlite alignment** | **resolved & merged (#59).** Crate was pinned `0.32` (bundled), host pins `=0.40.0` (bundled). Two `links = "sqlite3"` = hard Cargo error. Fixed in #59 (bump to `0.40` + `usize``i64`/`try_from` — the same sweep that closed drift **D3**). |
| **git2 alignment** | **resolved & merged (#59).** Crate was pinned `0.19`, host `0.21` (vendored-libgit2). Two `links = "git2"`. Fixed in #59 (bump to `0.21` + API deltas: `Tag::message`, `StringArray::Iter`, `Buf::as_str`). |
| Crate compiles with aligned deps | ✅ `cargo check --all-targets` clean; 38 diff/checkpoint tests pass. |
| **Host root world compiles with dep active** | ✅ `cargo check --manifest-path Cargo.toml --lib` **exit 0** with `tinycortex = "0.1"` active + submodule at `35f9518`. **No `multiple packages link to native library` error** — one bundled SQLite + one libgit2 confirmed. (Verified locally; the Cargo.toml/lock activation is reverted from the Phase-0 docs branch and re-lands in W1 post-upstream-merge.) |
| **Host root world compiles with dep active** | ✅ `cargo check --manifest-path Cargo.toml --lib` **exit 0** with `tinycortex = "0.1"` active (`Cargo.toml:82`) + submodule at `33dda94`. **No `multiple packages link to native library` error** — one bundled SQLite + one libgit2 confirmed. **Now landed** (post-#59-merge): the `[dependencies]` line and gitlink are committed on the working branch, not reverted. Re-verified 2026-07-09. |
| Host `app/src-tauri` world | to verify in W1 (separate Cargo world / lockfile). |
| `GGML_NATIVE=OFF` macOS ARM | to verify on a macOS runner in W1 (no macOS host here). |
**Activation is deferred to W1.** Per the submodule rule, the host may only bump the gitlink to a
**merged** upstream SHA. The native-dep alignment is committed on the crate branch and ready to PR;
once it merges and is published, W1 lands: (a) `chore(vendor): bump tinycortex`, (b)
`[dependencies] tinycortex = "0.1"` in both worlds. The `[dependencies]` line and the compile
verification below were validated **locally** against the branch to prove the baseline is sound.
**Activation landed (post-#59).** The native-dep alignment merged upstream as **#59** and the host
gitlink was bumped to `33dda94`, so — per the submodule rule (bump only to a **merged** SHA) — W1's
activation is now in place: `[dependencies] tinycortex = "0.1"` is active in the root world
(`Cargo.toml:82`), the seam (`src/openhuman/tinycortex/`) is wired (`src/openhuman/mod.rs:130`), and
`cargo check --lib` is **exit 0**. Remaining §0.4 follow-ups: the `app/src-tauri` world and the
`GGML_NATIVE=OFF` macOS-runner check still verify in W1 (see rows above).
---
@@ -96,8 +102,16 @@ verification below were validated **locally** against the branch to prove the ba
- **RPC surfaces:** `memory/{ops,schemas,schema,read_rpc}`, `rpc_models.rs`. Method names/payloads unchanged.
- **Agent tools:** `memory/tools/`, `memory/query/`, `memory_search/tools/`, `memory_tools`(tool surface) — thin wrappers over crate retrieval + `SecurityPolicy` gating.
- **Live sync:** all of `memory_sync/`, `memory/sync.rs` lifecycle + bus stage events.
- **Process glue:** `memory/global.rs` singleton + queue worker; `memory/source_scope.rs` task-locals; `memory/chat.rs`; embeddings provider wiring.
- **Live sync (amended 2026-07-09, plan §8 / W-SYNC):** the sync **engine** (pipelines, per-toolkit
Composio providers + HTTP client, canonicalize, sync_state, audit/rebuild, sync_status query,
dispatcher) **moves to the crate** behind an optional `sync` cargo feature. The crate's
"never makes a network call" invariant becomes **feature-scoped**: the default build stays
network-free; the `sync` feature adds an HTTP Composio client whose credentials are injected by
the host. Host retains: scheduler loops (tick-driven crate, like `queue::run_once`),
credentials/OAuth (keychain `composio-direct`), event-bus bridges via a new **`SyncEventSink`**
seam trait, RPC wrappers (`memory/{ops,schemas}/sync.rs`, `memory_sources/rpc.rs`), and the
UnifiedMemory writeback via a new **`SkillDocSink`** seam trait. MCP transport stays host.
- **Process glue:** `memory/global.rs` singleton + queue worker; `memory/source_scope.rs` task-locals; `memory/chat.rs`; embeddings provider wiring. *(Amended, plan §8 / W-EMB: the provider **implementations** in `src/openhuman/embeddings/` migrate upstream into `tinyagents::harness::embeddings` — trait gains `name`/`model_id`/`signature` byte-pinned to `provider={name};model={model};dims={dims}` (P10) — and tinycortex bridges `EmbeddingBackend` to that trait; the host keeps factory/config/RPC wiring only.)*
- **Policy/UX:** `preferences.rs`, `remember.rs`, `tree_policy.rs`, `util/redact.rs`, config mapping.
- **Host-retained `UnifiedMemory` namespace-document tier** (0.3 key finding) — the 10 tables that
coexist in the shared DB but **do not move**: `memory_docs`, `graph_global`, `graph_namespace`,
@@ -110,11 +124,23 @@ verification below were validated **locally** against the branch to prove the ba
### The adapter seam: `src/openhuman/tinycortex/` (W1, mirrors `src/openhuman/tinyagents/`)
**W1 seam files** (all against seam traits already present in the crate, §0.2):
`embeddings.rs` (`EmbeddingBackend`/`Embedder`), `chat.rs` (`ChatProvider`/`Summariser`×2/
`EntityExtractor`/`GoalsGenerator`), `queue_driver.rs` (`QueueDelegates` + tokio worker loop +
Sentry/bus), `config.rs` (`Config``MemoryConfig`), `sinks.rs` (`TreeJobSink`/`TreeLeafSink`/
`SnapshotItemSource`/`EntityOccurrenceIndex`), `bus.rs` (engine outcomes → `DomainEvent`),
`mod.rs` (facade re-exports + boundary doc). All 17 seam traits confirmed present (§0.2).
`mod.rs` (facade re-exports + boundary doc). All 17 W1 seam traits confirmed present (§0.2).
**Later seam additions (amended 2026-07-09):**
- **W-EMB rebridge** — `embeddings.rs` is re-pointed from `openhuman::embeddings` to
`tinyagents::harness::embeddings::EmbeddingModel` (crate bridges `EmbeddingBackend` to that trait,
plan §8.2 / gap-audit roster). Seam file stays; its backing implementation moves upstream.
- **W-SYNC seam file** — `sync_sink.rs` adds the host adapters for **two new crate traits** landing
with W-SYNC.1 (not among the 17 above; they do not exist in the crate at the Phase-0 SHA):
`SyncEventSink` (→ `MemorySyncStage` bus events) and `SkillDocSink` (→
`MemoryClient::store_skill_sync`, the host-retained namespace-document tier). See plan §8.1 and the
gap-audit roster.
---
@@ -135,21 +161,26 @@ audit SHA.
| `memory_graph/` | 3 (0) | W7 | gap **G2** resolved (derive-on-read parity vs host-retained `graph_*`) |
| `memory_goals/` | 7 (0) | W7 | seam `GoalsGenerator` wired |
| `memory_archivist/` | 6 (0) | W7 | `TreeLeafSink` seam wired |
| `memory_sources/` | 16 (0) | W7 | registry + local readers move; **live sync stays host** |
| `memory_sources/` | 16 (0) | W7 / W-SYNC | registry + local readers move (W7); `sync.rs` dispatcher + `reconcile.rs` move in W-SYNC; `rpc.rs` kept host |
| `memory_sync/` (engine) | — | **W-SYNC.3** | drift **D4** closed; W6 landed (crate ingest live); mocked + live Composio test pair green; sync-status parity green; schedulers/bus/RPC/keychain kept host |
| `src/openhuman/embeddings/` (provider impls) | — | **W-EMB.3** | tinyagents provider port merged; signature parity (P10) green; `factory.rs`(thin)/`rpc.rs`/`schemas.rs`/catalog kept host |
| `memory_tools/` | 10 (1) | W7 | engine → `tool_memory/`; tool surface kept host |
| `memory_search/` | 8 (0) | W5 | `vector`/`scoring` → crate `retrieval`/`score`; `tools/` kept host |
| `memory/ingest_pipeline.rs` internals | (thin entry points kept) | W6 | `ingest_chat`/`ingest_document_with_scope` signatures unchanged; 11 call sites untouched |
**Kept host (never deleted):** `memory/{ops,schemas,schema,read_rpc,tools,query,tree_source,
ingestion,util}`, `memory/{global,source_scope,chat,sync,preferences,remember,tree_policy,rpc_models,
traits(→re-exports)}.rs`, all of `memory_sync/`, `memory_store/unified/*` (the namespace-document
traits(→re-exports)}.rs`, `memory_sync/`'s host-retained shell only (schedulers `periodic.rs`,
`bus.rs` subscribers, RPC registration — the engine moves in W-SYNC, plan §8), `memory_store/unified/*` (the namespace-document
tier), `memory_store/content/{wiki_git,obsidian,obsidian_registry}`, `memory_tree/health/`, and the
new `src/openhuman/tinycortex/` seam.
## 3. Workstream order (one workstream ≈ one host PR)
W1 seam scaffolding → W2 types/trait re-export → W3 store+chunks → W4 queue → W5 tree+retrieval+score
→ W6 ingest → W7 long tail → W8 test-port + golden parity sweep + deletion-ledger close-out.
→ W6 ingest → **W-SYNC** (sync engine + Composio client, plan §8) → W7 long tail → W8 test-port +
golden parity sweep + deletion-ledger close-out. **W-EMB** (embeddings inheritance from tinyagents,
plan §8.2) runs in parallel; W-EMB.2 must land before the W-SYNC.3 flip.
Each risky workstream is a sandwich (plan §4): (a) tinycortex PR(s) closing that module's drift/gap
ledger, (b) host `chore(vendor): bump tinycortex`, (c) host cutover PR (adapter flip + legacy
@@ -161,3 +192,10 @@ deletion + host-side tests in the same PR for the ≥80% diff-coverage gate).
2. **`source_scope` per-turn allowlist** — must survive the W5 retrieval cutover; the retrieval
primitives (`query_source`/`query_topic`/`drill_down`) run inside the host's task-local scope, and
a W5 seam test must assert an out-of-allowlist source is not returned.
3. **Composio credential handling (W-SYNC)** — the crate holds the key only as a redacted
`SecretString` (`Debug`/`Display` masked, serialization skipped); production resolution stays in
the host keychain (`composio-direct`), the `COMPOSIO_API_KEY` env fallback is test-only; a seam
test asserts the key never appears in `MemoryConfig` `Debug` output or client error messages
(401 path included).
4. **Sync taint provenance (W-SYNC)** — every crate-side sync ingest must stamp
`MemoryTaint::ExternalSync`; the mocked Composio pipeline test pins this.
+42 -12
View File
@@ -62,24 +62,41 @@ two layers.
### Layer 1 — schema/format asserters (host-side unit tests, cheap, run every PR)
A `tests/tinycortex_parity/` module with pure-function comparators (no disk):
Pure-function comparators (no disk), implemented in **`src/openhuman/tinycortex/parity.rs`**
(`#[cfg(test)]`). Status ✅ = landed & green; ⏳ = pending.
- **`chunk_id_parity`** — table of `(source_kind, source_id, seq, content)` → assert host `chunk_id`
== `tinycortex::memory::chunks::chunk_id` (covers P1). *(After W3 both resolve to the crate; keep
the vector as a regression pin.)*
- **`vector_roundtrip_parity`** — random `Vec<f32>` `vec_to_bytes``bytes_to_vec` byte-equal
across both (P2).
- **`content_path_parity`** — corpus of adversarial ids (colons, unicode, >255 chars, `email`
source) → assert identical `chunk_rel_path`/`summary_rel_path` (P6).
- **`frontmatter_parity`** — compose a fixed chunk+summary → byte-compare markdown incl. frontmatter
key order (P7).
- **`embedding_signature_parity`** — assert the W1 seam's `signature()` string == the format the
store persisted into `store_meta` (P10).
- **`chunk_id_matches_historical_golden` / `chunk_id_is_sensitive_to_every_field`** — golden +
every-field sensitivity for the deterministic `chunk_id` (covers P1). *(After W3 both resolve to the
crate; the golden vector stays as a regression pin.)*
- **`vector_encoding_is_le_packed_f32`** — `vec_to_bytes`/`bytes_to_vec` LE-packed-f32 round-trip
+ golden bytes (P2).
- **`chunk_rel_path_host_crate_byte_parity` / `summary_rel_path_host_crate_byte_parity`** —
adversarial id corpus (colons, all Windows-illegal chars, unicode, >255 chars, gmail participant
slugs, malformed email; every summary-id shape × 3 tree kinds × levels) → assert host
`chunk_rel_path`/`summary_rel_path` **byte-equal** the crate's (P6). *(Landed; verified identical.)*
- **`embedding_signature_host_crate_byte_parity`** — assert host
`embeddings::format_embedding_signature` == crate `store::vectors::format_embedding_signature` and
both == the golden `provider={name};model={model};dims={dims}` over a provider corpus (P10). The
seam's own `signature()` pass-through is separately pinned in `tinycortex/embeddings.rs`.
-**`frontmatter_parity`** — compose a fixed chunk+summary → byte-compare markdown incl.
frontmatter key order (P7). *(Not yet landed — needs the host/crate compose types aligned.)*
### Layer 2 — golden-workspace differential harness (the flip gate)
The core mechanism from plan §0.3: **one on-disk workspace, opened by both engines, outputs compared.**
> **Status (2026-07-10).** First cut landed as **`tests/memory_golden_parity_e2e.rs`** —
> comparator **1** (schema composition) and comparator **5** (idempotent re-open) are green:
> a real workspace is stood up through the host production surface (`memory::ops`), the crate
> substrate init is forced deterministically, and **all `*.db` files under the workspace are scanned
> path-agnostically** (union of tables). It asserts the crate chunk-DB substrate (15 `chunks/schema.rs`
> tables) and the host `UnifiedMemory` tier (10 tables) **coexist without collision** (P3/P5/P11/P12),
> and that re-running the flow adds/drops no tables (comparator 5). Still TODO: `vectors`/`store_meta`/
> `kv_*` (created by the chunk/embed pipeline, need a widened ingest flow), the seeded golden fixture +
> `scripts/gen-golden-workspace.sh`, and comparators **2** (recall/retrieval snapshot), **3** (tree
> read), **4** (byte-compare vault) — these require a populated, sealed fixture and the W5 retrieval
> surface, so they land alongside the W3/W5 flips.
**Fixture.** Check in a small, deterministic `tests/fixtures/golden-workspace/` produced by the
*pre-migration* build: a real `chunks.db` + content vault + diff `.git`, seeded via a fixed script
(`scripts/gen-golden-workspace.sh`) with: a handful of chat + document + email sources across ≥2
@@ -115,6 +132,19 @@ flip preserves *existing* data, not just that the API still functions.
**W3** (store+chunks), **W5** (tree+retrieval+score), **W6** (ingest). W4 (queue) additionally asserts
job payload_json parity (P4/P9). Any red = upstream fix in tinycortex, re-bump submodule, re-run.
**W-SYNC gates (amendment 2026-07-09, plan §8):**
- **P13 sync-status parity** — `memory_sync_status_list` output (per-`source_kind` freshness rows)
byte-equal pre/post flip on a golden workspace; asserter added to
`src/openhuman/tinycortex/parity.rs`.
- **P14 Composio sync test pair** — the crate's mocked-HTTP gmail pipeline test
(`vendor/tinycortex/tests/composio_sync_mock.rs`, wiremock, always-on) green in crate CI before
the W-SYNC.3 flip; the live `#[ignore]` test (`composio_sync_live.rs`, `COMPOSIO_API_KEY`) run
manually at least once per flip wave.
**W-EMB gate:** the existing **P10 `embedding_signature_parity`** asserter is the regression pin —
the tinyagents-backed provider stack must emit byte-identical
`provider={name};model={model};dims={dims}` signatures, or existing vector spaces split.
## Open divergences to resolve upstream before their flip
| Item | Flip gated | Resolution |
+2 -1
View File
@@ -32,5 +32,6 @@ scheduler (1 task) → daily wall-clock tick → `digest_daily(yesterday)`
- `store.rs` — SQLite persistence: `INSERT OR IGNORE` + partial unique index on `dedupe_key WHERE status IN ('ready','running')` for at-most-one-active dedupe; `claim_next` is a single `UPDATE ... RETURNING`; `mark_done`/`mark_failed` are claim-token gated to make stale-worker settlements no-ops.
- `worker.rs` — three worker tasks plus startup `recover_stale_locks` and a 3-permit semaphore around LLM-bound jobs. Calls into `crate::openhuman::scheduler_gate::wait_for_capacity()` before claiming so Throttled / Paused modes back off without holding DB leases.
- `scheduler.rs` — daily tick at UTC 00:05 that enqueues `digest_daily(yesterday)` + `flush_stale(today)`; `trigger_digest` and `backfill_missing_digests` are manual catch-up helpers.
- `handlers/` — per-`JobKind` handler implementations.
- `testing.rs``drain_until_idle` for tests that need the pipeline to settle synchronously.
Per-`JobKind` dispatch (the former `handlers/` module) was deleted at the W4 flip: `worker::run_once` now delegates claim → dispatch → settle to `tinycortex::memory::queue::run_once` through `crate::openhuman::tinycortex::HostQueueDelegates`, which bridges each heavy step back to the host `memory_tree`/score/embed engine.
@@ -1,20 +0,0 @@
# Memory tree — jobs handlers
Per-`JobKind` handler implementations dispatched by `worker::run_once_with_semaphore`. Each handler parses its payload, performs side effects, and enqueues any follow-up work (typically inside the same SQLite transaction as its primary write so a crash doesn't lose downstream jobs).
## Public surface
- `pub async fn handle_job(config, job)``mod.rs` — branches on `job.kind` and invokes the matching handler.
## Handlers (private to the module)
- `handle_extract` — runs the scorer + LLM extractor over one chunk, packs the embedding, writes `mem_tree_score` + entity-index rows + chunk lifecycle in one tx, and enqueues the follow-up `append_buffer` and `topic_route` jobs. Also rewrites Obsidian-style `tags:` in the on-disk chunk markdown (best-effort, post-tx).
- `handle_append_buffer` — hydrates a `LeafRef` (chunk or summary), pushes into the target tree's L0 buffer, and enqueues a `seal` job if the buffer crosses its budget. Updates chunk lifecycle (`buffered`) for source-tree appends. All in one tx.
- `handle_seal` — seals exactly one buffer level via `bucket_seal::seal_one_level` (which atomically inserts the parent-cascade seal and summary-side `topic_route` for source trees). Topic-tree seals are sinks and do not enqueue further routing. Rewrites tags on the sealed summary's `.md` post-commit.
- `handle_topic_route` — for each canonical entity associated with the node, asks the topic curator whether to spawn a topic tree, and enqueues an `append_buffer` per matched topic tree.
- `handle_digest_daily` — invokes `tree_global::digest::end_of_day_digest` for the requested UTC date; idempotent via the digest's own `find_existing_daily` check.
- `handle_flush_stale` — walks `list_stale_buffers` and enqueues a forced `seal` per buffer over the configured `DEFAULT_FLUSH_AGE_SECS` cap.
## Files
- `mod.rs``handle_job` dispatch and all handler bodies.
File diff suppressed because it is too large Load Diff
@@ -1,744 +0,0 @@
use super::*;
use crate::openhuman::memory::tree_source::registry::get_or_create_source_tree;
use crate::openhuman::memory_queue::store::{count_by_status, count_total};
use crate::openhuman::memory_queue::types::JobStatus;
use crate::openhuman::memory_store::chunks::store::with_connection;
use crate::openhuman::memory_store::content as content_store;
use crate::openhuman::memory_tree::tree::bucket_seal::{append_leaf_deferred, LeafRef};
use crate::openhuman::memory_tree::tree::store as src_store;
use chrono::TimeZone;
use rusqlite::params;
use tempfile::TempDir;
fn test_config() -> (TempDir, Config) {
let tmp = TempDir::new().unwrap();
let mut cfg = Config::default();
cfg.workspace_dir = tmp.path().to_path_buf();
cfg.memory_tree.embedding_endpoint = None;
cfg.memory_tree.embedding_model = None;
cfg.memory_tree.embedding_strict = false;
(tmp, cfg)
}
/// Build a minimal `Job` row for direct handler invocation. Mirrors
/// what `claim_next` would produce for a freshly-claimed row.
fn mk_running_job(kind: JobKind, payload_json: String) -> Job {
let now_ms = chrono::Utc::now().timestamp_millis();
Job {
id: "test-job-id".into(),
kind,
payload_json,
dedupe_key: None,
status: JobStatus::Running,
attempts: 1,
max_attempts: 5,
available_at_ms: now_ms,
locked_until_ms: Some(now_ms + 60_000),
last_error: None,
created_at_ms: now_ms,
started_at_ms: Some(now_ms),
completed_at_ms: None,
failure_reason: None,
failure_class: None,
}
}
/// Count rows in `mem_tree_jobs` matching a specific kind.
fn count_jobs_of_kind(cfg: &Config, kind: &str) -> u64 {
with_connection(cfg, |conn| {
let n: i64 = conn.query_row(
"SELECT COUNT(*) FROM mem_tree_jobs WHERE kind = ?1",
params![kind],
|r| r.get(0),
)?;
Ok(n.max(0) as u64)
})
.unwrap()
}
/// Seed a source tree and push enough labeled leaves into its L0 buffer
/// to cross `INPUT_TOKEN_BUDGET`, returning the tree. The caller can then
/// fire `handle_seal` and inspect the result.
async fn seed_source_tree_ready_to_seal(
cfg: &Config,
) -> crate::openhuman::memory_store::trees::types::Tree {
use crate::openhuman::memory_store::chunks::store::upsert_chunks;
use crate::openhuman::memory_store::chunks::types::{
chunk_id, Chunk, Metadata, SourceKind, SourceRef,
};
let tree = get_or_create_source_tree(cfg, "slack:#eng").unwrap();
let ts = chrono::Utc.timestamp_millis_opt(1_700_000_000_000).unwrap();
let chunk = Chunk {
id: chunk_id(SourceKind::Chat, "slack:#eng", 0, "handler-seed"),
content: "alice@example.com leading the rollout".into(),
metadata: Metadata {
source_kind: SourceKind::Chat,
source_id: "slack:#eng".into(),
owner: "alice".into(),
timestamp: ts,
time_range: (ts, ts),
tags: vec![],
source_ref: Some(SourceRef::new("slack://x")),
path_scope: None,
},
// Bust budget so the L0 buffer is "ready" for seal.
token_count: 60_000,
seq_in_source: 0,
created_at: ts,
partial_message: false,
};
upsert_chunks(cfg, &[chunk.clone()]).unwrap();
// Stage to disk so `hydrate_leaf_inputs` can read the full body via
// `read_chunk_body` when `handle_seal` fires and calls `seal_one_level`.
let content_root = cfg.memory_tree_content_root();
std::fs::create_dir_all(&content_root).unwrap();
let staged = content_store::stage_chunks(&content_root, &[chunk.clone()]).unwrap();
with_connection(cfg, |conn| {
let tx = conn.unchecked_transaction()?;
crate::openhuman::memory_store::chunks::store::upsert_staged_chunks_tx(&tx, &staged)?;
tx.commit()?;
Ok(())
})
.unwrap();
let leaf = LeafRef {
chunk_id: chunk.id,
token_count: 60_000,
timestamp: ts,
content: chunk.content,
entities: vec![],
topics: vec![],
score: 0.5,
};
// append_leaf_deferred only buffers; doesn't seal. handle_seal will.
let _ = append_leaf_deferred(cfg, &tree, &leaf).unwrap();
tree
}
/// #1574 §6: a chunk with content but no sidecar vector at the active
/// signature (the post-switch / dim-mismatch state) is re-embedded by
/// `handle_reembed_backfill`; the chain `Defer`s while work remains and
/// returns `Done` once the space is covered; a stale-signature job
/// finishes immediately without touching anything.
///
/// (The process-global `backfill_in_progress` flag is intentionally not
/// asserted here — it is shared across parallel tests and set widely by
/// the §7 trigger, so asserting it would be flaky. The handler's
/// deterministic effects are what this test pins.)
#[tokio::test]
async fn reembed_backfill_repopulates_then_completes() {
use crate::openhuman::memory_store::chunks::store::{
get_chunk_embedding_for_signature, tree_active_signature, upsert_chunks,
upsert_staged_chunks_tx,
};
use crate::openhuman::memory_store::chunks::types::{
chunk_id, Chunk, Metadata, SourceKind, SourceRef,
};
let (_tmp, mut cfg) = test_config();
// Deliberate "none" opt-out → build_write_embedder yields an InertEmbedder
// (correct-dim zero vectors, no network). This test pins backfill
// *mechanics* (worklist → sidecar write → Defer/Done), not embed quality;
// the no-provider skip path is covered separately.
cfg.embeddings_provider = Some("none".to_string());
let ts = chrono::Utc.timestamp_millis_opt(1_700_000_000_000).unwrap();
let chunk = Chunk {
id: chunk_id(SourceKind::Chat, "slack:#eng", 0, "reembed-seed"),
content: "memory content about the phoenix migration project".into(),
metadata: Metadata {
source_kind: SourceKind::Chat,
source_id: "slack:#eng".into(),
owner: "alice".into(),
timestamp: ts,
time_range: (ts, ts),
tags: vec![],
source_ref: Some(SourceRef::new("slack://x")),
path_scope: None,
},
token_count: 12,
seq_in_source: 0,
created_at: ts,
partial_message: false,
};
upsert_chunks(&cfg, &[chunk.clone()]).unwrap();
// Stage the body to disk so `read_chunk_body` succeeds in the handler.
let content_root = cfg.memory_tree_content_root();
std::fs::create_dir_all(&content_root).unwrap();
let staged = content_store::stage_chunks(&content_root, &[chunk.clone()]).unwrap();
with_connection(&cfg, |conn| {
let tx = conn.unchecked_transaction()?;
upsert_staged_chunks_tx(&tx, &staged)?;
tx.commit()?;
Ok(())
})
.unwrap();
let sig = tree_active_signature(&cfg);
assert!(
get_chunk_embedding_for_signature(&cfg, &chunk.id, &sig)
.unwrap()
.is_none(),
"precondition: no sidecar vector at the active signature"
);
// Work present → re-embed + write sidecar, Defer to revisit.
let job = mk_running_job(
JobKind::ReembedBackfill,
serde_json::to_string(&ReembedBackfillPayload {
signature: sig.clone(),
})
.unwrap(),
);
let out = handle_reembed_backfill(&cfg, &job).await.unwrap();
assert!(
matches!(out, JobOutcome::Defer { .. }),
"work present must Defer (self-continue), got {out:?}"
);
assert!(
get_chunk_embedding_for_signature(&cfg, &chunk.id, &sig)
.unwrap()
.is_some(),
"chunk re-embedded into the sidecar at the active signature"
);
// Nothing left → Done.
let out2 = handle_reembed_backfill(&cfg, &job).await.unwrap();
assert_eq!(out2, JobOutcome::Done, "covered space must complete");
// Stale signature (embedder changed since enqueue) → finishes
// immediately, no work, no panic.
let stale = mk_running_job(
JobKind::ReembedBackfill,
serde_json::to_string(&ReembedBackfillPayload {
signature: "provider=other;model=x;dims=1".into(),
})
.unwrap(),
);
assert_eq!(
handle_reembed_backfill(&cfg, &stale).await.unwrap(),
JobOutcome::Done
);
}
/// #002 (FR-002) regression gate: when NO usable embeddings provider is
/// configured, the re-embed backfill must SKIP (return `Done`) instead of
/// falling back to an `InertEmbedder` and persisting all-zero vectors that
/// would silently poison semantic recall — the same hazard the extract and
/// seal write paths already guard against. The chunk stays embedding-less at
/// the active signature (re-embeddable once a provider is configured).
#[tokio::test]
async fn reembed_backfill_skips_when_no_provider() {
use crate::openhuman::memory_store::chunks::store::{
get_chunk_embedding_for_signature, tree_active_signature, upsert_chunks,
upsert_staged_chunks_tx,
};
use crate::openhuman::memory_store::chunks::types::{
chunk_id, Chunk, Metadata, SourceKind, SourceRef,
};
// Default test config leaves embeddings unconfigured (no endpoint/model,
// provider unset) — the no-provider path build_write_embedder guards.
//
// Hold the shared health test-guard: the no-provider path marks the
// process-global semantic-recall degraded flag, so the guard resets it on
// entry and keeps the signal from leaking into parallel status tests.
let _health_guard = crate::openhuman::memory_tree::health::test_guard();
let (_tmp, cfg) = test_config();
let ts = chrono::Utc.timestamp_millis_opt(1_700_000_000_000).unwrap();
let chunk = Chunk {
id: chunk_id(SourceKind::Chat, "slack:#eng", 0, "no-provider-seed"),
content: "memory content with no embeddings provider configured".into(),
metadata: Metadata {
source_kind: SourceKind::Chat,
source_id: "slack:#eng".into(),
owner: "alice".into(),
timestamp: ts,
time_range: (ts, ts),
tags: vec![],
source_ref: Some(SourceRef::new("slack://x")),
path_scope: None,
},
token_count: 12,
seq_in_source: 0,
created_at: ts,
partial_message: false,
};
upsert_chunks(&cfg, &[chunk.clone()]).unwrap();
let content_root = cfg.memory_tree_content_root();
std::fs::create_dir_all(&content_root).unwrap();
let staged = content_store::stage_chunks(&content_root, &[chunk.clone()]).unwrap();
with_connection(&cfg, |conn| {
let tx = conn.unchecked_transaction()?;
upsert_staged_chunks_tx(&tx, &staged)?;
tx.commit()?;
Ok(())
})
.unwrap();
let sig = tree_active_signature(&cfg);
let job = mk_running_job(
JobKind::ReembedBackfill,
serde_json::to_string(&ReembedBackfillPayload {
signature: sig.clone(),
})
.unwrap(),
);
// No provider → skip the whole backfill (Done), do NOT write a vector.
let out = handle_reembed_backfill(&cfg, &job).await.unwrap();
assert_eq!(
out,
JobOutcome::Done,
"no usable provider must skip the backfill, not Defer/embed"
);
assert!(
get_chunk_embedding_for_signature(&cfg, &chunk.id, &sig)
.unwrap()
.is_none(),
"no zero/inert vector may be persisted when no provider is configured"
);
}
/// #1574 §6 regression gate: a terminal-failure chunk (its body file is
/// missing on disk, despite the metadata row staying staged) is
/// persistently tombstoned by `mark_chunk_reembed_skipped` on the first
/// pass, then excluded from the next batch's worklist so the chain
/// terminates (`Done`) instead of looping forever. Without this guard
/// the §6 runaway-loop fix would silently regress — the same 16 orphans
/// → ~8k defers → ~128k warns symptom observed in the wild before the
/// fix landed (see PR body and store.rs:1195).
///
/// What the test pins:
/// 1. Tombstone row is written for the failing chunk (exactly one).
/// 2. The next-batch worklist `NOT EXISTS … reembed_skipped` clause
/// excludes the tombstoned row — the handler returns `Done`.
/// 3. The `ensure_reembed_backfill` migration probe agrees the space
/// is covered (or the chain would re-arm on every config save).
#[tokio::test]
async fn reembed_backfill_tombstones_orphan_and_terminates() {
use crate::openhuman::memory_store::chunks::store::{
get_chunk_content_path, get_chunk_embedding_for_signature, tree_active_signature,
upsert_chunks, upsert_staged_chunks_tx,
};
use crate::openhuman::memory_store::chunks::types::{
chunk_id, Chunk, Metadata, SourceKind, SourceRef,
};
let (_tmp, mut cfg) = test_config();
// Deliberate "none" opt-out → InertEmbedder (zero vectors, no network) so
// the backfill reaches the orphan body-read and tombstones it; this test
// pins the tombstone-and-terminate mechanics, not embed quality.
cfg.embeddings_provider = Some("none".to_string());
let ts = chrono::Utc.timestamp_millis_opt(1_700_000_000_000).unwrap();
let chunk = Chunk {
id: chunk_id(SourceKind::Chat, "slack:#eng", 0, "orphan-seed"),
content: "memory content about the orphaned phoenix project".into(),
metadata: Metadata {
source_kind: SourceKind::Chat,
source_id: "slack:#eng".into(),
owner: "alice".into(),
timestamp: ts,
time_range: (ts, ts),
tags: vec![],
source_ref: Some(SourceRef::new("slack://x")),
path_scope: None,
},
token_count: 12,
seq_in_source: 0,
created_at: ts,
partial_message: false,
};
upsert_chunks(&cfg, &[chunk.clone()]).unwrap();
// Stage the body file + metadata, then DELETE the body file from
// disk while leaving the staged DB rows intact. Reproduces the
// in-wild failure mode: chunk row + path hash both present, but
// the body content was lost (user moved workspace dirs, partial
// backup restore, manual file cleanup). `stage_chunks` returns
// paths relative to `content_root`; resolve absolute before unlink.
let content_root = cfg.memory_tree_content_root();
std::fs::create_dir_all(&content_root).unwrap();
let staged = content_store::stage_chunks(&content_root, &[chunk.clone()]).unwrap();
with_connection(&cfg, |conn| {
let tx = conn.unchecked_transaction()?;
upsert_staged_chunks_tx(&tx, &staged)?;
tx.commit()?;
Ok(())
})
.unwrap();
let staged_rel = get_chunk_content_path(&cfg, &chunk.id)
.unwrap()
.expect("staged body path");
let body_abs = content_root.join(&staged_rel);
std::fs::remove_file(&body_abs).unwrap();
let sig = tree_active_signature(&cfg);
let job = mk_running_job(
JobKind::ReembedBackfill,
serde_json::to_string(&ReembedBackfillPayload {
signature: sig.clone(),
})
.unwrap(),
);
// Pass 1: worklist picks up the orphan, body read fails, tombstone
// written, `Defer` to revisit (the handler doesn't distinguish
// "all rows tombstoned" from "more rows pending" inside this batch).
let out1 = handle_reembed_backfill(&cfg, &job).await.unwrap();
assert!(
matches!(out1, JobOutcome::Defer { .. }),
"first pass should Defer after failing to read body, got {out1:?}"
);
assert!(
get_chunk_embedding_for_signature(&cfg, &chunk.id, &sig)
.unwrap()
.is_none(),
"orphan chunk must not have a sidecar vector after failure"
);
// (1) Tombstone row exists for exactly this (chunk, sig).
let tombstone_count: i64 = with_connection(&cfg, |conn| {
Ok(conn.query_row(
"SELECT COUNT(*) FROM mem_tree_chunk_reembed_skipped
WHERE chunk_id = ?1 AND model_signature = ?2",
params![chunk.id, sig],
|r| r.get(0),
)?)
})
.unwrap();
assert_eq!(
tombstone_count, 1,
"orphan chunk must be tombstoned exactly once"
);
// (2) Pass 2: worklist NOT EXISTS clause excludes the tombstoned
// row; both worklists empty; chain completes.
let out2 = handle_reembed_backfill(&cfg, &job).await.unwrap();
assert_eq!(
out2,
JobOutcome::Done,
"tombstoned-only state must complete the chain"
);
// (3) Migration probe in `ensure_reembed_backfill` must agree the
// space is covered, otherwise the chain re-arms on every config
// save and we're back to the original infinite-loop bug.
let probe_uncovered = with_connection(&cfg, |conn| {
Ok(chunk_store::has_uncovered_reembed_work(conn, &sig)?)
})
.unwrap();
assert!(
!probe_uncovered,
"after tombstoning the only orphan, the ensure_reembed_backfill probe must report covered"
);
}
/// #2358: clearing a tombstone re-opens the row for the backfill worklist.
#[tokio::test]
async fn clear_chunk_reembed_skipped_reopens_worklist() {
use crate::openhuman::memory_store::chunks::store::{
clear_chunk_reembed_skipped, get_chunk_content_path, mark_chunk_reembed_skipped,
tree_active_signature, upsert_chunks, upsert_staged_chunks_tx,
};
use crate::openhuman::memory_store::chunks::types::{
chunk_id, Chunk, Metadata, SourceKind, SourceRef,
};
let (_tmp, cfg) = test_config();
let ts = chrono::Utc.timestamp_millis_opt(1_700_000_000_000).unwrap();
let chunk = Chunk {
id: chunk_id(SourceKind::Chat, "slack:#eng", 0, "clear-tombstone-seed"),
content: "memory content for clear tombstone test".into(),
metadata: Metadata {
source_kind: SourceKind::Chat,
source_id: "slack:#eng".into(),
owner: "alice".into(),
timestamp: ts,
time_range: (ts, ts),
tags: vec![],
source_ref: Some(SourceRef::new("slack://x")),
path_scope: None,
},
token_count: 12,
seq_in_source: 0,
created_at: ts,
partial_message: false,
};
upsert_chunks(&cfg, &[chunk.clone()]).unwrap();
let content_root = cfg.memory_tree_content_root();
std::fs::create_dir_all(&content_root).unwrap();
let staged = content_store::stage_chunks(&content_root, &[chunk.clone()]).unwrap();
with_connection(&cfg, |conn| {
let tx = conn.unchecked_transaction()?;
upsert_staged_chunks_tx(&tx, &staged)?;
tx.commit()?;
Ok(())
})
.unwrap();
let staged_rel = get_chunk_content_path(&cfg, &chunk.id)
.unwrap()
.expect("staged body path");
std::fs::remove_file(content_root.join(&staged_rel)).unwrap();
let sig = tree_active_signature(&cfg);
mark_chunk_reembed_skipped(&cfg, &chunk.id, &sig, "orphan").unwrap();
let covered_before_clear = with_connection(&cfg, |conn| {
Ok(!chunk_store::has_uncovered_reembed_work(conn, &sig)?)
})
.unwrap();
assert!(
covered_before_clear,
"tombstone must hide orphan from uncovered probe"
);
clear_chunk_reembed_skipped(&cfg, &chunk.id, &sig).unwrap();
let uncovered_after_clear = with_connection(&cfg, |conn| {
Ok(chunk_store::has_uncovered_reembed_work(conn, &sig)?)
})
.unwrap();
assert!(
uncovered_after_clear,
"clearing tombstone must re-include chunk in worklist probe"
);
}
/// #1574 §4: `ensure_reembed_backfill` (the switch-path trigger) enqueues
/// exactly one chain when there is uncovered work, is idempotent on
/// re-call (per-signature dedupe), and enqueues nothing for an
/// empty/covered space.
#[tokio::test]
async fn ensure_reembed_backfill_enqueues_only_when_uncovered() {
use crate::openhuman::memory_queue::ensure_reembed_backfill;
use crate::openhuman::memory_store::chunks::store::{upsert_chunks, upsert_staged_chunks_tx};
use crate::openhuman::memory_store::chunks::types::{
chunk_id, Chunk, Metadata, SourceKind, SourceRef,
};
// Empty space → nothing to do → no job.
let (_t0, empty_cfg) = test_config();
ensure_reembed_backfill(&empty_cfg);
assert_eq!(
count_jobs_of_kind(&empty_cfg, "reembed_backfill"),
0,
"empty/covered space must not enqueue a backfill"
);
// Chunk with content but no sidecar vector → exactly one chain.
let (_t1, cfg) = test_config();
let ts = chrono::Utc.timestamp_millis_opt(1_700_000_000_000).unwrap();
let chunk = Chunk {
id: chunk_id(SourceKind::Chat, "slack:#eng", 0, "ensure-seed"),
content: "memory content needing a re-embed".into(),
metadata: Metadata {
source_kind: SourceKind::Chat,
source_id: "slack:#eng".into(),
owner: "alice".into(),
timestamp: ts,
time_range: (ts, ts),
tags: vec![],
source_ref: Some(SourceRef::new("slack://x")),
path_scope: None,
},
token_count: 12,
seq_in_source: 0,
created_at: ts,
partial_message: false,
};
upsert_chunks(&cfg, &[chunk.clone()]).unwrap();
let content_root = cfg.memory_tree_content_root();
std::fs::create_dir_all(&content_root).unwrap();
let staged = content_store::stage_chunks(&content_root, &[chunk.clone()]).unwrap();
with_connection(&cfg, |conn| {
let tx = conn.unchecked_transaction()?;
upsert_staged_chunks_tx(&tx, &staged)?;
tx.commit()?;
Ok(())
})
.unwrap();
ensure_reembed_backfill(&cfg);
assert_eq!(
count_jobs_of_kind(&cfg, "reembed_backfill"),
1,
"uncovered work must enqueue exactly one backfill chain"
);
// Idempotent — re-call must not create a second chain (dedupe by sig).
ensure_reembed_backfill(&cfg);
assert_eq!(
count_jobs_of_kind(&cfg, "reembed_backfill"),
1,
"re-call must dedupe to a single chain per signature"
);
}
/// `cap_embed_text` must bound an over-budget body to `EMBED_SAFE_TOKENS` before
/// it is batched for embedding, and pass small bodies through verbatim, so no
/// single `embed_batch` input can exceed the embedder's input limit.
#[test]
fn cap_embed_text_bounds_oversized_input() {
use crate::openhuman::memory_store::chunks::types::conservative_token_estimate;
// Punctuation-dense body (~1 token/char) far over the embed budget.
let big = "x,".repeat(EMBED_SAFE_TOKENS as usize);
assert!(conservative_token_estimate(&big) > EMBED_SAFE_TOKENS);
let capped = cap_embed_text(&big);
assert!(capped.len() < big.len(), "oversized body must be truncated");
assert!(
conservative_token_estimate(capped) <= EMBED_SAFE_TOKENS,
"capped body must be within the embed budget",
);
assert!(big.starts_with(capped), "cap must return a leading prefix");
// A small body is returned unchanged.
let small = "hello world";
assert_eq!(cap_embed_text(small), small);
}
/// C9: `prepare_extract` must score over the **full** on-disk body but leave
/// the returned `PreparedExtract.chunk` holding only the ≤500-char preview —
/// never the full body. This is the swap-then-restore guarantee that keeps a
/// batch of N prepared items from retaining N full bodies in memory before
/// finalize. We assert the body is read from disk (a body longer than the
/// preview) yet the returned chunk content equals the preview again.
#[tokio::test]
async fn prepare_extract_scores_full_body_but_returns_preview() {
use crate::openhuman::memory::chat::{test_override, StaticChatProvider};
use crate::openhuman::memory_queue::types::ExtractChunkPayload;
use crate::openhuman::memory_store::chunks::store::upsert_chunks;
use crate::openhuman::memory_store::chunks::types::{
chunk_id, Chunk, Metadata, SourceKind, SourceRef,
};
use std::sync::Arc;
let (_tmp, cfg) = test_config();
let ts = chrono::Utc.timestamp_millis_opt(1_700_000_000_000).unwrap();
// Full body is > 500 chars so the stored preview is a strict prefix and
// is observably different from the body the scorer reads off disk.
let body: String = "the quarterly planning notes from the engineering sync covering rollout sequencing and staffing. "
.repeat(12);
assert!(body.len() > 500, "test body must exceed the preview cap");
let expected_preview: String = body.chars().take(500).collect();
let chunk = Chunk {
id: chunk_id(SourceKind::Chat, "slack:#eng", 0, "c9-extract-seed"),
content: body.clone(),
metadata: Metadata {
source_kind: SourceKind::Chat,
source_id: "slack:#eng".into(),
owner: "alice".into(),
timestamp: ts,
time_range: (ts, ts),
tags: vec![],
source_ref: Some(SourceRef::new("slack://x")),
path_scope: None,
},
token_count: 64,
seq_in_source: 0,
created_at: ts,
partial_message: false,
};
// upsert_chunks stores only the ≤500-char preview in the `content` column.
upsert_chunks(&cfg, &[chunk.clone()]).unwrap();
// Stage the full body to disk so `read_chunk_body` returns it.
let content_root = cfg.memory_tree_content_root();
std::fs::create_dir_all(&content_root).unwrap();
let staged = content_store::stage_chunks(&content_root, &[chunk.clone()]).unwrap();
with_connection(&cfg, |conn| {
let tx = conn.unchecked_transaction()?;
crate::openhuman::memory_store::chunks::store::upsert_staged_chunks_tx(&tx, &staged)?;
tx.commit()?;
Ok(())
})
.unwrap();
let payload = ExtractChunkPayload {
chunk_id: chunk.id.clone(),
};
let job = mk_running_job(
JobKind::ExtractChunk,
serde_json::to_string(&payload).unwrap(),
);
// Pin a static (offline) chat provider so any borderline LLM consult is
// deterministic and never touches the network.
let prepared = test_override::with_provider(
Arc::new(StaticChatProvider::new("[]")),
prepare_extract(&cfg, &job),
)
.await
.unwrap()
.expect("seeded chunk must produce a PreparedExtract");
assert_eq!(
prepared.chunk.content, expected_preview,
"returned chunk must hold the restored preview, not the full body"
);
assert_ne!(
prepared.chunk.content, body,
"returned chunk must NOT retain the full on-disk body"
);
}
/// #4359: a cloud-embedding "No backend session" bail is a global, login-
/// recoverable condition — `reembed_collect` must fail the backfill fast with a
/// typed `AuthMissing` failure and must **not** tombstone any row, so the same
/// rows re-embed once the user signs in. (A per-row tombstone here would
/// exclude the rows from the worklist forever — the regression this guards.)
struct MissingSessionEmbedder;
#[async_trait::async_trait]
impl crate::openhuman::memory_tree::score::embed::Embedder for MissingSessionEmbedder {
fn name(&self) -> &'static str {
"missing-session"
}
async fn embed(&self, _text: &str) -> anyhow::Result<Vec<f32>> {
anyhow::bail!(
"No backend session for cloud embeddings: log in to OpenHuman, or set \
memory.embedding_provider to \"ollama\" / \"none\" in config.toml"
)
}
}
#[tokio::test]
async fn reembed_backfill_auth_missing_fails_fast_without_tombstone() {
use crate::openhuman::memory_tree::health::{FailureCode, PipelineFailure};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
let (_tmp, cfg) = test_config();
let ids = vec![
"chunk-a".to_string(),
"chunk-b".to_string(),
"chunk-c".to_string(),
];
let tombstoned = Arc::new(AtomicUsize::new(0));
let tombstoned_for_mark = Arc::clone(&tombstoned);
let err = reembed_collect(
&cfg,
&MissingSessionEmbedder,
"provider=cloud;model=embedding-v1;dims=1024",
&ids,
"chunk",
|_cfg, id| Ok(format!("body for {id}")),
move |_cfg, _id, _sig, _reason| {
tombstoned_for_mark.fetch_add(1, Ordering::SeqCst);
},
)
.await
.expect_err("missing cloud session must fail the backfill, not return Ok");
let failure = err
.downcast_ref::<PipelineFailure>()
.expect("auth-missing backfill error must carry a typed PipelineFailure");
assert_eq!(failure.code, FailureCode::AuthMissing);
assert!(failure.is_unrecoverable());
assert_eq!(
tombstoned.load(Ordering::SeqCst),
0,
"auth-missing must NOT tombstone any row — they must stay re-embeddable after login"
);
}
-1
View File
@@ -30,7 +30,6 @@
//! execution/runtime concern rather than a leaf of the memory policy API.
//! `openhuman::memory` re-exports it as `memory::jobs` during the migration.
pub(crate) mod handlers;
mod ops;
mod redact;
pub mod scheduler;
-53
View File
@@ -151,59 +151,6 @@ pub fn claim_next(config: &Config, lock_duration_ms: i64) -> Result<Option<Job>>
})
}
/// Claim additional ready `extract_chunk` jobs for the same worker tick.
///
/// The caller has already claimed one job through [`claim_next`]. This helper
/// leases up to `limit` more extract jobs so the handler can batch the
/// embedding sub-step without widening non-extract scheduling semantics.
pub fn claim_ready_extract_batch(
config: &Config,
lock_duration_ms: i64,
limit: usize,
) -> Result<Vec<Job>> {
if limit == 0 {
return Ok(Vec::new());
}
with_connection(config, |conn| {
let now_ms = Utc::now().timestamp_millis();
let lock_until = now_ms.saturating_add(lock_duration_ms);
let mut stmt = conn
.prepare(
"UPDATE mem_tree_jobs
SET status = 'running',
attempts = attempts + 1,
started_at_ms = ?1,
locked_until_ms = ?2,
last_error = NULL
WHERE id IN (
SELECT id FROM mem_tree_jobs
WHERE status = 'ready'
AND kind = 'extract_chunk'
AND available_at_ms <= ?1
ORDER BY available_at_ms ASC
LIMIT ?3
)
RETURNING id, kind, payload_json, dedupe_key, status, attempts,
max_attempts, available_at_ms, locked_until_ms, last_error,
created_at_ms, started_at_ms, completed_at_ms,
failure_reason, failure_class",
)
.context("prepare claim_ready_extract_batch")?;
let jobs = stmt
.query_map(params![now_ms, lock_until, limit as i64], row_to_job)?
.collect::<rusqlite::Result<Vec<_>>>()
.context("query claim_ready_extract_batch")?;
if !jobs.is_empty() {
log::debug!(
"[memory::jobs] claimed extract batch count={} lock_until_ms={}",
jobs.len(),
lock_until
);
}
Ok(jobs)
})
}
/// Mark a claimed job as `done`. Clears the lock and stamps `completed_at_ms`.
///
/// The UPDATE is gated on `attempts` and `started_at_ms` matching the values
+27 -148
View File
@@ -1,5 +1,7 @@
//! Worker pool: claims jobs from `mem_tree_jobs`, dispatches them through
//! [`handlers::handle_job`], and settles the row.
//! Worker pool: drives the crate queue engine (W4 flip). Each `run_once`
//! delegates claim → dispatch → settle to `tinycortex::memory::queue::run_once`
//! via [`crate::openhuman::tinycortex::HostQueueDelegates`]; the legacy host
//! `handlers` engine that used to own dispatch was deleted at the flip.
//!
//! Concurrency control for LLM-bound work is delegated to
//! [`crate::openhuman::scheduler_gate`] — its global single-slot
@@ -16,15 +18,13 @@ use anyhow::Result;
use tokio::sync::Notify;
use crate::openhuman::config::Config;
use crate::openhuman::memory_queue::handlers;
use crate::openhuman::memory_queue::redact::scrub_for_log;
use crate::openhuman::memory_queue::store::{
claim_next, claim_ready_extract_batch, mark_deferred, mark_done, mark_failed_typed,
recover_stale_locks, release_running_locks, DEFAULT_LOCK_DURATION_MS,
};
use crate::openhuman::memory_queue::types::{Job, JobKind, JobOutcome};
// W4 flip: `run_once` now delegates claim/dispatch/settle to the crate, so the
// legacy `handlers`, per-job settle (`mark_*`/`scrub_for_log`), and claim
// helpers are gone from this module. Only startup lock recovery + the loop's
// storage-degraded signalling remain host.
use crate::openhuman::memory_queue::store::{recover_stale_locks, release_running_locks};
use crate::openhuman::memory_tree::health::{
clear_storage_degraded, mark_storage_degraded, FailureCode, PipelineFailure,
clear_storage_degraded, mark_storage_degraded, FailureCode,
};
/// Number of concurrent job-worker tasks. Each worker claims one job
@@ -269,145 +269,24 @@ pub fn start(config: Config) {
/// Claim and run a single job. Returns `true` when work was processed,
/// `false` when no eligible row was available.
pub async fn run_once(config: &Config) -> Result<bool> {
// Cooperative throttle BEFORE `claim_next()`. Holding the DB claim
// across an awaited `wait_for_capacity()` would let `Paused` mode
// sit on the row past `DEFAULT_LOCK_DURATION_MS`, after which
// `recover_stale_locks()` would requeue it for another worker to
// pick up — duplicating side effects. Throttling here means
// non-LLM jobs (AppendBuffer/FlushStale) also experience the same
// gate delay, but that's fine: in Throttled mode the host is
// already overloaded and a 30s breather between any DB-write batch
// is welcome; in Paused mode the user has explicitly asked us to
// stand down. Returns immediately in Aggressive/Normal so plugged-in
// desktops with headroom pay zero cost.
//
// For LLM-bound jobs the returned `LlmPermit` reserves the global
// single slot for the lifetime of `handle_job`. Non-LLM jobs
// (`AppendBuffer`, `FlushStale`) drop the permit before the
// handler runs so they don't block the slot.
let gate_permit = crate::openhuman::scheduler_gate::wait_for_capacity().await;
// Cooperative throttle BEFORE claiming, so memory queue work still yields to
// voice/autocomplete/triage under load (Throttled/Paused modes), exactly as
// the legacy pool did. Held across the single crate step below; returns
// immediately in Aggressive/Normal so idle desktops pay zero cost.
let _gate_permit = crate::openhuman::scheduler_gate::wait_for_capacity().await;
let Some(job) = claim_next(config, DEFAULT_LOCK_DURATION_MS)? else {
return Ok(false);
};
let llm_permit = if job.kind.is_llm_bound() {
// Local Ollama loads ~1.3 GB resident per concurrent call —
// hold the gate to enforce process-wide single-slot RAM
// safety. Cloud calls are bandwidth-bound, not RAM-bound:
// drop the permit so multiple workers can run cloud
// extract/summarise calls in parallel (the worker pool
// itself, sized to `WORKER_COUNT`, is the upstream bound).
let memory_uses_local = config.workload_uses_local("memory");
log::trace!(
"[memory::jobs] llm permit routing job_id={} kind={} memory_uses_local={}",
job.id,
job.kind.as_str(),
memory_uses_local
);
if memory_uses_local {
gate_permit
} else {
drop(gate_permit);
None
}
} else {
// Non-LLM jobs don't need the global slot; release it so an
// LLM-bound caller waiting elsewhere in the process can run.
drop(gate_permit);
None
};
let mut jobs = vec![job];
if jobs[0].kind == JobKind::ExtractChunk {
let extra_limit = handlers::EXTRACT_EMBED_BATCH.saturating_sub(1);
let mut extra = claim_ready_extract_batch(config, DEFAULT_LOCK_DURATION_MS, extra_limit)?;
if !extra.is_empty() {
log::debug!(
"[memory::jobs] running extract batch count={}",
extra.len() + 1
);
jobs.append(&mut extra);
}
}
let results = if jobs.len() > 1 && jobs[0].kind == JobKind::ExtractChunk {
handlers::handle_extract_batch(config, &jobs).await?
} else {
let job = jobs
.pop()
.expect("worker has exactly one claimed job in non-batch path");
let result = handlers::handle_job(config, &job).await;
vec![(job, result)]
};
drop(llm_permit);
// A failed settle (`mark_done` / `mark_failed` / `mark_deferred` below)
// can also return `SQLITE_BUSY`. The worker's outer `Err` arm in
// `start` reclassifies those into a warn-log + backoff (no Sentry
// report) via [`is_sqlite_busy`]. On a stale settle the row's
// `locked_until_ms` eventually elapses and `recover_stale_locks`
// requeues it, so dropping the error here is at-most a re-run.
for (job, result) in results {
settle_job(config, &job, result)?;
}
Ok(true)
}
fn settle_job(config: &Config, job: &Job, result: Result<JobOutcome>) -> Result<()> {
match result {
Ok(JobOutcome::Done) => {
log::debug!(
"[memory::jobs] done id={} kind={}",
job.id,
job.kind.as_str()
);
mark_done(config, job)?;
}
Ok(JobOutcome::Defer { until_ms, reason }) => {
// Defer is normal operation (transient blocker, e.g. rate
// limit) — log at info, not warn — and do NOT count this
// claim toward the failure-attempt budget. `mark_deferred`
// reverts the bump applied by `claim_next` so the row's
// attempts counter stays where it was before this claim.
//
// `reason` is handler-supplied free-form text and may
// include upstream provider responses; scrub for log
// emission while keeping the original in DB state.
log::info!(
"[memory::jobs] deferred id={} kind={} until_ms={} reason={}",
job.id,
job.kind.as_str(),
until_ms,
scrub_for_log(&reason)
);
mark_deferred(config, job, until_ms, &reason)?;
}
Err(err) => {
// Preserve the full anyhow cause chain in the persisted
// last_error so a reader of mem_tree_jobs can see the root
// cause, not just the top-level message. The log line gets
// the same chain after `scrub_for_log`, since anyhow chains
// commonly embed upstream HTTP bodies / auth headers.
let message = format!("{err:#}");
// #002: if the error chain carries a typed `PipelineFailure`
// (attached at the embed/extract boundary), pass it through so
// `mark_failed_typed` can fail fast on unrecoverable causes
// (budget/auth/dim) instead of burning the retry budget, and
// persist the typed reason for the status/doctor surface.
let typed = err.downcast_ref::<PipelineFailure>();
log::warn!(
"[memory::jobs] job failed id={} kind={} reason={:?} err={}",
job.id,
job.kind.as_str(),
typed.map(|f| f.code.as_str()),
scrub_for_log(&message)
);
mark_failed_typed(config, job, &message, typed)?;
}
}
Ok(())
// W4 flip: TinyCortex now owns claim → dispatch → settle. `queue::run_once`
// claims one `mem_tree_jobs` row (the same table host producers enqueue
// into — identical schema, parity P4) and runs it through the crate's
// `handle_job` + `HostQueueDelegates`, which bridge each heavy step
// (score/admit, buffer push, seal, seal-document, re-embed) back to the host
// `memory_tree`/score/embed engine, then settles the row itself. The crate's
// single-slot LLM gate serialises llm-bound jobs; the legacy per-job
// local/cloud permit routing and the extract-batch coalescing are
// intentionally dropped here (perf, not correctness — W4 follow-up).
let mc = crate::openhuman::tinycortex::memory_config_from(config, config.workspace_dir.clone());
let delegates = crate::openhuman::tinycortex::HostQueueDelegates::new(config.clone());
tinycortex::memory::queue::run_once(&mc, &delegates).await
}
/// Classify whether an error is a transient I/O failure that should be
@@ -554,21 +554,16 @@ async fn clear_memory_delete_cascades_orphaned_source_tree_and_settles_queued_jo
.unwrap();
// ---- the queued Seal job settles to Done (tree missing), not stuck pending ----
let claimed = queue_store::claim_next(&cfg, queue_store::DEFAULT_LOCK_DURATION_MS)
.unwrap()
.expect("seal job claimable");
assert_eq!(claimed.kind, queue_types::JobKind::Seal);
let outcome = crate::openhuman::memory_queue::handlers::handle_job(&cfg, &claimed)
// Drive the crate queue engine end-to-end through the seam
// (`drain_until_idle` → `worker::run_once` → crate claim/dispatch/settle),
// the flipped W4 production path — rather than the deleted host engine.
crate::openhuman::memory_queue::drain_until_idle(&cfg)
.await
.expect("handle_job ok");
assert!(
matches!(outcome, queue_types::JobOutcome::Done),
"seal over a deleted tree must no-op to Done, got {outcome:?}"
);
queue_store::mark_done(&cfg, &claimed).unwrap();
.expect("drain queue");
assert_eq!(
queue_store::get_job(&cfg, &job_id).unwrap().unwrap().status,
queue_types::JobStatus::Done
queue_types::JobStatus::Done,
"seal over a deleted tree must settle to Done, not stuck pending"
);
}
@@ -757,15 +752,16 @@ async fn queued_jobs_for_deleted_chunk_settle_to_done() {
delete_chunks_by_source(&cfg, SourceKind::Chat, "slack:#eng").unwrap();
assert!(get_chunk(&cfg, &c.id).unwrap().is_none());
queue_store::enqueue(
let extract_id = queue_store::enqueue(
&cfg,
&queue_types::NewJob::extract_chunk(&queue_types::ExtractChunkPayload {
chunk_id: c.id.clone(),
})
.unwrap(),
)
.unwrap();
queue_store::enqueue(
.unwrap()
.expect("extract_chunk job enqueued");
let append_id = queue_store::enqueue(
&cfg,
&queue_types::NewJob::append_buffer(&queue_types::AppendBufferPayload {
node: queue_types::NodeRef::Leaf {
@@ -777,21 +773,21 @@ async fn queued_jobs_for_deleted_chunk_settle_to_done() {
})
.unwrap(),
)
.unwrap();
.unwrap()
.expect("append_buffer job enqueued");
for _ in 0..2 {
let job = queue_store::claim_next(&cfg, queue_store::DEFAULT_LOCK_DURATION_MS)
.unwrap()
.expect("job claimable");
let outcome = crate::openhuman::memory_queue::handlers::handle_job(&cfg, &job)
.await
.expect("handle_job ok");
assert!(
matches!(outcome, queue_types::JobOutcome::Done),
"{:?} over a deleted chunk must settle Done, got {outcome:?}",
job.kind
// Drive both queued jobs through the flipped crate engine (via the host
// `drain_until_idle` → `worker::run_once` seam). Each must settle to Done
// (warn-and-skip over the deleted chunk), not stay stuck pending.
crate::openhuman::memory_queue::drain_until_idle(&cfg)
.await
.expect("drain queue");
for id in [extract_id, append_id] {
assert_eq!(
queue_store::get_job(&cfg, &id).unwrap().unwrap().status,
queue_types::JobStatus::Done,
"job {id} over a deleted chunk must settle to Done"
);
queue_store::mark_done(&cfg, &job).unwrap();
}
}
+1 -1
View File
@@ -31,7 +31,6 @@ use std::collections::{HashMap, HashSet};
use anyhow::Result;
use crate::openhuman::config::Config;
use crate::openhuman::memory_queue::handlers::chunk_tree_scope;
use crate::openhuman::memory_store::chunks::store::{list_chunks, ListChunksQuery};
use crate::openhuman::memory_store::chunks::types::{Chunk, SourceKind};
use crate::openhuman::memory_store::content::read as content_read;
@@ -40,6 +39,7 @@ use crate::openhuman::memory_tree::retrieval::types::{
hit_from_chunk, hit_from_summary, QueryResponse, RetrievalHit,
};
use crate::openhuman::memory_tree::tree::store;
use crate::openhuman::tinycortex::chunk_tree_scope;
/// Default cap on returned cover items when the caller passes `limit = 0`.
const DEFAULT_LIMIT: usize = 200;
+8
View File
@@ -38,10 +38,18 @@ mod config;
mod embeddings;
#[cfg(test)]
mod parity;
mod queue_driver;
pub use chat::{build_chat_provider, SeamChatProvider};
pub use config::memory_config_from;
pub use embeddings::SeamEmbedder;
pub use queue_driver::{
classify_worker_error, HostQueueDelegates, WorkerErrorAction, WorkerReport,
};
// `chunk_tree_scope` is a crate-internal pure helper (the single canonical host
// copy after the W4 handlers deletion); read paths like
// `memory_tree::retrieval::cover` import it through this seam.
pub(crate) use queue_driver::chunk_tree_scope;
// Facade re-exports — the rest of the host imports memory-engine types through
// this one seam so consumer import paths stay stable as the internals flip to
+156
View File
@@ -88,6 +88,162 @@ mod tests {
assert!(p1.ends_with(".md"), "chunk files are markdown -> {p1}");
}
/// P6 (differential) — the host and crate `chunk_rel_path` must produce
/// **byte-identical** vault paths for every id shape a real workspace holds.
/// Both impls still exist (content is not flipped until W3), so a crate-side
/// change to `slugify_source_id` / `sanitize_filename` / the email special
/// case would silently strand every existing chunk file under a new path.
/// This pins them together over an adversarial corpus (colons, all
/// Windows-illegal chars, unicode, >255-char ids, gmail participant slugs,
/// malformed email source_ids) so any drift fails here, not on a user's disk.
#[test]
fn chunk_rel_path_host_crate_byte_parity() {
use crate::openhuman::memory_store::content::paths as host;
use tinycortex::memory::store::content as cortex;
let long_id = "x".repeat(300);
let corpus: &[(&str, &str, &str)] = &[
// (source_kind, source_id, chunk_id)
("chat", "slack:#eng", "chat:slack:#eng:0"),
("chat", "Slack:#Eng__Team", "chat:slack:#eng:0"),
("document", "file:///Users/x/Notes.md", "doc:notes:3"),
("document", "weird__source__id", "id-with-no-illegal-chars"),
("chat", "src", "a\\b/c:d*e?f\"g<h>i|j"),
("chat", "东京:room", "chat:东京:0"),
("chat", "src", &long_id),
// Email: well-formed gmail participants → one slugified folder.
(
"email",
"gmail:notifications@github.com|sanil@x.com",
"email:msg:0",
),
("email", "gmail:Alice@X.com|bob@y.com", "email:msg:1"),
// Email: malformed / legacy source_id → flat fallback layout.
("email", "legacyid", "email:legacy:0"),
("email", "gmail:", "email:empty-participants:0"),
];
for (kind, source_id, chunk_id) in corpus {
let h = host::chunk_rel_path(kind, source_id, chunk_id);
let c = cortex::chunk_rel_path(kind, source_id, chunk_id);
assert_eq!(
h, c,
"chunk_rel_path diverged for (kind={kind}, source_id={source_id}, chunk_id={chunk_id}): host={h} crate={c}"
);
assert!(!h.contains(':'), "host path leaked ':' -> {h}");
assert!(h.ends_with(".md"), "chunk files are markdown -> {h}");
}
}
/// P6 (differential) — the same byte-parity requirement for summary paths.
/// The summary basename (`summary_filename`) and the `wiki/summaries/...`
/// layout per `SummaryTreeKind` must match across host and crate, or a
/// re-open would not find an existing sealed summary in place.
#[test]
fn summary_rel_path_host_crate_byte_parity() {
use crate::openhuman::memory_store::content::paths as host;
use tinycortex::memory::store::content as cortex;
// (host kind, crate kind, scope_slug) — variants are 1:1 across sides.
let kinds = [
(
host::SummaryTreeKind::Source,
cortex::SummaryTreeKind::Source,
"source-slug",
),
(
host::SummaryTreeKind::Global,
cortex::SummaryTreeKind::Global,
"ignored-for-global",
),
(
host::SummaryTreeKind::Topic,
cortex::SummaryTreeKind::Topic,
"phoenix-migration",
),
];
// Canonical ms-first ids, legacy level-first ids, and malformed shapes
// that must fall back through `sanitize_filename` on both sides.
let summary_ids: &[&str] = &[
"summary:1700000000000:L2-abc-uuid",
"summary:L3:legacy-uuid",
"summary:1700000000000:L2-a/b", // illegal tail → sanitized
"summary:notms:L1-tail", // non-13-digit ms → fallback
"raw-unknown-shape:with:colons", // unknown → sanitize_filename
"东京-summary", // unicode
];
for (hk, ck, scope) in kinds {
for level in [0u32, 1, 4] {
for sid in summary_ids {
let h = host::summary_rel_path(hk, scope, level, sid);
let c = cortex::summary_rel_path(ck, scope, level, sid);
assert_eq!(
h, c,
"summary_rel_path diverged for (scope={scope}, level={level}, id={sid}): host={h} crate={c}"
);
assert!(!h.contains(':'), "host summary path leaked ':' -> {h}");
}
}
}
}
/// P10 — the embedding-space **signature** string that keys every persisted
/// vector. Host (`embeddings::format_embedding_signature`) and crate
/// (`store::vectors::format_embedding_signature`) each own their **own** copy
/// of this formatter, so a change to either would silently split one
/// embedding space into two — every existing vector would look stale under
/// the new signature and trigger a full re-embed storm on the next open.
/// Pin both to the golden `provider={name};model={model};dims={dims}` form
/// over a corpus (real provider triples plus empties / special chars).
#[test]
fn embedding_signature_host_crate_byte_parity() {
use crate::openhuman::embeddings::format_embedding_signature as host_sig;
use tinycortex::memory::store::vectors::format_embedding_signature as cortex_sig;
// (name, model_id, dims, expected golden)
let corpus: &[(&str, &str, usize, &str)] = &[
(
"voyage",
"voyage-3",
1024,
"provider=voyage;model=voyage-3;dims=1024",
),
(
"openai",
"text-embedding-3-small",
1536,
"provider=openai;model=text-embedding-3-small;dims=1536",
),
(
"ollama",
"nomic-embed-text",
768,
"provider=ollama;model=nomic-embed-text;dims=768",
),
(
"cohere",
"embed-english-v3.0",
1024,
"provider=cohere;model=embed-english-v3.0;dims=1024",
),
("inert", "none", 0, "provider=inert;model=none;dims=0"),
// Edge shapes: empty model, punctuation in model id.
("noop", "", 3, "provider=noop;model=;dims=3"),
("x", "m-1_2.3", 42, "provider=x;model=m-1_2.3;dims=42"),
];
for (name, model, dims, golden) in corpus {
let h = host_sig(name, model, *dims);
let c = cortex_sig(name, model, *dims);
assert_eq!(
h, c,
"signature diverged for (name={name}, model={model}, dims={dims}): host={h} crate={c}"
);
assert_eq!(&h, golden, "signature format drifted from the golden form");
}
}
fn hex(bytes: &[u8]) -> String {
use std::fmt::Write;
bytes
File diff suppressed because it is too large Load Diff
+289
View File
@@ -0,0 +1,289 @@
//! Layer-2 golden-workspace schema-parity harness (migration spec §0.3, parity
//! checklist "Layer 2").
//!
//! The Layer-1 asserters (`src/openhuman/tinycortex/parity.rs`) pin pure on-disk
//! *format* contracts (chunk ids, vector encoding, vault paths, signatures).
//! This is the Layer-2 **differential** guard: it stands up a real workspace
//! through the host's production memory surface (`memory::ops`) and asserts that
//! the two schema tiers that share the workspace **compose** correctly —
//!
//! 1. the **crate-owned substrate** the `tinycortex` chunk DB creates
//! (`init_db` → `chunks/schema.rs`), and
//! 2. the **host-retained `UnifiedMemory` namespace-document tier**
//! (`memory_store/unified/*`),
//!
//! coexisting without collision (parity checklist P3/P5/P11/P12 — the W3 gate).
//! A store/tree cutover that reshaped, renamed, or dropped a table would strand
//! an existing user workspace; this fails here first.
//!
//! Design notes:
//! - **Path-agnostic.** It recursively scans *every* `*.db` under the temp
//! workspace and unions their tables, so it does not care whether the tiers
//! live in one DB file or several, nor exactly where the host client roots
//! them.
//! - The crate chunk-DB init is additionally forced via
//! `tinycortex::memory::chunks::with_connection` so the substrate schema is
//! deterministic regardless of which subsystems the op flow happened to touch.
//! - `vectors` / `store_meta` / `kv_*` are created by other crate subsystems on
//! their own first touch (the chunk/embed pipeline) rather than by the minimal
//! doc-put + recall flow; they are reported in the run's schema dump and left
//! to a follow-up that widens the flow, so this harness stays green and useful
//! today without a fragile dependence on ingest internals.
//!
//! Run with: `cargo test --test memory_golden_parity_e2e`
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use tempfile::tempdir;
use openhuman_core::openhuman::config::Config;
use openhuman_core::openhuman::memory::ops::{
doc_put, memory_recall_context, memory_recall_memories, PutDocParams,
};
use openhuman_core::openhuman::memory::rpc_models::{RecallContextRequest, RecallMemoriesRequest};
use openhuman_core::openhuman::tinycortex::memory_config_from;
// ── Env isolation (mirrors memory_roundtrip_e2e) ─────────────────────────────
struct EnvVarGuard {
key: &'static str,
old: Option<String>,
}
impl EnvVarGuard {
fn set_to_path(key: &'static str, path: &Path) -> Self {
let old = std::env::var(key).ok();
// SAFETY: only used under env_lock(), which serialises env mutation.
unsafe { std::env::set_var(key, path.as_os_str()) };
Self { key, old }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match &self.old {
// SAFETY: see set_to_path; teardown runs under the same env_lock().
Some(v) => unsafe { std::env::set_var(self.key, v) },
None => unsafe { std::env::remove_var(self.key) },
}
}
}
/// Serialises tests: `HOME` + `OPENHUMAN_WORKSPACE` are process-global.
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.expect("env lock poisoned")
}
// ── Expected schema tiers (authoritative names from the two engines) ─────────
/// The crate chunk-DB substrate created by `init_db` (`chunks/schema.rs`). These
/// are the tables the tinycortex store owns and must preserve byte-for-byte
/// across every W3+ cutover.
const CRATE_CHUNK_SCHEMA_TABLES: &[&str] = &[
"mem_tree_chunks",
"mem_tree_chunk_embeddings",
"mem_tree_chunk_reembed_skipped",
"mem_tree_score",
"mem_tree_entity_index",
"mem_tree_entity_edges",
"mem_tree_trees",
"mem_tree_summaries",
"mem_tree_summary_embeddings",
"mem_tree_summary_reembed_skipped",
"mem_tree_buffers",
"mem_tree_entity_hotness",
"mem_tree_jobs",
"mem_tree_ingested_sources",
"mcp_writes",
];
/// The host-retained `UnifiedMemory` namespace-document tier
/// (`memory_store/unified/*`) — stays host, coexists in the shared workspace.
const HOST_UNIFIED_TABLES: &[&str] = &[
"memory_docs",
"graph_global",
"graph_namespace",
"episodic_log",
"event_log",
"event_embeddings",
"conversation_segments",
"segment_embeddings",
"vector_chunks",
"user_profile",
];
// ── Schema scan helpers (path-agnostic, read-only) ───────────────────────────
fn collect_db_files(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_db_files(&path, out);
} else if path.extension().and_then(|e| e.to_str()) == Some("db") {
out.push(path);
}
}
}
/// Union of every user table across every `*.db` under `ws` (read-only opens;
/// SQLite-internal `sqlite_%` tables excluded).
fn tables_in_workspace(ws: &Path) -> BTreeSet<String> {
let mut dbs = Vec::new();
collect_db_files(ws, &mut dbs);
let mut tables = BTreeSet::new();
for db in dbs {
let Ok(conn) =
rusqlite::Connection::open_with_flags(&db, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)
else {
continue;
};
let Ok(mut stmt) = conn.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'",
) else {
continue;
};
let Ok(rows) = stmt.query_map([], |row| row.get::<_, String>(0)) else {
continue;
};
for name in rows.flatten() {
tables.insert(name);
}
}
tables
}
fn put_params(ns: &str) -> PutDocParams {
PutDocParams {
namespace: ns.to_string(),
key: "golden-parity-canary".to_string(),
title: "Golden parity canary".to_string(),
content: "TinyCortex golden-workspace schema-parity canary fact".to_string(),
source_type: "doc".to_string(),
priority: "medium".to_string(),
tags: Vec::new(),
metadata: serde_json::Value::Null,
category: "core".to_string(),
session_id: None,
document_id: None,
}
}
/// Drive the real production surface so both schema tiers initialise, then force
/// the crate substrate init to make the chunk-DB schema deterministic. Returns
/// the union of tables observed across the workspace.
async fn init_and_scan(ns: &str, workspace: &Path) -> BTreeSet<String> {
// Host unified tier + retrieval (production path).
doc_put(put_params(ns)).await.expect("doc_put");
let _ = memory_recall_memories(RecallMemoriesRequest {
namespace: ns.to_string(),
min_retention: None,
as_of: None,
limit: Some(10),
max_chunks: None,
top_k: None,
})
.await
.expect("recall_memories");
let _ = memory_recall_context(RecallContextRequest {
namespace: ns.to_string(),
include_references: Some(true),
limit: Some(10),
max_chunks: None,
})
.await
.expect("recall_context");
// Force the crate chunk-DB substrate init (deterministic — creates the full
// chunks/schema.rs table set regardless of what the ops above touched).
let mc = memory_config_from(&Config::default(), workspace.to_path_buf());
tinycortex::memory::chunks::with_connection(&mc, |_conn| Ok(())).expect("crate chunk-DB init");
tables_in_workspace(workspace)
}
// ── Tests ────────────────────────────────────────────────────────────────────
/// P3/P5/P11/P12 — the crate substrate and the host `UnifiedMemory` tier both
/// initialise into the shared workspace without collision. Any cutover that
/// renames/drops one of these tables fails here before it can strand a real
/// user workspace.
#[tokio::test]
async fn golden_workspace_composes_substrate_and_unified_tiers() {
let _lock = env_lock();
let tmp = tempdir().expect("tempdir");
let _home = EnvVarGuard::set_to_path("HOME", tmp.path());
let workspace = tmp.path().join("workspace");
std::fs::create_dir_all(&workspace).expect("mkdir workspace");
let _ws = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", &workspace);
let tables = init_and_scan("golden-parity-e2e", &workspace).await;
// Full schema dump for review / manifest capture in the test log.
eprintln!(
"[golden-parity] workspace tables ({}): {:?}",
tables.len(),
tables
);
let missing_substrate: Vec<&str> = CRATE_CHUNK_SCHEMA_TABLES
.iter()
.copied()
.filter(|t| !tables.contains(*t))
.collect();
assert!(
missing_substrate.is_empty(),
"crate chunk-DB substrate tables missing from the workspace: {missing_substrate:?}; found: {tables:?}"
);
let missing_unified: Vec<&str> = HOST_UNIFIED_TABLES
.iter()
.copied()
.filter(|t| !tables.contains(*t))
.collect();
assert!(
missing_unified.is_empty(),
"host UnifiedMemory tables missing from the workspace: {missing_unified:?}; found: {tables:?}"
);
// Coexistence: both tiers are present in the same workspace (P12).
assert!(
tables.contains("mem_tree_chunks") && tables.contains("memory_docs"),
"both the crate substrate and the host unified tier must coexist"
);
}
/// Comparator 5 (idempotent re-open) — re-running the production flow over an
/// existing workspace must not add or drop tables (no lazy second-run schema
/// churn / migration storm).
#[tokio::test]
async fn golden_workspace_reopen_is_schema_stable() {
let _lock = env_lock();
let tmp = tempdir().expect("tempdir");
let _home = EnvVarGuard::set_to_path("HOME", tmp.path());
let workspace = tmp.path().join("workspace");
std::fs::create_dir_all(&workspace).expect("mkdir workspace");
let _ws = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", &workspace);
let first = init_and_scan("golden-parity-reopen", &workspace).await;
let second = init_and_scan("golden-parity-reopen", &workspace).await;
assert_eq!(
first, second,
"re-running the flow changed the workspace table set (schema churn on re-open)"
);
assert!(
!first.is_empty(),
"sanity: the first pass should have created tables"
);
}