Skip to content

fix(mem0): reset() proves the namespace is writable, never sleeps - #7

Merged
YinkaMetrics merged 1 commit into
mainfrom
claude/quota-reset-diagnostics-8w7gh0
Aug 4, 2026
Merged

fix(mem0): reset() proves the namespace is writable, never sleeps#7
YinkaMetrics merged 1 commit into
mainfrom
claude/quota-reset-diagnostics-8w7gh0

Conversation

@YinkaMetrics

Copy link
Copy Markdown
Owner

Summary

reset() no longer treats "the namespace reads empty" as proof the delete_all finished. When it actually deleted, it proves the namespace accepts writes again before returning: write a sentinel, poll for it, delete it, return.

  • Only on the delete path. The skip path — namespace already empty, the common case — is untouched and still costs nothing.
  • Raises if the namespace never accepts a write within the timeout, rather than starting a scenario whose first facts would be swallowed.
  • No blanket settle added, per instruction.

⚠️ Invariant 9

This can convert phantom missing_current_fact FAILs into passes — the guarded direction. It was founder-instructed, which is the sign-off invariant 9 requires before merge, and the HANDOFF.md entry 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_all never 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_convergence and prints:

  [reset] mc_ns: namespace read empty after 2.5s, writable 1.5s later (2 sentinel attempt(s))

empty_to_writable_s is the number nobody has had: how long a delete_all keeps 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 ReapingClient whose delete_all arms a window of swallowed writes after the namespace already reads empty — the measured 6/14 behaviour:

  • the sentinel absorbs the reaping window, so the scenario's first real write survives (without it, that write vanishes and is scored as a missing fact);
  • no sentinel is left behind;
  • the gap is recorded on the delete path and not on the skip path;
  • reset raises rather than starting against an unwritable namespace.

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 skipped
  • memorycheck run scenarios --adapter reference:strict --fail-on p2: PASS

What this does not do

It does not close the 012/003 question. 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

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
YinkaMetrics marked this pull request as ready for review August 4, 2026 10:09
@YinkaMetrics
YinkaMetrics merged commit e7e89dc into main Aug 4, 2026
2 checks passed
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants