fix(mem0): reset() proves the namespace is writable, never sleeps - #7
Merged
Merged
Conversation
Reading empty is necessary but not sufficient: a delete_all keeps reaping writes after it stops being visible to search, which is how 6/14 writes were lost. reset() now proves the namespace accepts writes again before returning — write a sentinel, poll for it, delete it. Only on the delete path; the skip path (namespace already empty, the common case) is untouched and still costs nothing. If the namespace never accepts a write within the timeout, raise rather than start a scenario whose first facts would be swallowed and scored against the provider. No blanket settle added. The sentinel retries rather than firing once. A write swallowed by the still-reaping delete_all never becomes retrievable however long it is polled, so a single-shot sentinel would abort runs a second attempt moments later would have saved — trading a phantom FAIL for a phantom abort. Each attempt gets 5s; the loop is bounded by the 30s converge timeout, so it cannot hang. Every deleting reset now records empty_after_s, empty_to_writable_s and sentinel_attempts, and logs them to stderr. That series is the number nobody has had — how long a delete_all keeps reaping after it stops being observable — collected free on every run, including runs that pass. Four tests added against a client whose delete_all arms a window of swallowed writes after the namespace already reads empty: the sentinel absorbs the window so the first real write survives, no sentinel is left behind, the gap is recorded only on the delete path, and reset raises rather than starting against an unwritable namespace. Invariant 9: this can convert phantom missing_current_fact FAILs into passes. Founder-instructed, recorded in HANDOFF. A genuine finding is untouched — the sentinel proves writability and is removed before any scenario value is written. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqxND2pAdrMMubjBnp3VLR
YinkaMetrics
marked this pull request as ready for review
August 4, 2026 10:09
YinkaMetrics
pushed a commit
that referenced
this pull request
Aug 4, 2026
PR #7 merged as e7e89dc on explicit founder approval, quoted in the entry. That closes the invariant 9 requirement: the change can convert phantom missing_current_fact FAILs into passes, so it needed sign-off before merge. The retry-vs-single-shot deviation was ratified. Promotes the first live namespace-arm result, reported not observed here: arm (f) came back WRITE_LOST — a swallowed write never became retrievable across 25 reads over 120s. That localises the mechanism to delete_all. Per-key delete followed by an immediate write landed at 0s in arms (a), (b) and (c); the namespace-wide delete_all that reset() performs loses the write for at least two minutes. Content dedup and same-scope reaping stay ruled out. The per-key substitution in the earlier arms is confirmed as why they all passed. Flags a sizing risk rather than leaving it to be discovered on a metered run: the shipped sentinel loop is bounded at 30s, while (f) shows the condition persisting for at least 120s under single-write polling. Re-issuing may converge far sooner — that is the difference the retry loop exploits and (f) cannot measure — but it is unmeasured, so _CONVERGE_TIMEOUT stays at 30s until the empty_to_writable_s series sizes it from data. Records what is still missing for the promotion rule: (g)'s outcome and the results filename for the (f) run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqxND2pAdrMMubjBnp3VLR
YinkaMetrics
pushed a commit
that referenced
this pull request
Aug 4, 2026
PR #7 merged as e7e89dc on explicit founder approval, quoted in the entry. That closes the invariant 9 requirement: the change can convert phantom missing_current_fact FAILs into passes, so it needed sign-off before merge. The retry-vs-single-shot deviation was ratified. Promotes the first live namespace-arm result, reported not observed here: arm (f) came back WRITE_LOST — a swallowed write never became retrievable across 25 reads over 120s. That localises the mechanism to delete_all. Per-key delete followed by an immediate write landed at 0s in arms (a), (b) and (c); the namespace-wide delete_all that reset() performs loses the write for at least two minutes. Content dedup and same-scope reaping stay ruled out. The per-key substitution in the earlier arms is confirmed as why they all passed. Flags a sizing risk rather than leaving it to be discovered on a metered run: the shipped sentinel loop is bounded at 30s, while (f) shows the condition persisting for at least 120s under single-write polling. Re-issuing may converge far sooner — that is the difference the retry loop exploits and (f) cannot measure — but it is unmeasured, so _CONVERGE_TIMEOUT stays at 30s until the empty_to_writable_s series sizes it from data. Records what is still missing for the promotion rule: (g)'s outcome and the results filename for the (f) run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqxND2pAdrMMubjBnp3VLR
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
reset()no longer treats "the namespace reads empty" as proof thedelete_allfinished. When it actually deleted, it proves the namespace accepts writes again before returning: write a sentinel, poll for it, delete it, return.This can convert phantom
missing_current_factFAILs into passes — the guarded direction. It was founder-instructed, which is the sign-off invariant 9 requires before merge, and theHANDOFF.mdentry records that.The good-faith test named in the invariant holds: a genuine finding survives untouched. The sentinel only proves the namespace accepts writes again, and is deleted before any scenario value is written. Nothing about retrieval, supersession, scoping or deletion behaviour is affected.
One deliberate refinement of the brief
The brief said: write a sentinel, poll, raise if it never lands. Implemented as a bounded retry loop instead.
A single-shot sentinel is wrong in exactly the case it exists for: a write swallowed by the still-reaping
delete_allnever becomes retrievable however long it is polled. Polling harder cannot help; only writing again can. So a single-shot sentinel would abort runs that a second attempt moments later would have saved — trading a phantom FAIL for a phantom abort.Each attempt gets 5s (
_SENTINEL_ATTEMPT_TIMEOUT); the loop is bounded by the 30s_CONVERGE_TIMEOUT, so it cannot hang. Attempt count is recorded and logged.Say if you want the strict single-shot version — it's a two-line change.
The measurement series, free on every run
Each deleting reset appends to
adapter.reset_convergenceand prints:empty_to_writable_sis the number nobody has had: how long adelete_allkeeps reaping after it stops being visible to search. One value is an anecdote; a run produces one per deleting reset, and the distribution is the real characterisation.This makes arms (f)/(g) a confirmation rather than the only source — and it collects data on runs that pass, not just ones that break.
Tests: 67 → 71
Against a
ReapingClientwhosedelete_allarms a window of swallowed writes after the namespace already reads empty — the measured 6/14 behaviour:Building these caught a defect in my first fake client: it armed reaping from construction rather than from
delete_all, which isn't the condition — reaping is a consequence of the delete. Fixed before it could validate the wrong thing.Two details worth reviewing
Sentinel cleanup uses a per-key delete by id, never a
delete_all, so cleanup cannot restart the condition it just cleared. Arms (a)–(c) measured per-key delete followed by immediate write landing at 0s, three for three — that's the evidence for the choice.Residual risk, stated: a sentinel landing after cleanup would persist. It sits under a tenant/user pair no scenario maps onto, so no scenario query can retrieve it, and the next
reset()removes it. It would make the namespace read non-empty, turning a future skip-path reset into a delete-path one. Bounded and self-healing, but real.Verification
pytest -q: 71 passed, 4 skippedmemorycheck run scenarios --adapter reference:strict --fail-on p2: PASSWhat this does not do
It does not close the
012/003question. It removes one harness-side mechanism by which a run can be killed or a provider mis-scored. Whether the aborts were that mechanism is still open — but the next run will now measure the gap rather than guess at it.External launch remains HELD.
Generated by Claude Code