Skip to content

fix(*): tell a handed-over memory write from a lost one at shutdown - #544

Merged
0xKT merged 5 commits into
mainfrom
fix/memory_write_outcome_report
Sep 21, 2026
Merged

0xKT merged 5 commits into
mainfrom
fix/memory_write_outcome_report

Conversation

@LivXue

@LivXue LivXue commented Sep 20, 2026 •

Copy link
Copy Markdown
Member

Summary

At teardown the store pipeline counted every unsettled record as lost, including one
a worker had already handed to the memory service. Cancelling that worker does not
cancel its request, so the service finished the write anyway, and the host told the
user the turn was gone while it was being indexed.

Measured on three one-shot CLI runs against a live EverOS: the client gave up at its
budget and atomic_facts_extracted landed for the same session 32 to 48 seconds
later, every time. The message was wrong twice over -- the turn was written, and the
service was not unavailable.

Review found that the first version of this fix replaced that error with its mirror.
The mark is set on entering MemoryBackend.store(), which is not a handoff: a
backend that persists only after an await, cancelled mid-await, leaves
DrainOutcome(lost=0, in_flight=1) with the backend's persistence list empty, and
the notice then said the turn had reached long-term memory. A slow connection
cancelled before its request body goes out has the same shape. MemoryBackend
declares no delivery guarantee, so no evidence from the transport is available
without extending the protocol and asking every backend to implement it. This branch
takes the other route and preserves the uncertainty instead.

What changed:

  • StorePipeline.drain returns DrainOutcome(lost, in_flight) instead of one count.
    A record is marked in flight around the backend call, and the mark is read before
    the collect await that unwinds the cancellation, because that unwinding clears
    exactly what is being counted.
  • AgentLoop.drain_backend_stores returns the outcome and logs the two apart: a
    warning for a turn this side watched fail, and for an unsettled one a line saying
    the drain stopped waiting and cannot see whether the service finished it.
  • report_dropped_memory_writes becomes report_memory_write_outcome, since it no
    longer reports only what was dropped. An unsettled turn reads "were still being
    written when shutdown stopped waiting; whether the memory service finished them is
    not known here" -- the outcome, not a verdict on it.
  • DrainOutcome is published on the memory engine's face beside StorePipeline, so
    callers reach it the way test_memory_engine_face requires. Its docstring now
    carries both cases, with the EverOS measurement scoped to the one it measured.
  • A test drives the raven agent teardown that renders the outcome. It had no
    coverage at all: the shared harness stubs maybe_build_memory_backend to None,
    so the branch guarding those two lines never ran.

in_flight keeps its name deliberately. This repository already uses the word for
started and not settled -- swap_in_flight, outbound.in_flight, a DAG node in
flight -- with no delivery sense, so renaming it would detach the field from a
vocabulary that is already right. The delivery claim lived in the prose, and that is
what changed.

Deliberately unchanged: the 2.0s drain budget. It is far shorter than the write it
waits on -- one store measured 16.98s end to end, of which the flush leg was 12.1s --
and raising it to cover that would park every CLI exit for as long while buying
nothing, since the service completes a request it received either way and nothing
needs to recall the turn that just ended. That reasoning sits beside the constant,
because the gap between the two numbers reads like a defect and the next reader would
otherwise close it.

Type

  • Fix
  • Feature
  • Docs
  • CI / tooling
  • Refactor
  • Other

Verification

Run on this branch's head, Python 3.12.13, all extras synced, rebased onto the
current default branch.

  • pytest tests/test_agent_loop_backend_dispatch.py tests/test_providers_factory.py tests/test_cli_agent_commands.py tests/test_memory_engine_face.py tests/test_cli_gateway_commands.py -- 171 passed, 0 failed.
  • Full suite: CI on this head runs the suite in four shards: 23742
    passed, 117 skipped, 0 failed. A local full run reports one failure,
    tests/test_install_script.py::test_resolve_node_dir_answers_each_case_it_exists_for,
    which was run again in a clean tree extracted from the default branch at the
    commit this branch is based on and fails there with the same assertion and the
    same value; the CI runner, which is not root, passes it. 0 introduced either way. The one failure is
    tests/test_install_script.py::test_resolve_node_dir_answers_each_case_it_exists_for.
    It was run again in a clean tree extracted from the default branch at the commit
    this branch is based on, and fails there with the same assertion and the same
    value. 0 introduced.
  • Diff coverage against the default branch: Diff coverage: 93.94% (31/33 executable changed lines),
    measured by the coverage gates job on this head. The ratchet moved with it:
    line +1.81pp, branch +2.74pp. The remaining uncovered pair is
    the same teardown inside gateway_commands.run, a 634-line function whose harness
    would cost far more than the one in agent_commands.
  • make lint-python, make lint-imports, make lint-types -- all exit 0.
  • make check-commits, make check-large-files, make check-source-language --
    all exit 0.
  • The rebase was checked with git range-diff: every pre-existing commit replays
    =, and none of the commits the base gained touches a file this branch touches, so
    the changed-line set the coverage gate measures is unchanged.

Neither round's tests are theatre, and each was checked on its own:

  • First round: the tests were watched failing on the missing attribute and the
    missing function. Two mutants were then run against them -- moving the in-flight
    read below the collect await reddens two of them, which is the constraint the
    comment beside that read now names; moving it below cancel() alone leaves them
    green, correctly, because cancel() only schedules.
  • Second round: the notice test was watched failing on the delivery claim before the
    wording changed, and the end-to-end test was watched failing on the log line.
    Removing the report_memory_write_outcome call from the agent teardown turns the
    new CLI test red on its stdout assertion; restoring it turns it green with
    git diff HEAD empty.

One thing that would have gone unnoticed: capsys captures nothing from loguru here,
because the sink holds the real sys.stderr from when the handler was added, so the
first version of the end-to-end test passed while reading an empty string. It adds
its own sink now and asserts the sink saw something, so the log half cannot pass
unread.

  • Relevant tests pass locally
  • Relevant lint / type checks pass locally
  • User-facing docs or screenshots are updated when needed

No docs change: the only user-facing text is the shutdown notice itself, and no
document quotes it.

Risk

User-visible change is one line of CLI output at exit, and it now states an unknown
outcome rather than a good one. A user who reads it has nothing to redo -- re-sending
the turn could duplicate a write the service did complete -- so the line is
informational by design, not an alarm.

The exit path is otherwise untouched: the budget constant is byte-identical to the
one on the default branch, and the diff of that file contains no non-comment line, so
exit timing does not move.

drain_backend_stores changes its return type, which is internal. Five callers:
three render the notice and are updated here, two await it and ignore the value as
they already did.

Rollback is a revert of the five commits on this branch. Nothing persists and no
format changes. Reverting only the last two would restore the delivery claim while
keeping the split, which is the state review rejected, so revert all or none.

Checked and deliberately not changed:

  • An existing assertion changed meaning and is called out rather than left in the
    diff. The test pinning the wording "still finishing in the background", whose
    docstring stated the write had reached the service, is renamed and rewritten; its
    two negative assertions were right and are kept, and a new sibling pins the other
    direction.
  • The memory plugin still prints its own final-flush sweep hit its 5.0s budget line
    to stderr at shutdown. It belongs to a different layer and is factually correct;
    folding it into this notice would widen the diff into the plugin.
  • The same teardown in gateway_commands stays uncovered. Its enclosing function is
    634 lines against 60 in agent_commands, and the gate passes without it.
  • No domain term is coined. StorePipeline appears in CONTEXT.md only as a passing
    mention with no entry of its own, and DrainOutcome is a module-local type rather
    than a concept the map tracks.

Related Issues

N/A

@LivXue
LivXue requested review from 0xKT and gloryfromca September 20, 2026 07:54

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Blocking: a backend call can be cancelled before persistence but is reported as having reached long-term memory.

I found one concrete blocker, marked inline. I covered the full diff; all drain_backend_stores callers; the generic MemoryBackend contract and bundled EverOS path; cancellation, retry, repeated-drain, and shutdown accounting; branch history; backward compatibility; the changed assertions for weakening; and the repository's AGENTS/CLAUDE and runtime-context architecture rules. I found no separate issue in those areas.

Verification: the first focused run without optional extras stopped during collection because raven_everos was not installed. Re-running through the declared project setup, uv run --frozen --all-extras pytest tests/test_agent_loop_backend_dispatch.py tests/test_providers_factory.py tests/test_generation_swap.py tests/test_agent_loop_everos_pipeline.py tests/test_rpc_bootstrap.py tests/test_core_runtime_swap.py tests/test_cli_tui_commands.py tests/test_everos_backend.py tests/test_agent_loop_run_emit.py tests/test_memory_engine_face.py -x, passed all 402 tests. A minimal compliant cancellable backend reproduced the blocker as DrainOutcome(lost=0, in_flight=1) with no persisted record.

Comment thread raven/memory_engine/store_pipeline.py
LivXue and others added 5 commits September 20, 2026 09:39
At teardown the store pipeline counted every unsettled record as lost,
including the one a worker had already handed to the memory service.
Cancelling that worker does not cancel its request, so the service
finished the write anyway: on three one-shot runs the extraction landed
32 to 48 seconds after the host had told the user the turn was gone.

drain() now returns DrainOutcome(lost, in_flight). A record is marked in
flight around the backend call, and the mark is read before the workers
are cancelled, because the cancellation unwinds that call and its finally
clears exactly what is being counted. The host renders the two apart, so
a handed-over turn reads "indexing is still finishing in the background"
instead of blaming a service that answered.

report_dropped_memory_writes is renamed report_memory_write_outcome: it
no longer reports only what was dropped.

The 2.0s drain budget is unchanged, and the reasoning now sits beside the
constant. It is far shorter than the write it waits on -- one store
measured 16.98s end to end, of which the flush leg was 12.1s -- and
sizing it to cover that would park every CLI exit for as long while
buying nothing, since the service completes the request either way and
nothing needs to recall the turn that just ended.

Co-authored-by: Claude (claude-opus-5[1m]) <noreply@anthropic.com>
The comment said the count is taken before the cancellation because the
cancellation clears the marks. A mutation moving the read below
task.cancel() left every test green: cancel() only schedules, so nothing
is unwound until this coroutine yields, and the collect await below is
where the finally actually runs. Moving the read below that await does
redden two tests, so the constraint is real and the comment named the
wrong boundary for it.

Co-authored-by: Claude (claude-opus-5[1m]) <noreply@anthropic.com>
The two new imports reached raven.memory_engine.store_pipeline directly,
which test_memory_engine_face pins against for everything outside the
engine. lint-imports keeps its ten contracts either way, so the full
suite was the only gate that saw it.

DrainOutcome joins __all__, the TYPE_CHECKING block and the lazy map
beside StorePipeline, and both call sites plus the notice tests now
import it from the face.

Co-authored-by: Claude (claude-opus-5[1m]) <noreply@anthropic.com>
The split this branch added reports a record that was inside
MemoryBackend.store() when the drain cancelled apart from one that never
left the queue. Entering that call is not delivery, so the three surfaces
reporting it claimed more than this side can see.

Measured: a backend that persists only after an await, cancelled
mid-await, leaves DrainOutcome(lost=0, in_flight=1) while the backend's
persistence list stays empty. The shutdown notice then told the user the
turn had reached long-term memory. A slow connection cancelled before its
request body goes out has the same shape.

The set itself is sound and stays. "Was inside the backend call" is
computable, and an outcome nobody observed is neither a loss nor a win.
What changes is every claim derived from it: the shutdown notice, the
teardown log, and four docstrings that said the service had already been
handed the record. The EverOS measurement that motivated the split is
kept and scoped to the case it actually measured.

in_flight keeps its name. Across this repo the word already means started
and not settled, as in swap_in_flight, outbound.in_flight and a DAG node
in flight, and it carries no delivery claim. The delivery claim was in
the prose.

One existing assertion changed meaning. The test pinning the wording
"still finishing in the background", whose docstring stated the write had
reached the service, is renamed and rewritten; its two negative
assertions were right and are kept. A new sibling pins the other
direction, so the notice is now held to the only honest claim from both
sides.

Co-authored-by: Claude (claude-opus-5[1m]) <noreply@anthropic.com>
The raven agent teardown drains the memory backend and renders what the
drain left, and no test reached those two lines: the shared harness stubs
maybe_build_memory_backend to None, so the backend branch guarding them
was never true. The same teardown in tui_commands has its own coverage;
this copy had none, so dropping the render would have cost nothing red.

The harness now takes an optional backend and drain outcome, both
defaulting to the previous behaviour, leaving the thirty-three tests
already using it unchanged.

Proved load-bearing rather than assumed: removing the
report_memory_write_outcome call turns the new test red on its stdout
assertion, and restoring it turns it green with git diff HEAD empty.

Co-authored-by: Claude (claude-opus-5[1m]) <noreply@anthropic.com>
@LivXue
LivXue force-pushed the fix/memory_write_outcome_report branch from ad8392e to 9e11dd4 Compare September 20, 2026 09:40

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

No blockers; this can merge as far as I am concerned.

The new revision fixes my prior blocker by reporting a store call that outlives the drain as unsettled, without claiming either loss or delivery. The cancellable-backend reproduction now protects that boundary, and the added one-shot CLI test reaches the host rendering path that was previously unexercised. I found no new findings.

I reviewed the rebased full diff and its new commits; all drain callers; store cancellation and accounting; the generic backend contract and EverOS behavior; branch history and backward compatibility; the changed assertions for weakening; and the AGENTS/CLAUDE, runtime-context, and memory-engine-face constraints. The focused suite passed: uv run --frozen --all-extras pytest tests/test_agent_loop_backend_dispatch.py tests/test_providers_factory.py tests/test_cli_agent_commands.py tests/test_generation_swap.py tests/test_agent_loop_everos_pipeline.py tests/test_rpc_bootstrap.py tests/test_core_runtime_swap.py tests/test_cli_tui_commands.py tests/test_everos_backend.py tests/test_agent_loop_run_emit.py tests/test_memory_engine_face.py -x (438 passed). My prior thread is replied to and resolved.

@0xKT

0xKT commented Sep 20, 2026

Copy link
Copy Markdown
Member

Not a blocker. The split is the right call and it is pinned: reverting
max(0, abandoned - in_flight) to the old self._dropped += abandoned turns 5
tests red, moving the in_flight snapshot after the collect await turns 4 red,
and deleting either rendered sentence in cli/_helpers.py turns 2 red. Three
observations, all latent or inherited. Read on head 9e11dd4 against base
0cb8b79.

1. The subtraction reads its two operands on opposite sides of an await

In store_pipeline.py, in_flight is measured at :300, task.cancel() runs at
:302, await asyncio.wait(leftover, ...) at :310, and abandoned at :319. The
snapshot and the thing it is subtracted from are therefore taken before and
after a real yield point. Any worker that reaches self._active.pop(...) at
:258 inside that window shrinks abandoned while in_flight still counts it,
and the surplus comes off some other session's genuine loss.

Reaching it needs a worker that survives the cancel, and the design deliberately
prevents that for the backend that ships: :258 sits in the while body rather
than a finally -- the comment above it says so, "A cancelled worker leaves its
entry behind on purpose, so the drain can count it" -- and raven_everos's
store has no except CancelledError, no except BaseException and no
asyncio.shield anywhere (the only hit for those three in the whole package is
a type annotation at backend.py:672). So nothing in tree can produce it today.
A third-party MemoryBackend can: raven/contracts/memory.py says nothing
about cancellation, so a backend that swallows it is contract-conforming.

One correction to anyone acting on this: max(0, ...) is not the bug and
removing it makes things worse.
In the reproduction abandoned is 1 and
in_flight is 4, so without the clamp the line subtracts 3 from a running
_dropped total and erases losses already counted earlier in the session. The
clamp is protective; the skew between :300 and :319 is the thing.

2. Of the three render sites this PR rewired, one is driven by nothing

tests/test_cli_gateway_commands.py contains zero mentions of
drain_backend_stores, report_memory_write_outcome, or the new wording --
deleting report_memory_write_outcome(outcome, console) at
gateway_commands.py:1161 leaves the suite green. The control says the
selection really does reach CLI render sites: the same deletion at
agent_commands.py:371 turns
test_a_one_shot_shutdown_tells_the_user_what_the_drain_left red, and
test_cli_agent_commands.py:177 is where that path is stubbed.

Base has the same shape, so this is inherited rather than introduced, and it is
not why the PR should change. It is worth one line because the PR is rewiring
exactly that call and adding the test that covers its sibling.

3. One phenomenon, two different measurements

raven/agent/loop/_shared.py:93 says "one extraction landed 32s after the
drain gave up". raven/memory_engine/store_pipeline.py:68 says an extraction
"was measured landing 34-48s after the drain gave up". Both are written in
the grammar of measurement and both are the evidence for the same claim -- that
the service finishes whether or not this side is listening. Nothing in the tree
says whether that is one run described twice or two separate runs, so a later
reader cannot tell which number is the observation. These comments are precisely
the record that stops the next person re-litigating the drain budget, so it is
worth making the two agree.

@gloryfromca

Copy link
Copy Markdown
Member

No blockers; suggestions only, and they are marked inline.

The verifier note changes my stance from clean to nonblocking, not the merge decision. I reproduced observation 1 with a contract-conforming backend that suppresses cancellation: after four calls were snapshotted in flight, three workers settled during the collect await, and the drain returned DrainOutcome(lost=0, in_flight=4) while one genuinely queued record remained. The skew is real, but reaching it requires a third-party backend that suppresses cancellation, saturated store concurrency, and a queued record, so it belongs in follow-up work under the stated reachability bar.

Observation 2 is an inherited gateway-test coverage gap, and observation 3 is a prose measurement inconsistency; neither is a plain error that should hold this change. The focused shutdown and CLI suite remains green: uv run --frozen --all-extras pytest tests/test_agent_loop_backend_dispatch.py tests/test_providers_factory.py tests/test_cli_agent_commands.py tests/test_cli_gateway_commands.py -x (169 passed).

@0xKT
0xKT merged commit 2bc761b into main Sep 21, 2026
28 checks passed
@0xKT
0xKT deleted the fix/memory_write_outcome_report branch September 21, 2026 05:56
gloryfromca added a commit that referenced this pull request Oct 10, 2026
## Summary

With a `MemoryBackend` plugin wired, memory lives in the plugin's store
and reaches the
turn as the `# Memory` section. The identity block still named two files
under agent home,
so "remember this" became an `edit_file` against
`user_memory/profile/user.md`. On a
non-interactive turn that write is refused at the ask tier, and the
agent reported the
refusal as the memory system having failed -- while the backend had
already stored the
turn and recalled it in the next session.

`identity_text` takes a `has_memory_backend` flag, threaded from the one
place each caller
already holds the backend: `build_context_engine`, and `AgentLoop` for
the
token-estimation prompt in `ContextBuilder`, which must render what the
turn renders.

With a backend the two file lines become one:

```
- Agent home: <home> -- your own skills, not a place for user artifacts.
  - Memory is not yours to write: what is relevant is recalled into the `# Memory`
    section each turn, and the host persists the conversation after it ends. You
    cannot observe that write, so never report it to the user as done or as failed.
  - Custom skills: <home>/skills/{skill-name}/SKILL.md
```

Three clauses, three jobs. Do not write it. It is being saved, so stop
looking for a place
to put it. You cannot see that write, so do not narrate it.

The last clause is the other half of the same defect, and the third item
issue 440 asks to
separate. `backend.store` is dispatched after the turn ends, so a claim
either way is one
the agent never checked -- and whatever it claims is extracted into
long-term memory next
turn as if it were an observation of the system. The issue's own repro
produced exactly
that: an episode titled `Memory Save Failure for Green Brompton Bike
'Pickle' Noted on
2026-09-16`, whose content was right and whose narrative was wrong.
Filtering that
server-side is not available from here, so the prompt is where it is
addressed.

Without a backend the rendered prompt is unchanged, byte for byte.
Whether the episodic
log earns its line at all is issue 122's question, and answering it on
this branch would
couple the two.

The approach is AmirF194's, from the closed PR #216. That branch cannot
be reopened: it
predates the history linearization, so it shares no merge base with
`main`
(`git merge-tree` refuses unrelated histories), its six files no longer
apply, and it did
not allow maintainer edits. It is reimplemented here against current
`main`, with the
author credited as co-author on the commit.

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

Run on this branch's head, rebased onto `main` at c3d2821, Python
3.12, `--extra dev`.

- `pytest` over the 17 test files that construct `ContextBuilder`,
`IdentitySegmentBuilder`,
`build_context_engine` or call `identity_text` (the `git grep -l` list):
756 passed, 0 failed.
  `SERPER_API_KEY` is unset for the run; with it set,

`test_agents_code_launcher.py::test_the_products_tool_face_is_the_forks_config_intent_minus_the_ledger`
fails on an extra `web_search` in the tool face, on this branch and on
the base alike.
- The three new tests were each watched failing without the change, by
neutering the
behaviour in place rather than the signature, then restoring the file
and confirming it
  byte-identical by checksum:
- forcing the backend branch off reddens
`test_identity_with_a_backend_names_no_memory_file`;
  - dropping the flag from `ContextBuilder._get_identity` reddens
    `test_identity_with_a_backend_matches_the_estimation_prompt`;
- `test_identity_without_a_backend_still_names_both_memory_files` pins
the unchanged half.
- The no-backend claim is measured, not argued: the whole identity block
rendered from the
base and from this head is 3341 bytes on both sides and `diff` is empty.
- `make lint-python`, `make lint-types`, `make lint-imports`, `make
check-large-files` -- all exit 0.
- `RANGE=github/main..HEAD scripts/check_source_language.py` -- exit 0.
- `commitlint --config commitlint.config.cjs` on the commit body and on
the prospective
  squash header -- both exit 0.

Driven against a live EverOS after this description was first written.
All three
symptoms issue 440 describes reproduce on the merge base and none
reproduce here: on
`main` turn 1 calls `read_file` then `edit_file` on
`user_memory/profile/user.md` and
grows it to 897 bytes, at the ask tier that write is refused and the
agent reports the
save as failed, and EverOS extracts that false report into four durable
memory entries.
On this branch turn 1 makes no tool call, agent home is untouched, and
the same grep over
the store returns nothing. Both sides recall the fact in a fresh
session. Full method,
verbatim output and teardown in a comment on this PR.

One run per cell, not a distribution. The host still dumps `user.md`
into `# Memory` with
a backend wired, which is unchanged here and out of scope below.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

No docs change: the only user-facing text is the system prompt itself,
and no document
quotes this block.

## Risk

User-visible change is confined to installs that have a memory backend
wired. There, three
prompt lines become three different ones; nothing else in the prompt,
and no behaviour
outside prompt assembly, moves. Installs without a backend render a
byte-identical prompt,
which is the measurement above.

`has_memory_backend` defaults to `False` on all four signatures it is
added to, so every
call site not updated here renders what it rendered before. The three
updated call sites
are the only ones that hold a backend.

Rollback is a revert of the single commit. Nothing persists and no
format changes.

Checked and deliberately not changed:

- `MemorySegmentBuilder` still calls `get_memory_context()`
unconditionally, so `user.md`
content reaches `# Memory` with a backend wired. Turning that off is the
question of
whether two memory mechanisms should coexist, which is larger than this
issue and which
  four comparable systems answer by keeping both.
- The episodic log line, under the default no-backend configuration. It
belongs to issue 122.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

## Related Issues

Fixes #440 -- items 1 and 3 of the three the issue asks to separate,
both reproduced on
the merge base and verified gone here. Item 2 landed in #544.

#122 -- untouched, deliberately.

---------

Co-authored-by: gloryfromca <23442919+gloryfromca@users.noreply.github.com>
Co-authored-by: Amir Fathi <amirfathi.me@gmail.com>
Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
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