Skip to content

fix: durable memory writes no longer exit 1 when post-write embed/anchor fails#399

Open
gabaum10 wants to merge 5 commits into
feat/store-boundary-dedupfrom
fix/non-fatal-post-write-side-effects
Open

fix: durable memory writes no longer exit 1 when post-write embed/anchor fails#399
gabaum10 wants to merge 5 commits into
feat/store-boundary-dedupfrom
fix/non-fatal-post-write-side-effects

Conversation

@gabaum10

@gabaum10 gabaum10 commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Symptom

mx memory append (and its siblings) reported exit 1 on writes that had already landed. Callers treating exit 1 as "the write failed" re-fired — and the re-fire created duplicates. Hit in real use: a backgrounded append reported failure on a write that was already durable, the caller retried, and the entry got written twice.

Root cause

The durable write commits first, then post-write side effects run: auto_embed(...)? and auto_anchor(...)?. Those ? operators propagate transient side-effect failures — model cold-load, a websocket blip — straight to a non-zero exit, after the entry is already durable. So a fully-successful, committed write surfaced as a failure because an optional follow-up step hiccupped.

Diagnosis was trace-level: walking iteration counts and the exact ? operators in the post-write blocks of src/handlers/memory.rs.

Fix

Post-write embed/anchor is now non-fatal: on failure it logs a stderr warning noting that the entry itself is durable, and exits 0. This mirrors the existing non-fatal backup pattern already in the codebase. Applied symmetrically across Append / Add / Update / Edit / Prepend / Restore. Genuine write failures — where the durable write itself fails — still exit non-zero.

Validation

Adversarial repro against the patched build: corrupted the cached ONNX blob to force TractProvider::new to fail at the exact diagnosed line.

  • Transient side-effect failure → exit 0 + stderr warning + durable write confirmed by a follow-up graph read.
  • Genuine failure (append to a nonexistent ID) → still exits 1.

New test file tests/write_side_effect_nonfatal.rs, 5/5 passing. cargo fmt and cargo clippy clean.

Known follow-up (not in this PR)

--no-embed / --no-auto-anchor emit their "(... skipped)" skip-notices via println! to stdout, ahead of the JSON payload under --json — which corrupts the "stdout is JSON" contract. Surfaced by the new tests (the add_plain_no_embed helper drops --json to work around it). Pre-existing, independent of this change. Left for a separate call on the broader "human-facing notices never touch stdout under --json" rule.

Note

The local test environment couldn't run tests/trigger_check.rs (SurrealDB IAM permissions — environmental, not a code issue). CI should exercise it properly.

@coryzibell coryzibell left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: REQUEST CHANGES

The core change is right — durable-then-best-effort is the correct shape, and genuine write failures still exit non-zero (verified: upsert/read-back still use ?). Durability is sound: add_one read-back-verifies and bail!s before any side effect runs (src/handlers/memory.rs:446,62). But two things go back down the chute: partial-success is invisible to the consumers that matter, and half the change (anchor) is entirely untested.


Findings (ranked)

Critical

1. Partial-success is invisible to --json and exit-code consumers. src/handlers/memory.rs:479, 1126, 1815, 1898, 1992, 2082, 2212 (embed) and the anchor twins. On failure the entry lands durable-but-unsearchable (no embedding → absent from --semantic until a manual embed --all), and the only signal is a stderr eprintln!. Exit code is 0 — byte-identical to full success. Under --json, the payload gets no embed_deferred/embedding_failed field, so a structured caller (hearth/companions consume --json) reads clean success JSON and cannot detect the degraded state. The question "surfaced or fully silent" answers: surfaced to a human on stderr, silent to every machine consumer. Fix: add a structured field to the --json payload (e.g. "embed_deferred": true) so the degradation is programmatically detectable. This is the main lever.

2. The anchor-failure path is entirely untested. tests/write_side_effect_nonfatal.rs. All three failure tests use mx_with_broken_model_cache, which breaks embed. But auto_embed failing means the entry has no embedding, and auto_anchor early-returns Ok(()) when entry.embedding.is_none() (src/helpers.rs:405-407). So the anchor error branch — 6 of the 12 changed call sites — never executes in any test. The PR claims symmetric Add/Update/Edit/Append/Prepend/Restore anchor coverage; zero anchor failures are actually forced. Add a test that forces auto_anchor to fail on an already-embedded entry.

Warnings

3. 4 of 6 single-entry sites have no non-fatal embed test. Only Add (:479) and Append (:1992) are exercised. Update (:1798), Edit (:1898), Prepend (:2082), Restore (:2212) rely on "same code shape." Same shape, yes — but the commit message asserts all six, and untested is untested.

4. Merge/interaction conflict with PR #402 is near-certain. Both PRs edit add_one, the Add --json payload construction, and the add-batch embed block (#399 rewrites the hoisted embed pass at :2700; #402 adds a pre-write dedup gate to the same funnels). Semantically complementary — #402 dedups before the write, #399 softens side effects after — but the textual conflict in add_one and the batch block needs hand-reconciliation. More important: the merged --json schema needs joint review. #402 adds skipped/duplicate_of/status/dedup_lookup_warning/dedup: bypassed_no_session; #399 adds none for its degraded path (finding 1). See the Merge order section below.

5. Reconcile is real but manual and unsignalled in the DB. Confirmed mx memory embed --all re-embeds via list_all (:2690-2734), so a missed entry will be picked up — the PR comment's claim is accurate. But nothing triggers it automatically, it re-embeds the entire graph (not just the gaps), and there's no persistent "needs-embed" marker. A backgrounded caller that discards stderr leaves the entry silently unsearchable until a human remembers. Finding 1's structured field would also give a future targeted reconcile something to key on.

Suggestions

6. The let _ = expr.map_err(|e| eprintln!(...)) idiom reads oddly (all 12 sites). Using map_err for a side effect and then discarding a Result<(),()> obscures intent. if let Err(e) = expr { eprintln!(...) } says exactly what it does. Readability — the code is read a hundred times.

7. The warning omits the entry id. Every site prints "post-write embed failed (entry durable): {e}" with no kn- id. For the "reconcile later" story to be actionable, a human needs to know which entry. Add &id.

8. Does not touch #398, correctly out of scope — but note the direction. The diff wraps the existing auto_anchor(&id, db.as_ref(), removed)? (:1817) without altering anchor-merge plumbing, so #398 (--add-anchor clobbered by the auto-anchor pass) is neither fixed nor regressed. Caveat: making the anchor pass non-fatal makes an anchor-pass failure quieter, mildly at odds with #398's "at minimum fail loudly" ask on that path. Worth a line in the PR body.

9. Tests are network- and environment-dependent (self-documented): they require huggingface.co reachability, and trigger_check.rs couldn't run locally (SurrealDB IAM). CI must actually exercise both.


Merge order

Agreed cross-PR plan for the three concurrent memory PRs, to prevent conflicts:

  • Recommended landing order: #401#402#399 (this PR lands last).
  • Rationale: #402 changes add_one's signature and return type (Result<KnowledgeEntry>Result<WriteOutcome>) and adds skipped/duplicate_of/status to the --json payload. This PR should rebase on top of #402 and add its structured degraded-state field (finding 1, e.g. embed_deferred) into that already-richer JSON schema, so the JSON contract is designed once, coherently.
  • Semantics compose correctly: #402's dedup-skip early-return means no post-write side effects run on a skip — which is the right behavior. #402 dedups before the write; #399 softens side effects after. No semantic conflict, only textual — resolved by landing in this order.

The nut goes back to the examination table. Fix 1 and 2, fold the JSON field into #402's schema after rebase, and it earns the chute to the left.

gabaum10 added a commit that referenced this pull request Jul 6, 2026
…#402 review F1)

BLOCKER: dedup_hash keyed on title+body only, and get_entries_for_session
scoped candidates by session+owner but not category. Identical title+body
re-filed under a different category in the same session silently collided
-- the second write was skipped as "already saved" pointing at a row in
the wrong category (undocumented false-positive data loss, the exact class
the ticket named as worst-case).

Fix scopes the candidate query by category instead of folding a third
field into dedup_hash (avoids the LANDMINE ambiguity the hash's doc-comment
already warns about): get_entries_for_session/DedupIndex/dedup_gate now
thread category through, and DedupIndex's group/seen keys include category
so a same-hash entry from a different category's fetch can never shadow
another category's check. All three write funnels (add_one, single-add
--type, batch inline --type) now pass category through the shared gate.

Tags are deliberately excluded from the identity (edge-modeled, not a
scalar row field; folding them in costs a query per candidate) --
documented in dedup_hash's new IDENTITY CONTRACT note and pinned by
identical_content_different_tags_still_dedups.

New/updated tests: identical_content_different_category_does_not_dedup,
identical_content_different_tags_still_dedups,
test_get_entries_for_session_category_scope_excludes_other_categories, plus
the existing get_entries_for_session/DedupIndex test suites updated for the
new category parameter. docs/src/memory.typ coverage-boundary note updated
to describe the category scope and the tags exclusion, and the deferred
UNIQUE index note updated to key on (session, owner, category, dedup_hash).

Deferred (not in this PR, per review triage lanes 2-5): TOCTOU-open
in-process gate (garbaggio UNIQUE index option), unbounded
get_entries_for_session candidate fetch, and the #399/#401 merge-order
plan -- all acknowledged/comment-lane findings, not blockers.
@gabaum10
gabaum10 force-pushed the fix/non-fatal-post-write-side-effects branch from 3ad0e98 to df0cb71 Compare July 6, 2026 19:24
@gabaum10

gabaum10 commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto feat/store-boundary-dedup (b9d6d0c) per merge-order — this PR now stacks on #402. Review-feedback commits (embed_deferred field + anchor-failure test) coming in a follow-up round.

@gabaum10
gabaum10 changed the base branch from main to feat/store-boundary-dedup July 6, 2026 19:28
@gabaum10

gabaum10 commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Base retargeted to feat/store-boundary-dedup — this PR now merges into #402's branch and they ship together; GitHub will auto-retarget to master if #402 merges first.

@coryzibell

Copy link
Copy Markdown
Owner

Re-review (Verdictia Stern) — force-pushed branch @ df0cb71

VERDICT: REQUEST CHANGES — both prior blockers remain open. Confirmed by the author's own note: "Review-feedback commits (embed_deferred field + anchor-failure test) coming in a follow-up round." The rebase landed; the fixes did not.

Blocker 1 — Partial success invisible to --json/exit-code consumers → NOT RESOLVED

  • No embed_deferred (or any degraded-state) field exists anywhere in the tree.
  • add_one (src/handlers/memory.rs:772-797) still swallows both side effects via let _ = auto_embed(...).map_err(|e| eprintln!(...)) and returns WriteOutcome::Written regardless — no channel to the caller; the returned entry was built with embedding: None.
  • The Add-arm --json payload (memory.rs:1682-1702) is the right home — feat: store-boundary write dedup (normalized dedup_hash, both add paths) #402 furnished it with skipped/duplicate_of/status/dedup/dedup_lookup_warning, exactly the "design the JSON contract once" plan. fix: durable memory writes no longer exit 1 when post-write embed/anchor fails #399 added no field into it. Machine consumers still get byte-identical clean-success JSON, exit 0, on a write that landed un-embedded.

Fix stands: propagate embed/anchor results out of add_one, add "embed_deferred" (and the anchor twin) to that payload, both human and JSON paths, with a test.

Blocker 2 — Anchor-failure path ZERO test coverage → NOT RESOLVED

  • All three non-fatal tests in tests/write_side_effect_nonfatal.rs drive mx_with_broken_model_cache, which only trips embed.
  • auto_anchor still early-returns Ok(()) at src/helpers.rs:405-407 when embedding.is_none() — so the anchor map_err branch (memory.rs:791-793 + five sibling sites) never executes in any test. 6 of 12 changed sites unexercised.

Fix stands: force auto_anchor to fail on an already-embedded entry (embed succeeds, anchor fails); assert exit 0 + warning + durability.

Base / composition (good news)

Carry-over suggestions (non-blocking)

  • S6: let _ = expr.map_err(|e| eprintln!(...)) ×12 → if let Err(e) = ... { ... } says what it does.
  • S7: warnings omit the kn- id — "reconcile later" needs to know which entry; the batch variant reports a count, single-entry sites should report the id.

gabaum10 added a commit that referenced this pull request Jul 8, 2026
mx stores content_hash but never enforces it, so regenerated/recased
duplicates (same meaning, different case/punctuation/whitespace) land as
separate rows. Add a normalized dedup_hash (title+body) checked at the write
boundary, against the writer's own same-session entries, via a shared
pre-upsert helper covering BOTH write funnels:

- add_one (single `memory add` + the AddBatch standard-path delegate)
- the AddBatch fact-type inline path ({type, content} lines), which builds
  its entry and calls db.upsert_knowledge directly -- never touches add_one,
  so it needed its own gate wired to the same DedupIndex.

On a same-owner, same-session hit the write is skipped and reported as
already persisted (exit 0, not a failure). `--allow-duplicate` (single add)
/ `allow_duplicate` (per JSONL line) forces the write through.

Candidate fetch is a new KnowledgeStore::get_entries_for_session, field-scoped
on the indexed `session` record-link (NOT the EXTRACTED_FROM edge, which is
frequently missing), hard owner-scoped in application SQL (`owner = $owner`
or `owner IS NONE` for unowned public entries -- never delegated to the
public-permissive visibility filter), and projecting only id/title/body
(never the 768-dim embedding). AgentContext is derived from the entry's own
owner, matching the existing write_verification_ctx pattern.

The in-process DedupIndex fetches lazily, one query per distinct
(session_id, owner) group actually seen in a run -- exactly one query for
the common single-session single-owner batch, and correct (not silently
mis-scoped) for batches spanning multiple sessions/owners, without querying
once per entry.

session_id=None bypasses dedup entirely (by design) and surfaces an
observable bypass signal (`"dedup": "bypassed_no_session"` in --json, a
stderr note in plain mode) rather than failing silently. TOCTOU: this is
in-process, best-effort duplicate prevention, not a DB-level uniqueness
guarantee -- documented as such, not over-claimed.

Output contract treats a skip as success, never failure: affirmative
"Already saved" language (never bare "skipped"/"duplicate"), a positional
per-entry batch line carrying the duplicate_of kn-id (so positional
mark-back stays aligned), and its own "K already saved" tally in the batch
summary, separate from added/failed.

Docs: memory.typ gains a "Write-boundary deduplication" section (behavior
contract, coverage boundary, --allow-duplicate), architecture.typ
distinguishes the legacy content_hash from the new dedup_hash, CLAUDE.md's
maintenance rule now points at PR bodies (no CHANGELOG file exists in this
repo).

Tests: real-store (SurrealDB in-memory) unit tests for get_entries_for_session
scoping (owner exclusion, None-owner, session-field scoping, no embedding
leak), a real-store counting-spy proving one query per distinct group not
per entry, and CLI-level integration tests (tests/dedup_write_boundary.rs)
covering both write funnels' idempotency, the evolved-re-add case,
--allow-duplicate, the session_id=None bypass, and the json skip contract.

No schema change (knowledge_session and knowledge_owner indexes already
exist). Legacy content_hash (title-only, import-time) is untouched.

Note for review: sibling PR #401 (feat/exclude-tags-search-list) is open
against this repo and also touches src/handlers/memory.rs and
src/surreal_db/knowledge.rs -- landing order is Cory's call. This PR does
not touch the areas #401 changes (search/list filtering) as far as this
diff shows, but a rebase may still be needed depending on landing order.
Also note: PR #399 (fix/non-fatal-post-write-side-effects) touches the same
add_one/AddBatch functions; its WriteOutcome-adjacent changes (embed/anchor
skip notices) are pre-existing and out of scope here, but whoever merges
second should expect to reconcile call sites.
@gabaum10
gabaum10 force-pushed the feat/store-boundary-dedup branch from b9d6d0c to 024b1ea Compare July 8, 2026 13:13
gabaum10 added a commit that referenced this pull request Jul 8, 2026
…#402 review F1)

BLOCKER: dedup_hash keyed on title+body only, and get_entries_for_session
scoped candidates by session+owner but not category. Identical title+body
re-filed under a different category in the same session silently collided
-- the second write was skipped as "already saved" pointing at a row in
the wrong category (undocumented false-positive data loss, the exact class
the ticket named as worst-case).

Fix scopes the candidate query by category instead of folding a third
field into dedup_hash (avoids the LANDMINE ambiguity the hash's doc-comment
already warns about): get_entries_for_session/DedupIndex/dedup_gate now
thread category through, and DedupIndex's group/seen keys include category
so a same-hash entry from a different category's fetch can never shadow
another category's check. All three write funnels (add_one, single-add
--type, batch inline --type) now pass category through the shared gate.

Tags are deliberately excluded from the identity (edge-modeled, not a
scalar row field; folding them in costs a query per candidate) --
documented in dedup_hash's new IDENTITY CONTRACT note and pinned by
identical_content_different_tags_still_dedups.

New/updated tests: identical_content_different_category_does_not_dedup,
identical_content_different_tags_still_dedups,
test_get_entries_for_session_category_scope_excludes_other_categories, plus
the existing get_entries_for_session/DedupIndex test suites updated for the
new category parameter. docs/src/memory.typ coverage-boundary note updated
to describe the category scope and the tags exclusion, and the deferred
UNIQUE index note updated to key on (session, owner, category, dedup_hash).

Deferred (not in this PR, per review triage lanes 2-5): TOCTOU-open
in-process gate (garbaggio UNIQUE index option), unbounded
get_entries_for_session candidate fetch, and the #399/#401 merge-order
plan -- all acknowledged/comment-lane findings, not blockers.
gabaum10 added 3 commits July 8, 2026 10:31
auto_embed/auto_anchor ran with `?` after the durable write had already
committed (upsert_knowledge + read-back verified). A transient embed or
anchor failure -- model download hiccup, etc. -- propagated straight to
main() and exited non-zero even though the entry (or edit) had already
landed. Callers treating exit-1 as "it didn't happen" would retry and
duplicate the write.

Made the post-write embed/anchor steps non-fatal (log + continue) across
all six write-then-side-effect sites: Add (both the shared add_one path
and the --type quick-fact path), Update, Edit, Append, Prepend, and
Restore. Mirrors the existing non-fatal backup_content pattern. The write
itself still bails non-zero on genuine failure (permission denial,
missing entry, invalid category) -- only the best-effort side effects
after a confirmed-durable write are now soft.

Adds tests/write_side_effect_nonfatal.rs: forces a deterministic embed
failure (MX_ISOLATE_MODELS pointed at a blocked path) to assert exit 0 +
warning + persisted write on Add and Append, plus two genuine-failure
cases (missing entry, invalid category) asserting exit stays non-zero.
…ns pollute --json stdout (pre-existing contract bug, flagged separately)
@gabaum10
gabaum10 requested a review from coryzibell July 8, 2026 14:49
vsajan added 2 commits July 8, 2026 10:50
…(Cory B1)

add_one's post-write embed/anchor steps are non-fatal by design (the entry
is already durable and read-back verified before either runs), but a
failure there was only ever eprintln'd -- a --json caller had zero signal
that the write landed in a degraded state. Capture the failure in a new
WriteSideEffects struct threaded through WriteOutcome::Written and fold
embed_deferred/anchor_deferred into the Add JSON payload, following the
same conditional-field pattern #402 established for dedup/dedup_lookup_warning
(present only when the step actually failed; absent on success or on a
deliberate --no-embed/--no-auto-anchor skip, which already has its own
"(skipped)" notice).

AddBatch always calls add_one with embed=false/no_auto_anchor=true (embed is
hoisted, anchoring deferred to the nightly run), so its side_effects are
always empty -- destructured and intentionally ignored there.
auto_anchor early-returns Ok(()) when embedding.is_none() (covered by
no_embedding_skips_anchoring), but the path past that guard had zero
coverage: a candidate-fetch failure on an entry that IS embedded. Adds
FailingScoredStore, a KnowledgeStore test double that wraps a real
in-memory SurrealDB and forces semantic_search_entries_scored to error
(same shape as handlers::memory::FailingStore, different failing method),
plus a test proving the failure surfaces as Err rather than being
swallowed inside auto_anchor itself -- which is what lets add_one (B1)
catch it non-fatally and record it as anchor_deferred.
@gabaum10
gabaum10 force-pushed the fix/non-fatal-post-write-side-effects branch from df0cb71 to ae06332 Compare July 8, 2026 14:52
@gabaum10

gabaum10 commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

Both blockers addressed: B1 — embed/anchor side-effect failures now surface as conditional embed_deferred/anchor_deferred fields in the --json payload (same conditional-field pattern as #402's dedup fields, folded per your note); B2 — anchor-failure path test added (already-embedded entry, candidate-fetch failure surfaces past the embedding.is_none() guard). Rebased onto #402's head per landing order. Re-requesting review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants