Skip to content

fix(gateway): flush a spooled pending message under the session store's key so a restart can replay it - #136600

Closed
PRELVAS94 wants to merge 5 commits into
NousResearch:mainfrom
PRELVAS94:fix/shutdown-flush-store-key
Closed

PRELVAS94 wants to merge 5 commits into
NousResearch:mainfrom
PRELVAS94:fix/shutdown-flush-store-key

Conversation

@PRELVAS94

@PRELVAS94 PRELVAS94 commented Oct 11, 2026 •

Copy link
Copy Markdown

What does this PR do?

A restart could not replay a pending message spooled at shutdown whenever the platform that queued it derived its session key from different per-user isolation flags than the session store routes by. Recovery logs Cannot recover pending message for <key>: no session_id in flush file and session_key-to-id resolution failed on every boot and leaves the payload in <hermes_home>/pending_messages/; the message is never delivered.

_pending_messages slots are keyed by the adapter's own isolation flags (PlatformConfig.extra), while SessionStore routes by the gateway-wide group_sessions_per_user / thread_sessions_per_user. PlatformConfig.from_dict promotes every non-typed platform-block key into extra (gateway/config.py:452), and gateway/run_adapters.py:1890 only setdefaults the gateway-wide value into it, so one platform can legitimately key a conversation differently from the store. A standalone gateway hands that same key to the busy handler (gateway/run_adapters.py:1783, _standalone_scoped), so the queue head and the FIFO tail inherit it; _enqueue_fifo then writes into the adapter's _pending_messages under it (gateway/run_busy.py:105). recover_pending_to_db resolves a payload's key through the store alone — routing map first, then the durable row under the exact key — so a slot key written verbatim matched neither, and the file was preserved forever.

The fix records the key the store would route the payload by, instead of the slot key. It is deliberately the narrow write-side change: the wider adapter-vs-store key split (same conversation, two keys for batching/busy purposes) is left as it is.

Related Issue

Fixes #136189

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)

Changes Made

  • gateway/shutdown_flush.py: flush_pending_to_file and flush_overflow_to_file take an optional session_key_for callable that maps a payload's source to the store's key for that source; _recoverable_key applies it per payload. A value without a source (a raw str slot, an agent-history payload), an absent callable, a derivation that raises, or a non-str result all keep the slot key — i.e. the exact previous behaviour. adapter_store_key_for(adapter) returns an adapter's session-store derivation or None.
  • gateway/platforms/base.py:4796: the adapter shutdown flush (cancel_background_tasks) passes adapter_store_key_for(self).
  • gateway/run_shutdown.py:2013: stop() passes self._session_key_for_source to both flushes.
  • tests/gateway/test_shutdown_flush_store_key.py: new tests — the two derivations disagreeing for one source; the adapter flush recording the store key; an adapter-keyed spool replayed into the store session on the next boot (1 row appended, file removed); the runner shutdown path; and the fallback contract (no source, failing derivation, non-str result — the last is test_a_derivation_result_that_names_no_session_keeps_the_slot_key, parametrised over "", 0, None and a list; all four fail when the guard is reduced to return key).
  • evals/shutdown_flush_adapter_key_strand.py: standalone repro, VERDICT=STRANDED before / VERDICT=RECOVERED after.

How to Test

  1. python evals/shutdown_flush_adapter_key_strand.py — on the unpatched tree it prints spooled_keys=['agent:main:telegram:group:900:900:u1'], recovered=0, spool_remaining=1, VERDICT=STRANDED, plus the production warning. On this branch: spooled_keys=['agent:main:telegram:group:900:900'], recovered=1, replayed_content=['queued while busy'], spool_remaining=0, VERDICT=RECOVERED.
  2. HERMES_PYTHON=<python-with-pytest> scripts/run_tests.sh tests/gateway/test_shutdown_flush_store_key.py → === Summary: 1 files, 10 tests passed, 0 failed (100% complete) in 12.6s (8 workers) === (run on current main 97a9ae8ee5 with this commit applied; the shutdown-flush cluster, 10 files, is === Summary: 10 files, 48 tests passed, 0 failed (100% complete) in 48.0s (8 workers) ===)
  3. HERMES_PYTHON=<python-with-pytest> scripts/run_tests.sh <every test file referencing cancel_background_tasks / _pending_messages / flush_pending_to_file / flush_overflow_to_file / recover_pending_to_db / recover_gateway_pending> (113 files) → === Summary: 113 files, 932 tests passed, 0 failed, 3 skipped (100% complete) in 51.4s (8 workers) ===
  4. python scripts/check → scripts/check: 11 checks, ok (re-run on current main 97a9ae8ee5 with this commit applied, after the review follow-up on 5c3350a9b9fffb43ec90ca91eca97c723eeaca12; before it, python scripts/check --only ruff on the same tree reported Found 7 errors / scripts/check: 1 checks, FAILED: ruff — 5 × RUF100 Unused noqa directive (non-enabled: E402) and 2 × F841)

Checklist

Code

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — docstrings only; N/A otherwise
  • I've updated cli-config.yaml.example if I added/changed config keys — N/A
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — pure str/dict handling, no path or process APIs; the tests are host-agnostic
  • I've updated tool descriptions/schemas if I changed tool behavior — N/A

Screenshots / Logs

Unpatched (the production warning, then the stranded spool):

Cannot recover pending message for agent:main:telegram:group:900:900:u1: no session_id in flush file and session_key-to-id resolution failed. The message text is preserved in <tmp>/pending_messages/pending-33ec903ee67f4d79a4d9d70b7b253dd7.json
slot_key=agent:main:telegram:group:900:900:u1
store_key=agent:main:telegram:group:900:900
spooled_keys=['agent:main:telegram:group:900:900:u1']
recovered=0
replayed_content=[]
spool_remaining=1
VERDICT=STRANDED

This branch:

slot_key=agent:main:telegram:group:900:900:u1
store_key=agent:main:telegram:group:900:900
spooled_keys=['agent:main:telegram:group:900:900']
recovered=1
replayed_content=['queued while busy']
spool_remaining=0
VERDICT=RECOVERED

@alt-glitch alt-glitch added type/bug Something isn't working P1 High — major feature broken, no workaround comp/gateway Gateway runner, session dispatch, delivery area/sessions Session lifecycle, resume, persistence, history sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages labels Oct 11, 2026
@PRELVAS94

Copy link
Copy Markdown
Author

Ready for review — needs the draft flag cleared

This PR is filed as a draft because that is the only way our account can open a PR against this
repo: POST /repos/NousResearch/hermes-agent/pulls returns 201 with "draft": true and 404
with draft: false or the flag omitted (outside contributor: push: false). We also cannot flip it
ourselves — REST PATCH {"draft": false} returns 200 with the flag unchanged, and GraphQL
markPullRequestReadyForReview returns FORBIDDEN.

So the only missing step is yours: please mark this PR ready for review (Settings → "Ready for
review", or re-open it as non-draft). Nothing is pending on our side — it is complete:

If a draft PR never surfaces in your review queue, that is the whole reason this one looks untouched:
0 comments, 0 review comments and 0 check-runs at the time of writing (first-time-contributor CI also
needs a maintainer to approve the run).

@ehz0ah ehz0ah left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Request changes conclusion (formal review permission unavailable)

I reviewed 9f2ba1000b7052a2146e03f03689b4f4aa4206c4 against #136189 and current main.

The fix is aimed at the right boundary. gateway/shutdown_flush.py converts each queued event to the session store key before writing the recovery spool. The adapter and runner shutdown paths both pass the correct key resolver. Values without a source and derivation failures keep the old slot key. The new tests cover adapter shutdown, runner shutdown, restart recovery, and the fallback paths. The standalone probe reports STRANDED on the parent and RECOVERED with this patch.

I also applied this commit cleanly to current main at 97a9ae8ee55d31bebce49f1fb6b5ef775323976a. All 33 focused shutdown and recovery tests passed there, and the restart probe recovered the message.

One merge blocker remains. Current main lint rejects seven lines added by this PR. python scripts/check reports five unused # noqa: E402 comments in evals/shutdown_flush_adapter_key_strand.py, plus the unused _store_ bindings at lines 103 and 131 in tests/gateway/test_shutdown_flush_store_key.py. Please remove the stale suppressions and use _ for those unused tuple items.

The PR description also says the fallback tests cover a non-string key result, but this exact head has no such test. Please add that case or correct the description.

The broader affected-symbol run completed with 927 passes, 7 skips, and one unrelated update-command test failure. The same failure reproduces on the PR parent, so it is not caused by this change.

English verdict: Request changes on this exact head because it does not pass the current main lint gate. The shutdown recovery behavior itself is correct in the cases I verified.

A restart could not replay a spooled pending message when the platform that queued
it derived its session key from different per-user isolation flags than the session
store routes by, so the message was stranded in the spool forever.

Symptom: `Cannot recover pending message for <key>: no session_id in flush file and
session_key-to-id resolution failed` on every boot, with the payload left in
`<hermes_home>/pending_messages/`, and the message never delivered.

Root cause: `_pending_messages` slots are keyed by the adapter's own isolation flags
(`PlatformConfig.extra`), while `SessionStore` routes by the gateway-wide
`group_sessions_per_user` / `thread_sessions_per_user`. `PlatformConfig.from_dict`
promotes a platform-block override into `extra` and `gateway/run_adapters.py` only
`setdefault`s the gateway-wide value, so one platform legitimately keys a
conversation differently from the store; a standalone gateway then hands that same
key to the busy handler, so the queue head and the FIFO tail inherit it.
`recover_pending_to_db` resolves a payload's key through the store alone — routing
map first, then the durable row under the exact key — so a slot key written
verbatim matched neither.

Fix: `flush_pending_to_file` / `flush_overflow_to_file` take an optional
`session_key_for` callable that maps a payload's `source` to the key the store
routes it by, and record that key instead of the slot key. A value with no `source`
(a raw str slot, an agent-history payload) and any derivation failure keep the slot
key, so the fallback is exactly the previous behaviour. The adapter shutdown flush
passes its session store's derivation (`adapter_store_key_for`); the runner's
shutdown flush passes `_session_key_for_source`.

Tests: tests/gateway/test_shutdown_flush_store_key.py drives the real entry points
(`adapter.cancel_background_tasks()`, `runner.stop()`) and the real recovery pass
with the two derivations disagreeing. evals/shutdown_flush_adapter_key_strand.py is
the standalone repro: VERDICT=STRANDED on the unpatched tree, VERDICT=RECOVERED
after the fix.

Review follow-up (ehz0ah, #136600): the files were clean under the lint config this
branch was cut from, but main's `ruff.toml` enables RUF100 and F841 and the branch
tripped seven lines there. Dropped the five `# noqa: E402` directives in the eval —
E402 is not in the enabled rule set, so they suppressed nothing and RUF100 flagged
them as unused — and used `_` for the two unused `_store_` tuple bindings in the
tests. Added the fallback case the description claimed but the tests lacked:
`test_a_derivation_result_that_names_no_session_keeps_the_slot_key`, parametrised
over an empty string, `0`, `None` and a list, so a derivation that names no session
cannot record a payload the next boot's resolution cannot look up.
@PRELVAS94
PRELVAS94 force-pushed the fix/shutdown-flush-store-key branch from 9f2ba10 to 5c3350a Compare October 11, 2026 14:31
@PRELVAS94

PRELVAS94 commented Oct 11, 2026 •

Copy link
Copy Markdown
Author

Both points reproduce on this head against current main, and both are fixed in 5c3350a9b9fffb43ec90ca91eca97c723eeaca12 (previous head 9f2ba1000b). Relative to 9f2ba1000b only two files differ — the five noqa directives in the eval and the test file — so gateway/ is byte-identical to what you reviewed.

Lint — confirmed exactly as you describe. On main@97a9ae8ee5 with this commit applied, python scripts/check --only ruff:

RUF100 [*] Unused `noqa` directive (non-enabled: `E402`)  evals/shutdown_flush_adapter_key_strand.py:32:69 … :36:59
RUF059 Unpacked variable `_store_` is never used  tests/gateway/test_shutdown_flush_store_key.py:103:23, :131:22
Found 7 errors.
scripts/check: 1 checks, FAILED: ruff

The five directives suppressed nothing — E402 is not in the enabled rule set (ruff.toml selects only E722 and E902 among the E codes), which is why ruff reports them as unused — so I removed them rather than moving them. Both _store_ tuple items are now _; you are right that a trailing underscore is not in ruff's dummy-variable pattern. After the change, same tree: code health: 5 files vs 97a9ae8ee55d: 0 blocking, 0 advisory / scripts/check: 11 checks, ok.

Description/test gap — also real. The fallback contract as written claimed a non-str result; this head covered only a slot without a source and a derivation that raises. Added test_a_derivation_result_that_names_no_session_keeps_the_slot_key, parametrised over "", 0, None and a list (the empty-string arm of the same guard included). Negative control: with the guard in _recoverable_key reduced to return key, all four cases fail (4 failed, 6 deselected), so the test holds the behaviour rather than passing vacuously. Re-run on main@97a9ae8ee5 + this commit: === Summary: 1 files, 10 tests passed, 0 failed (100% complete) in 12.6s (8 workers) ===; the shutdown-flush cluster (10 files) is === Summary: 10 files, 48 tests passed, 0 failed (100% complete) in 48.0s (8 workers) ===.

Unchanged behaviour, re-verified today. The repro prints VERDICT=STRANDED on 97a9ae8ee5 unpatched and VERDICT=RECOVERED with this commit applied, both run on this box today. gateway/shutdown_flush.py, gateway/platforms/base.py and gateway/run_shutdown.py are byte-identical to what you reviewed.

One note on my own numbers, so nothing here is read as stronger than it is: my re-run of the affected-symbol sweep (116 files) was cut short at ~35% because this host sat at load ~10 — I am not claiming a fresh count for it, and your 927-pass run is the reference for that sweep. The two failures it did reach are a cross-session approval-queue timing assert and a compression-session test that hit a 120s API-stream timeout, in files that use _pending_messages only as runner state.

@JoaoMarcos44 JoaoMarcos44 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Independent Ledger-backed review of the current head

Reviewed head: 5c3350a9b9fffb43ec90ca91eca97c723eeaca12. Captured current main: 97a9ae8ee55d31bebce49f1fb6b5ef775323976a. PR comparison merge base: f86276d2ebcccf0530873c11e2965c3f1cbd5a31. Ledger run: run_2ddd83b7a18c4fe6b02e0b635e4bcaa7.

Verdict and priority boundary

The P1-labelled issue #136189 is real. I reproduced its adapter/store key mismatch on the merge base and on captured current main, and verified that this head repairs the tested ordinary pending-text paths. I did not substantiate a newly introduced P0/P1 defect or an officially in-scope security vulnerability in this patch.

I did reproduce three conditional, inherited data-integrity failures, documented below with the complete reviewer probe. They remain on both main and this head; they must not be presented as regressions introduced by this contributor. Their fault-mode severity needs maintainer prioritization; these experiments do not justify inventing a new P0/P1 blocker. This is a COMMENT review, not a blocking review decision or a claim that the entire repository is green.

Scope, mechanism, and ownership

I inspected all five PR-owned files: gateway/shutdown_flush.py, gateway/platforms/base.py, gateway/run_shutdown.py, tests/gateway/test_shutdown_flush_store_key.py, and evals/shutdown_flush_adapter_key_strand.py, plus retention, canonical identity, session recovery, profile routing, and startup readers.

Adapter busy/batching slots derive isolation from PlatformConfig.extra (base.py:2557-2567), while the durable store derives it from gateway configuration (session_recovery.py:96-103). The two legal configurations can add or remove a user suffix independently. Recovery searches the exact routing/durable key and the owning DB, not an arbitrary same-chat row (session_recovery.py:196-243). Writing a slot key unchanged can therefore preserve an unresolvable file indefinitely.

The patch places conversion at the shared derived-write boundary (shutdown_flush.py:90-168) and wires adapter shutdown and runner head/overflow shutdown (base.py:4795-4801, run_shutdown.py:2011-2019). It leaves adapter isolation semantics and recovery's existing profile/reset fences intact. Keyless raw strings and unavailable/throwing/empty/non-string derivations retain the old slot key. That fallback preserves the payload, not a guarantee that the key is recoverable.

The author's eval and tests use a fake DB and direct pending-slot injection. I executed them, but do not call them platform/provider E2E. My additional normal probe uses real SessionStore/SQLite, production _enqueue_fifo retention (including its acceptance flag), actual shutdown/spooling, close/reopen, the production resolver, persisted content/order, and a second recovery pass. It is a retention-to-durable-reader contract, not a complete transport authorization/ACK/LLM-delivery test.

Confirmed inherited findings

  1. A failed spool write can erase the last pending text copy.

    • Head locations: shutdown_flush.py:77-87, base.py:4795-4803; runner sibling run_shutdown.py:2011-2024 also ignores the best-effort count before clearing.
    • _flush_value catches the write failure and returns False. Adapter shutdown still clears _pending_messages without requiring a successful durable copy.
    • Reproduction: genuinely retain one text event, inject OSError(ENOSPC) at _write_payload, then call cancel_background_tasks. Observed: pending slot empty, zero spool JSON files, zero SQLite messages. The preservation assertion fails on both head and main.
    • Boundary: a modeled write failure, not an exhausted host disk or complete network ACK reproduction. The proof is loss of the retained text copy; it does not establish any additional external side effect.
    • Follow-up direction: distinguish attempted backup from successful durable ownership, make failures observable, and decide a bounded retry/alternative retention/shutdown policy. Keeping only memory is not crash durability; a retry is not a solution to a permanently unavailable disk. No implementation is proved here.
  2. Append success followed by unlink failure duplicates the user row after a real process restart.

    • Head locations: _remove_replayed, shutdown_flush.py:311-320, and replay at 403-407.
    • _REPLAYED_UNREMOVABLE correctly suppresses repeat replay in the same process, but there is no durable replay receipt. The existing docstring explicitly describes the next-process duplication.
    • Reproduction: real SQLite append, selective PermissionError from the spool file's unlink, verify same-process second recovery is zero, close handles, then launch a fresh Python interpreter against the same temporary home and real reader. Observed: recovered=1, contents ['fault-probe', 'fault-probe'].
    • Same failure on head and main. This proves duplicate persisted user rows, not repeated tools, agent turns, or destructive actions. The unlink error is injected; native Windows ACL behavior and kill-between-commit/unlink are not separately exercised.
    • Follow-up direction: a stable spool identity and replay receipt committed atomically with the row, using existing durable infrastructure where possible. Content-based deduplication would incorrectly collapse legitimate repeated messages; an in-memory set or post-commit file marker cannot provide exactly-once crash recovery. No repair has been implemented or verified.
  3. Ordinary pending-message recovery can permanently invert FIFO after one failed head append.

    • Head locations: shutdown_flush.py:395-410, 430-456, 457-484.
    • The per-session blocked_sessions fence covers TRANSCRIPT_CAP_DROP_REASON, but the ordinary pending-text arm neither marks nor consults it after an append failure. The outer loop logs the failed head and proceeds to the tail.
    • Reproduction: retain head fault-probe and tail fault-tail via _enqueue_fifo, run actual runner.stop, reopen SQLite, inject one terminal sqlite3.OperationalError only for the head append, then perform two recovery passes. Observed durable row order: ['fault-tail', 'fault-probe'] instead of arrival order.
    • Same failure on head and main. This is not merely a filename-order concern: row-ID order remains wrong after the failed message is successfully retried. It models one exhausted transient append failure, not native SQLite contention or an executed-command consequence.
    • Follow-up direction: consider a shared failure-order fence across the relevant payload classes without blocking unrelated sessions. Permanent row rejection/quarantine, profile ownership, and normal replay require separate controls before recommending a patch.

Security assessment

I inspected canonical identity at ingress, topic-source rewriting, profile-key generation, exact-key recovery, and owning-store selection. Topic recovery updates the event source before keying and its adapter path preserves pinned identity. Existing recovery remains fail-closed when a named profile's DB cannot be resolved. The patch does not add an external surface, weaken an allowlist, introduce executable spool contents, change spool paths/permissions, or add credential use.

No unauthorized external caller, OS-isolation escape, credential exfiltration, or contrary-to-documentation security outcome was demonstrated. Per SECURITY.md §§2.2, 2.6, and 3, routing handles are not authorization by themselves. A synthetic source/helper exception is not proof of an authorization bypass. The inherited fault findings above are reliability evidence, not an official advisory or vulnerability classification. POSIX permission enforcement, Windows ACLs, all multiplexed layouts, and credential E2E were not exhaustively exercised.

Alternative designs and related work

  • Keep the old slot key: rejected by the actual baseline RED and stranded recovery probes.
  • Unify every adapter slot with the store key: larger behavioral change to deliberately platform-specific batching/busy isolation (base.py:2564-2566); not necessary for this durable-write mismatch.
  • Broaden recovery to approximate chat/user matches or strip suffixes: cannot reconstruct a missing user identifier in the inverse mismatch and weakens exact-key/profile/reset attribution. The existing resolver already supplies the appropriate owning-store infrastructure.
  • Bind an immutable canonical key/session ID at retention: potentially preferable for a separately demonstrated mid-flight rebind contract, but requires auditing admission, merges, serialization, resets, and every producer. I have not proved that broader design superior for this stable-config bug; do not dismiss or claim it solved based on these tests.
  • Shared write-boundary conversion: bounded and supported for this issue by both mismatch directions and both shutdown lanes. It does not make the whole spool subsystem fault-proof.

Inspected effective related diffs, not just titles: merged #114225 owns key-to-ID/DB resolution and fences; merged #133932 owns ordered/full-fidelity cap-drop recovery, profile-home replay, and the process-local unlink guard. Those are inherited capabilities, not newly implemented here. #123585 is closed/unmerged, not a merged predecessor. Open #123707 changes the off-loop cap-drop writer/lane; it is complementary, not the pending-slot-key conversion. No competing implementation/PR was published during this review.

Compatibility, operations, and residual trade-offs

  • This is prevention for newly written flushes, not migration of already-stranded old files. The issue explicitly calls that out; automatic suffix stripping would risk misattribution. Preserve existing files for operator-directed recovery.
  • Optional keyword callbacks preserve old caller shapes. Plain strings and failed derivation keep the old behavior, including potentially unresolved keys; the comment that the slot key "still works" should not be interpreted as an unconditional recovery guarantee.
  • One additional derivation per event is introduced; synchronous filesystem writes and their shutdown latency are inherited. I did not run a capacity/latency benchmark.
  • The tested contract assumes stable admitted source/configuration. Arbitrary hot reconfiguration or source rebinding was not proven safe. Normal pending text recovery is not full multimodal event replay or live turn execution.
  • Rollback restores old write semantics and can strand newly queued messages again; it does not undo committed transcript rows. Backup/recovery policy and durable exactly-once replay are separate from this narrow key fix.

Executed verification

Environment: Windows 11 x64, PM-built Python 3.14.7, pytest 9.1.1, pytest-asyncio 1.3.0, SQLite 3.53.1. Canonical Git Bash runner; 2 workers; HERMES_TEST_FILE_RETRIES=0; clean TZ=UTC, LANG=C.UTF-8, PYTHONHASHSEED=0. No live platform/provider requests. Test-only overlays in independent checkouts; original production head unchanged.

Frozen scope Actual result Exit Command wall time
Final real normal contract on main (-k retained_events) 2 aligned controls passed, 8 key-mismatch assertions failed; 3 fault cases deselected 1 10.607s
Identical final file/argv on head 10 passed, 0 failed; 3 fault cases deselected 0 12.266s
Three adverse contracts on head (-k adverse_fault) 0 passed, 3 intended preservation assertions failed; 10 normal cases deselected 1 11.301s
Same adverse contracts on main Same three behavioral failures 1 29.203s
Final unfiltered head cluster, 11 files 64 passed, 3 inherited-fault assertions failed, no skips 1 30.899s
Local main + exact candidate patch, 8 files, -k 'not adverse_fault' 57 passed, 0 failed; the 3 known adverse assertions explicitly deselected 0 27.258s
Final head scripts/check --base f86276d2ebcccf0530873c11e2965c3f1cbd5a31 11 checks OK; code health: 0 blocking, 0 advisory 0 62.628s

The final cluster contains 54 committed cases plus my 10 positive cases and 3 intentionally RED adverse cases. Repeated runs are not additional unique coverage. It is not a clean 67-test pass. The filtered 57-test integration is positive stable-config evidence only; the excluded failures are fully reported and reproduced, not removed or weakened. My reviewer file is not part of the submitted PR.

The identical final reviewer file in all three checkouts has SHA-256 4ca1a77c0398d93041673cd27a513f62557e394ed85a7d16c06e5e1571b01ee7. The exact PR-owned merge-base patch used for local integration has SHA-256 9489c4a0435959f92b054e5348445da15b7733e0175ec2b5c05d6d93a2a1e2e2.

An earlier unqualified all-check invocation hit a 300s outer timeout and supplied no final stage receipt. I do not count it as pass/fail behavioral evidence. The final explicit-comparison-base command above completed all 11 checks. Initial shared-object Git fetch attempts failed before testing; the investigation then used an independent repository. The primary dirty user checkout was not modified or used for results.

Reproduction commands and complete reviewer probe

Use the checkout's PM-built interpreter selected via HERMES_PYTHON, and the canonical runner, not bare pytest. Save the code below as tests/gateway/test_review_store_key_contracts.py in isolated main/head checkouts. No production edits are required.

HERMES_TEST_FILE_RETRIES=0 scripts/run_tests.sh tests/gateway/test_review_store_key_contracts.py -j 2 -k retained_events -- --tb=short
HERMES_TEST_FILE_RETRIES=0 scripts/run_tests.sh tests/gateway/test_review_store_key_contracts.py -j 2 -k adverse_fault -- --tb=short
python scripts/check --base f86276d2ebcccf0530873c11e2965c3f1cbd5a31

The second command is expected to fail on the preservation assertions on both snapshots. The subprocess arm is a genuine fresh interpreter, not an in-memory reset of the replay guard.

Complete executed reviewer probe (two parametrized functions, thirteen cases)
"""Reviewer-only real SQLite/retention/restart contracts; no platform or provider calls."""
import json
from unittest.mock import patch

import pytest

from gateway.config import GatewayConfig
from gateway.platforms.event import MessageEvent, MessageType
from gateway.session import SessionStore
from gateway.shutdown_flush import recover_pending_to_db
from tests.gateway.restart_test_helpers import make_restart_runner, make_restart_source


@pytest.mark.asyncio
@pytest.mark.parametrize('flags', [
    (True, False, True, False, 'dm', None),
    (True, False, True, True, 'group', '900'),
    (True, False, False, False, 'group', None),
    (False, False, True, False, 'group', None),
    (True, True, True, False, 'group', '900'),
])
@pytest.mark.parametrize('lane', ['adapter', 'runner'])
async def test_retained_events_roundtrip_real_sqlite(tmp_path, monkeypatch, flags, lane):
    import hermes_constants
    import hermes_state
    sg, st, ag, at, chat_type, thread = flags
    monkeypatch.setenv('HERMES_HOME', str(tmp_path))
    monkeypatch.setattr(hermes_constants, 'get_hermes_home', lambda: tmp_path)
    monkeypatch.setattr(hermes_state, 'DEFAULT_DB_PATH', hermes_state._IMPORT_DEFAULT_DB_PATH)
    config = GatewayConfig(group_sessions_per_user=sg, thread_sessions_per_user=st)
    store = SessionStore(sessions_dir=tmp_path / 'sessions', config=config)
    runner, adapter = make_restart_runner()
    runner.config = config
    runner.session_store = store
    adapter.set_session_store(store)
    adapter.config.extra.update(group_sessions_per_user=ag, thread_sessions_per_user=at)
    source = make_restart_source(chat_id='900', chat_type=chat_type, thread_id=thread)
    entry = store.get_or_create_session(source)
    db = store._db_for_key(entry.session_key)
    assert db is not None
    slot = adapter._source_session_key(source)
    assert entry.session_key == store._generate_session_key(source)
    contents = ['head'] if lane == 'adapter' else ['head', 'tail']
    try:
        for i, text in enumerate(contents):
            event = MessageEvent(text=text, message_type=MessageType.TEXT, source=source, message_id=f'review-{i}')
            # Actual production retention seam, not raw ledger/queue row injection.
            runner._enqueue_fifo(slot, event, adapter)
            assert event._gateway_accepted is True
        if lane == 'adapter':
            await adapter.cancel_background_tasks()
        else:
            with patch('gateway.status.remove_pid_file'), patch('gateway.status.publish_runtime_status'):
                await runner.stop()
        files = list((tmp_path / 'pending_messages').glob('*.json'))
        assert len(files) == len(contents)
        assert {json.loads(p.read_text(encoding='utf-8'))['session_key'] for p in files} == {entry.session_key}
        store.close_all_db_handles()
        store = SessionStore(sessions_dir=tmp_path / 'sessions', config=config)
        db = store._db_for_key(entry.session_key)
        assert db is not None
        assert recover_pending_to_db(db, session_resolver=store.resolve_session_id_for_key) == len(contents)
        assert recover_pending_to_db(db, session_resolver=store.resolve_session_id_for_key) == 0
        assert [m['content'] for m in db.get_messages(entry.session_id)] == contents
    finally:
        store.close_all_db_handles()
    # Re-open through the real reader and verify persisted order/content and index.
    reopened = SessionStore(sessions_dir=tmp_path / 'sessions', config=config)
    try:
        assert reopened.peek_session_id(entry.session_key) == entry.session_id
        restored_db = reopened._db_for_key(entry.session_key)
        assert [m['content'] for m in restored_db.get_messages(entry.session_id)] == contents
        assert not list((tmp_path / 'pending_messages').glob('*.json'))
    finally:
        reopened.close_all_db_handles()


@pytest.mark.asyncio
@pytest.mark.parametrize('fault', ['spool_write', 'unlink', 'append_once'])
async def test_review_adverse_fault_contract(tmp_path, monkeypatch, fault):
    """Adversarial preservation assertions: intentionally RED if an inherited limitation remains."""
    import errno
    import os
    import subprocess
    import sys
    from pathlib import Path
    import hermes_constants
    import hermes_state
    import gateway.shutdown_flush as flush

    monkeypatch.setenv('HERMES_HOME', str(tmp_path))
    monkeypatch.setattr(hermes_constants, 'get_hermes_home', lambda: tmp_path)
    monkeypatch.setattr(hermes_state, 'DEFAULT_DB_PATH', hermes_state._IMPORT_DEFAULT_DB_PATH)
    store = SessionStore(sessions_dir=tmp_path / 'sessions', config=GatewayConfig())
    runner, adapter = make_restart_runner()
    runner.session_store = store
    adapter.set_session_store(store)
    source = make_restart_source(chat_id='901', chat_type='dm')
    entry = store.get_or_create_session(source)
    event = MessageEvent(text='fault-probe', message_type=MessageType.TEXT, source=source, message_id='fault-1')
    runner._enqueue_fifo(adapter._source_session_key(source), event, adapter)
    assert event._gateway_accepted is True
    try:
        if fault == 'spool_write':
            def fail_write(*args, **kwargs):
                raise OSError(errno.ENOSPC, 'reviewer-injected spool write failure')
            monkeypatch.setattr(flush, '_write_payload', fail_write)
            await adapter.cancel_background_tasks()
            files = list((tmp_path / 'pending_messages').glob('*.json'))
            rows = store._db_for_key(entry.session_key).get_messages(entry.session_id)
            # A retained payload or durable copy must still exist; no raw-row injection.
            assert bool(adapter._pending_messages) or bool(files) or bool(rows), 'accepted event lost after failed spool write'
        elif fault == 'append_once':
            import sqlite3
            tail_event = MessageEvent(text='fault-tail', message_type=MessageType.TEXT, source=source, message_id='fault-2')
            runner._enqueue_fifo(adapter._source_session_key(source), tail_event, adapter)
            assert tail_event._gateway_accepted is True
            with patch('gateway.status.remove_pid_file'), patch('gateway.status.publish_runtime_status'):
                await runner.stop()
            store.close_all_db_handles()
            store = SessionStore(sessions_dir=tmp_path / 'sessions', config=GatewayConfig())
            db = store._db_for_key(entry.session_key)
            original_append = db.append_message
            failed = []
            def append_with_one_failed_head(**kwargs):
                if kwargs.get('content') == 'fault-probe' and not failed:
                    failed.append(True)
                    raise sqlite3.OperationalError('reviewer-injected exhausted transient append failure')
                return original_append(**kwargs)
            with monkeypatch.context() as scoped:
                scoped.setattr(db, 'append_message', append_with_one_failed_head)
                assert recover_pending_to_db(db, session_resolver=store.resolve_session_id_for_key) == 1
                assert failed == [True]
                assert recover_pending_to_db(db, session_resolver=store.resolve_session_id_for_key) == 1
            content = [row['content'] for row in db.get_messages(entry.session_id)]
            assert content == ['fault-probe', 'fault-tail'], f'pending FIFO replay inversion: {content}'
        else:
            await adapter.cancel_background_tasks()
            files = list((tmp_path / 'pending_messages').glob('*.json'))
            assert len(files) == 1
            original_unlink = Path.unlink
            def fail_unlink(path, *args, **kwargs):
                if path == files[0]:
                    raise PermissionError('reviewer-injected unlink failure')
                return original_unlink(path, *args, **kwargs)
            with monkeypatch.context() as scoped:
                scoped.setattr(Path, 'unlink', fail_unlink)
                db = store._db_for_key(entry.session_key)
                assert recover_pending_to_db(db, session_resolver=store.resolve_session_id_for_key) == 1
                assert len(db.get_messages(entry.session_id)) == 1
                assert files[0].exists()
                # Same-process guard really works; do not simulate restart by clearing it.
                assert recover_pending_to_db(db, session_resolver=store.resolve_session_id_for_key) == 0
            store.close_all_db_handles()
            child_code = (
                'import json,sys; from pathlib import Path; '
                'from gateway.config import GatewayConfig; from gateway.session import SessionStore; '
                'from gateway.shutdown_flush import recover_pending_to_db; '
                's=SessionStore(sessions_dir=Path(sys.argv[1])/"sessions",config=GatewayConfig()); '
                'd=s._db_for_key(sys.argv[2]); '
                'n=recover_pending_to_db(d,session_resolver=s.resolve_session_id_for_key); '
                'rows=d.get_messages(sys.argv[3]); '
                'print(json.dumps({"recovered":n,"content":[r["content"] for r in rows]})); '
                's.close_all_db_handles()'
            )
            env = dict(os.environ, HERMES_HOME=str(tmp_path), HERMES_NIX_BUILD='1')
            child = subprocess.run([sys.executable, '-c', child_code, str(tmp_path), entry.session_key, entry.session_id],
                                   cwd=Path(__file__).resolve().parents[2], env=env, capture_output=True,
                                   text=True, encoding='utf-8', timeout=45, check=True)
            receipt = json.loads(child.stdout.strip().splitlines()[-1])
            assert receipt['content'] == ['fault-probe'], f'fresh-process duplicate: {receipt}'
    finally:
        store.close_all_db_handles()

Current remote state and merge conditions

The inspected remote PR is OPEN, draft, MERGEABLE but BLOCKED, with no checks reported, and the comparison is 1 candidate commit ahead / 209 base commits behind. These are snapshot statements, not proof that CI was never executed or that the branch is approved.

The exact five-file patch applied cleanly to captured main and passed the filtered local integration above. That is an uncommitted patch-level integration result, not a rebased/pushed candidate, a validation of remote CI, or permission to merge. Before merging, maintainers still need to clear the draft when appropriate and obtain the required checks/review on the resulting current head. Revalidate if head/base changes.

The earlier review at 9f2ba1000b7052a2146e03f03689b4f4aa4206c4 identified lint and a non-string-result test gap. Both are corrected at this head: the four invalid-result cases are present at test_shutdown_flush_store_key.py:165-175 and pass; the final full local checker passes. I am not carrying those old blockers forward or claiming the other reviewer's broader test totals as my own.

Bottom line: the issue's P1 key-mismatch defect is substantiated and repaired in the tested scope; no new P0/P1 regression was substantiated. The three reproduced inherited fault modes remain explicit follow-up work, and the patch must not be advertised as exactly-once, complete multimodal, crash-proof, or platform/provider E2E recovery.

@ehz0ah ehz0ah left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval conclusion (formal approval permission unavailable)

GitHub did not accept a formal approval from this account, so this review records the approval conclusion on the exact head.

Reviewed head 5c3350a9b9fffb43ec90ca91eca97c723eeaca12 against issue #136189 and current main@97a9ae8ee55d31bebce49f1fb6b5ef775323976a.

Motivation

The reported bug reproduces on current main. A platform can add a per-user suffix to its pending-message slot key while SessionStore uses the gateway-wide isolation flags. On shutdown, the old code wrote the slot key. On the next boot, recovery could not resolve that key, so it kept the spool file and never restored the message to the transcript. My unpatched control returned VERDICT=STRANDED.

Approach

The fix converts each sourced payload to the key owned by the session store at the shared spool-write boundary. Adapter shutdown passes the store resolver through adapter_store_key_for. Runner shutdown passes _session_key_for_source for both the pending head and FIFO overflow. A value without a source, a missing resolver, an exception, an empty result, or a non-string result keeps the old slot key. Recovery itself is unchanged.

Specific changes

gateway/shutdown_flush.py adds the narrow key conversion and optional resolver arguments. gateway/platforms/base.py wires adapter shutdown to the store resolver. gateway/run_shutdown.py wires runner shutdown for both spool lanes. tests/gateway/test_shutdown_flush_store_key.py covers the mismatched keys, both shutdown paths, restart recovery, and all fallback results. evals/shutdown_flush_adapter_key_strand.py provides a direct before-and-after probe.

Risk to main

The commit applies cleanly to current main. On that composition, 29 focused shutdown and spool tests passed. The recovery probe returned VERDICT=RECOVERED, and scripts/check completed all 11 checks. The change adds no config, schema, public API, prompt surface, or new state owner. The focused validation proves transcript recovery for the tested pending-text paths. It does not claim full platform delivery after replay. The remote PR still has no hosted checks and remains a draft.

Overall assessment

The earlier lint blocker and the missing invalid-result regression case are fixed at this head. The root cause is real, the write boundary owns the correction, and the fallback keeps prior behavior when the store key cannot be derived. I found no remaining blocker in this patch.

English verdict: APPROVE 5c3350a9b9fffb43ec90ca91eca97c723eeaca12. The patch fixes the stranded-spool case on current main, preserves the fallback contract, and passes the focused validation above.

@PRELVAS94

PRELVAS94 commented Oct 11, 2026 •

Copy link
Copy Markdown
Author

Approval recorded on the unchanged head — no code change in response

Thank you. This review names 5c3350a9b9fffb43ec90ca91eca97c723eeaca12, and that is still the PR head and the branch tip (PRELVAS94:fix/shutdown-flush-store-key, 1 commit, 5 files), so the conclusion matches exactly what is pushed. Nothing in it asks for a change, so nothing was pushed in response — deliberately: a new commit would move the head and void both reviews recorded on this one.

Re-read at that head to check the review's own account of the patch (read-only; I am not restating the reviewer's test totals as mine):

  • gateway/shutdown_flush.py:90-112, _recoverable_key: a payload with no source, no callable resolver, a derivation that throws, or a result that is empty or not a str keeps the slot key — exactly the fallback described in the review.
  • tests/gateway/test_shutdown_flush_store_key.py:165-175 holds the parametrised invalid-result case ("", 0, None, and the correct store key agent:main:telegram:group:900:900 wrapped in a list) that the round-2 head was missing.
  • The five # noqa: E402 directives from the round-2 head are absent here.
  • check_runs for this head: 0. mergeable: true, mergeable_state: blocked.

One correction on the next step: the draft flag is not ours to clear. Re-probed on this head — GraphQL markPullRequestReadyForReview returns FORBIDDEN — PRELVAS94 does not have permission to mark the pull request ready for review, and PATCH /pulls/136600 {"draft": false} answers 200 with the flag unchanged. Marking the PR ready for review, and whatever check wiring the repository requires, therefore sits with a maintainer; the only PR write this account has on this repository is the draft: true creation itself.

Scope stays where the review puts it: this is the pending-text spool key and nothing wider — not exactly-once restore, not full multimodal replay, not crash-proof recovery, not platform/provider E2E. The independent review filed on this same head reports its three conditional fault modes as inherited from main rather than regressions introduced here; that review is being answered on its own thread.

The head stays where it is. We will push a commit only if a review asks for a specific change.

@PRELVAS94

PRELVAS94 commented Oct 11, 2026 •

Copy link
Copy Markdown
Author

The three inherited findings: mechanics confirmed on this head, and deliberately not fixed here

Thank you — this is the most useful kind of review: it separates what our diff does from what the
subsystem it touches already does. I reproduced all three findings with my own probes before answering,
and I am answering them with a triage decision rather than a patch. Reasoning below, so you can push back
on the decision rather than on the facts.

No code changed in response to this review. The head is still
5c3350a9b9fffb43ec90ca91eca97c723eeaca12 (1 commit, 5 files), so the suite evidence already recorded on
it in 6110115459 still applies unchanged; a new commit would move the head and void the three reviews
recorded against it.

What I ran

Three standalone probes, one per finding, each driving the real entry points (cancel_background_tasks,
recover_pending_to_db, _order_flush_files) with a store that performs real SQLite INSERTs. Each probe
was run in a fresh process against three trees, and no tree was modified to run them (git status clean in
all three afterwards) — the failure injections live in the probe, not in the checkout:

Tree Commit
This head 5c3350a9b9
PR parent f86276d2eb
Current main 97a9ae8e

Environment: Linux, Python 3.13.5, pytest 9.1.1 / pytest-asyncio 1.4.0 (copy of the shipped venv), the
checkout's canonical runner. Results are identical on all three trees — the same verdict in every one of the
nine probe applications (three probes × three trees):

Finding Injection Observable Head Parent Main
1 OSError(28, "No space left on device") raised out of _write_payload pending_before=1 pending_after=0, spool_files=[] → VERDICT=LOST LOST LOST LOST
2 PermissionError from the spool file's Path.unlink only pass1_recovered=1 pass2_recovered=0, spool_still_on_disk=True, then a genuinely fresh interpreter: restart_recovered=1, rows_after_restart=[(1, 'sid-900', 'retained text'), (2, 'sid-900', 'retained text')] → VERDICT=DUPLICATE DUPLICATE DUPLICATE DUPLICATE
3 One sqlite3.OperationalError for the head append only intended_replay_order=['pending-0000.json', 'pending-0001.json']; rows_after_pass1=[(1, 'second (queue tail)')]; after the retry rows_after_pass2=[(1, 'second (queue tail)'), (2, 'first (queue head)')] → VERDICT=INVERTED INVERTED INVERTED INVERTED

So: the mechanics are exactly as you describe, and the zero behavioural delta from this diff is what the
row-identical verdicts above show. Line-level that is expected — the only thing this PR changes on those
paths is the added session_key_for= argument (base.py:4796-4801, run_shutdown.py:2013); the
unconditional clear that follows it, _remove_replayed, the replay loop and the pending-text arm are
byte-identical to main.

Boundaries I am not overstating, in the same spirit as your own: my probes model the failures (an injected
ENOSPC, an injected unlink refusal, one injected per-row append failure); they are not an exhausted
host disk, not native Windows ACL behaviour, and not a kill between commit and unlink. The store is
duck-typed around the single method recovery calls, with real SQLite underneath, not the production
SessionDB reader. I claim the three mechanics and nothing wider — no repeated turns, no repeated tool
effects, no external side effect. Say the word and I will paste the three probe scripts (about 60 lines
each) on this thread so you can re-run them instead of taking my verdicts.

Two things worth recording next to the findings

  1. Finding 2 is documented in the code you cite, in words. _remove_replayed's docstring says the
    consequence outright — "Nothing on disk marks it, so a restart writes the row a second time" — and the
    refusal path already logs an operator instruction. Same for finding 1: both lanes flush inside
    with contextlib.suppress(Exception): # flush pending messages to disk before clearing and then clear
    unconditionally, and I confirmed that shape on the runner lane too (run_shutdown.py:2009-2028), so the
    flush is best-effort by construction at both call sites, not accidentally best-effort at one.
  2. Finding 3's skip-and-continue is asserted by the repository's own test.
    tests/gateway/test_recover_skips_failing_payload_and_continues requires a failed payload to be skipped
    while the pass goes on. That test separates two sessions, so it does not exercise the same-session
    ordering case you reproduced — which is precisely the gap.

Neither point makes the behaviour correct. They change what a fix has to be: documented-by-design but
still a data-integrity failure is a policy question, not a defect line to repair, and that matches your own
follow-up directions (bounded retry vs. alternative retention; a replay receipt committed atomically with
the row; a failure-order fence shared across payload classes).

Triage decision: not folded into this PR

I am not patching any of the three here, and I would rather say why than let silence imply denial:

  • Each one requires a decision only the maintainers can make — how much shutdown latency to trade for a
    retry, what "durable ownership" means when the disk will not take the copy at all, what identity a spool
    file carries and where the receipt that makes replay exactly-once lives, and whether the transcript arm's
    ordering fence should be generalised to the pending-text arm without holding unrelated sessions back.
    Those are the same questions your follow-up directions raise, and you explicitly left them to maintainer
    prioritisation rather than to this contributor.
  • This PR's single logical change is the write-boundary key conversion for gateway: shutdown flush files are written under the adapter's session key, so restart recovery can never map them when adapter and gateway per-user isolation flags differ #136189. Folding a
    shutdown-policy or exactly-once redesign into it would mix unrelated risk, decide semantics in the wrong
    place, and move a head that currently carries three reviews.
  • I am taking "these experiments do not justify inventing a new P0/P1 blocker" at its word — as scope, not
    as a reason to drop the findings. They are not dropped: they are recorded on our side as open,
    reproduced, unfixed items tied to this PR's thread.

What would actually help, if the maintainers agree the findings are worth acting on: do you want these
as three separate issues, or one tracking issue for the spool's fault modes? Say which, and we will file
them with the probes — we have deliberately filed nothing yet, to avoid adding to your triage before you
have decided where these belong. Would you rather have a fix from us than an issue? Name which one and it
comes as its own PR against main, with the probe as the failing test first, and no change to this head.

Is there a reading of any of the three I have got wrong — or a reason one of them is this PR's
responsibility after all? Say so and I will re-run it against whatever you point at.

@JoaoMarcos44 JoaoMarcos44 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Expanded P1/P2/P3 review: one new P2 must be fixed before merge

This is a material update to my earlier review on the same head, 5c3350a9b9fffb43ec90ca91eca97c723eeaca12. The ordinary issue #136189 is still fixed in the tested paths, but the expanded sibling-producer investigation found a new, conditional P2 recovery regression introduced by this patch. My recommendation is do not merge this head until the recall regression is corrected. No new P0/P1 has been demonstrated. The earlier three injected-fault failures remain inherited, not additional PR-owned blockers.

Expanded Ledger run: run_1509a097bc834f6980e36f61e632f63d. The earlier completed run and its report remain historical evidence. This update corrects the scope of the earlier conclusion: normal ingress canonicalization does not cover every direct pending-slot producer.

Blocking P2: partial-source Yuanbao recall overwrites an already valid queued owner

Changed boundary: gateway/shutdown_flush.py:106-112, applied at line 138 and called from base.py:4796-4797.

Preconditions are supported but non-default: a Yuanbao group/thread with both group_sessions_per_user: true and thread_sessions_per_user: true, a recall of an in-flight message without its original sender, and shutdown before the pending recall notice drains. The generic per-user thread flag is explicitly supported in gateway/config.py:602-603, website/docs/user-guide/sessions.md:937-938, and website/docs/developer-guide/gateway-session-lifecycle.md:637-638; no Yuanbao exclusion was found.

  1. The original participant's adapter key and store key already agree: agent:main:yuanbao:group:group:G:main:u1. The durable session exists under that exact key. This is an aligned-flags case, not the mismatch the PR repairs.
  2. DecodeMiddleware.parse_json_push explicitly accepts recall callbacks without from_account or message body (yuanbao.py:415-438). RecallGuardMiddleware matches the active processing slot by recalled message ID (546-574). That ownership was established by normal dispatch (1677-1705).
  3. The recall producer directly puts its notice into adapter._pending_messages[matched_slot], bypassing handle_message (577-590). Its replacement source is built from the callback and has user_id=None (524-530). The callback need not reconstruct the original participant because the producer already matched the slot.
  4. The new _recoverable_key trusts that partial source and records agent:main:yuanbao:group:group:G:main, dropping :u1. This is a valid nonempty string, so the exception/type fallback does not help.
  5. After reopening real SQLite, the real exact-key resolver returns no session for the replacement key: recovered=0, no recall notice in the original transcript, and the JSON remains stranded. Baseline preserves the matched slot key and recovers the exact notice into the original session.

The bytes are not irreversibly deleted: they remain in an unresolvable spool file. The proven impact is regression of automatic recovery of a queued control notice. I have not demonstrated continued execution of a withdrawn request, user-content corruption, unauthorized access, or a security advisory. P2 here is a functional regression under a documented configuration, not an inflated P1 claim.

Parent-executed differential proof

The reviewer fixture is byte-identical in the head, merge-base, main, and local integration trees. Final SHA-256: adff0c2321cf2cb9d573e30a647ed47e3533dab192b3065533c470dbe847dcc2. It has two parametrized functions/four cases. The supplied-sender u1 controls pass on every tree; only the supported missing-sender cases fail on this head. The failures are behavioral assertions, not import/setup errors.

Canonical actual argv, with the checkout's PM interpreter selected through HERMES_PYTHON:

C:/Program Files/Git/usr/bin/bash.exe scripts/run_tests.sh tests/gateway/test_review_recall_spool_identity.py
Tree Result Exit Measured command wall time
Merge base f86276d2ebcccf0530873c11e2965c3f1cbd5a31 4 passed, 0 failed 0 5.565 s
Captured main 97a9ae8ee55d31bebce49f1fb6b5ef775323976a 4 passed, 0 failed 0 5.691 s
Reviewed head 5c3350a9b9fffb43ec90ca91eca97c723eeaca12 2 passed, 2 failed 1 6.417 s
Captured main + exact PR-owned patch, uncommitted local integration 2 passed, 2 failed 1 21.311 s

The fourth run uses the same final fixture bytes on the already prepared integration tree: current-main HEAD 97a9ae8ee55d31bebce49f1fb6b5ef775323976a plus the exact five-file candidate-owned patch. It reproduces the same missing-sender recovery/key assertions; known-sender controls still pass. This is patch-level local integration evidence, not a rebased/pushed candidate or remote CI result.

At fixture line 145, recovered == 1 fails with actual 0; at line 168, the spooled key lacks :u1. The known-sender controls preserve successful recovery and the exact original notice text. yuanbao.py and session_recovery.py are unchanged in the merge-base-to-head PR diff; the new key replacement is causal, not base drift.

Environment: Windows 11 x64, PM Python 3.14.7, pytest 9.1.1, pytest-asyncio 1.3.0, SQLite 3.53.1; canonical native Git Bash, 12 workers, retries disabled, isolated test home, UTC/clean runner environment. Parent receipts are recall-final-{head,mergebase,base}.{json,log} in the isolated evidence directory. The final runs followed documentation-only corrections to the delegated fixture; older hashes/receipts are not credited to these final bytes.

Proof scope is local decode -> real recall producer -> actual adapter shutdown -> real reopened SQLite/recovery. The fixture seeds the processing-ID/active-guard maps normally produced by dispatch and uses blank processing text to avoid unrelated redaction polling. It is not full dispatch/transport ACK/provider/LLM E2E. Parent source tracing checked the genuine dispatch map writer and cancellation branch: normal follow-up drain occurs on successful completion (base.py:4650-4658); task cancellation takes the distinct branch at 4660-4665, leaving the pending notice for shutdown backup.

Required merge condition and alternatives

Preserve the already-established owner for supported partial-source pending producers while retaining the ordinary adapter/store mismatch repair. Carrying the original accepted source/routing identity through the synthetic replacement is a credible direction; simply restoring every old slot key would reintroduce #136189. A blanket "missing user means invalid source" rule is also unsafe: legitimate shared/internal events can lack a user ID. Check producer ownership rather than assuming every source-bearing value came through ordinary canonicalized ingress.

Require all four recall cases to pass on the revised head, while preserving the ordinary two-direction key-mismatch, adapter-head/runner-overflow, profile, fallback, and reset controls. No production fix is implemented or validated in this review. The baseline-GREEN/head-RED comparison proves a regression; it is not a claim that a proposed repair is GREEN.

The inspected #99950 policy-unification alternative changes key derivation semantics but does not reconstruct a missing participant in this aligned-flags case. Source-bearing recovery would still receive the same partial source unless original ownership is retained, and requires schema expansion. Recording both competing keys and replaying twice would risk duplication. For the newly searched siblings, inspection of #75849 at 46c77dad0cbdc3caa41afdd6d442e3473dc66d03 shows a control/internal-event distinction and retains _build_source(..., from_account); it does not restore the missing participant or change this spool normalization. #83418 at c629a7ccd9041875334c3a60fe47eef9c4afd6ca anchors reconnect tasks and drains background work on disconnect, without changing recall identity or spool keys. These are complementary lifecycle/control work, not a demonstrated repair for this finding. This is effective-diff inspection, not execution or CI validation of those branches; other broad keyword hits were not exhaustively audited.

Other expanded results, not additional hard blockers

  • Merged-slot replay ordering limitation: parent ran four real SQLite characterization/control cases. Arrival A1,A2,B1,B2 from two adapter per-user slots collapsing to one store session is recovered as A1,B1,A2,B2 with sequential filenames. Heads have no seq; each slot tail restarts at zero. The B1-before-A2 inversion is filename-independent. Survival once and per-sender order are preserved. The passing test deliberately characterizes this order; it is not a failing global-order contract test. Cross-slot chronology is not established as a product contract, so I do not elevate this observation into a forced P3 blocker. The old mismatch strands these files, not irreversibly deletes them. Probe SHA-256: 743ce0da8ad5035c4815ec57c11a579dd9448af10b76bc16ed1f5f4d2e0e3c3b; parent result 4 passed/exit 0 in 7.760 s.
  • Submitted runner-head test fidelity: the author's test places a MessageEvent into the runner's legacy pending_command_text view (test_shutdown_flush_store_key.py:129-142, session_state.py:71,239). The in-tree FIFO retains its actual head in the adapter and tails in conversation.queued_events (run_busy.py:105-114). No in-tree non-None writer for the legacy field was found. This limits that particular test's production claim; it is not a demonstrated runtime regression. My ordinary reviewer probe covers the actual head/overflow retention path.
  • Expanded five-file shutdown run: 27 passed, 1 failed, exit 1, command wall time 10.837 s. The failure is the unmarked POSIX-style zombie oracle at test_gateway_shutdown.py:360. The identical focused assertion fails on both merge base and captured main. status.py:931-962 intentionally skips POSIX zombie probing on Windows. This is an inherited off-host test-oracle mismatch, not a PR-owned blocker; no assertion was weakened or skipped to report green.
  • Sync/async binding hypothesis refuted: adapters receive the synchronous SessionStore (run.py:3518, run_adapters.py:1357), while AsyncSessionStore is a separate facade (run.py:3523). The hypothesized coroutine return was not found on the production binding. That refutation does not cover direct partial-source synthetic producers, which is how the P2 escaped the initial audit.

The review remains a COMMENT submission. The authenticated account has read-only repository permissions, so this technical "fix before merge" recommendation is not a claim that I set a required blocking review decision. The PR remains draft/BLOCKED with no checks reported; do not attribute GitHub's BLOCKED state to my finding. Current-main revalidation and CI remain separate merge conditions.

The complete executed recall probe follows. The original report and its three inherited findings are preserved below for traceability.

Complete executed recall probe (two functions, four cases)
"""Reviewer-only probe: a partial-source Yuanbao recall notice must survive the shutdown flush
into the ORIGINAL durable session.

PR #136600 makes ``flush_pending_to_file`` re-derive each slot's key from the queued value's
``source`` (``_recoverable_key`` + ``adapter_store_key_for``, called from
``BasePlatformAdapter.cancel_background_tasks``). The recall guard writes its synthetic interrupt
notice *straight into* ``adapter._pending_messages[matched_slot]`` (bypassing ``handle_message``)
with a source built from the *recall callback*. The supported local recall callback shape may
omit ``from_account`` and ``msg_body`` — ``DecodeMiddleware.parse_json_push`` explicitly
admits that input — so ``RecallGuardMiddleware._build_source`` yields ``user_id=None``.

When the adapter's own isolation flags agree with the gateway-wide ones (so the slot key IS the
store key), re-deriving the key from that partial source silently drops the participant segment:
the notice is spooled under a key that neither the routing map nor the durable row knows, recovery
cannot resolve it, and the notice strands in the spool instead of replaying into the original
session. The pre-PR flush recorded the slot key verbatim, which resolved.

This probe drives the real decode -> real recall producer -> real adapter shutdown flush -> real
SessionStore/SQLite reopen/recovery, and asserts the exact notice text lands in the ORIGINAL
session with the spool drained. ``from_account=""`` is the supported partial-source failing case;
``from_account="u1"`` is a positive control proving the fixture and the recovery seam are sound.
"""

import asyncio
import json
from pathlib import Path

import pytest

from gateway.config import GatewayConfig, PlatformConfig
from gateway.platforms.yuanbao import (
    DecodeMiddleware,
    InboundContext,
    RecallGuardMiddleware,
    YuanbaoAdapter,
)
from gateway.session import SessionStore
from gateway.shutdown_flush import recover_pending_to_db

RECALLED_MSG_ID = "m-original"
GROUP_CODE = "G"
ORIGINAL_SENDER = "u1"
# Both the adapter (PlatformConfig.extra) and the store use the gateway-wide isolation flags, so
# the slot key the notice is queued under IS the key the store routes the original turn by.
FLAGS = dict(group_sessions_per_user=True, thread_sessions_per_user=True)


def _config() -> GatewayConfig:
    return GatewayConfig(**FLAGS)


def _open_store(tmp_path: Path) -> SessionStore:
    return SessionStore(sessions_dir=tmp_path / "sessions", config=_config())


@pytest.fixture
def hermes_home(tmp_path, monkeypatch):
    """Real SQLite under an isolated HERMES_HOME (same seam as the other reviewer probes)."""
    import hermes_constants
    import hermes_state

    monkeypatch.setenv("HERMES_HOME", str(tmp_path))
    monkeypatch.setattr(hermes_constants, "get_hermes_home", lambda: tmp_path)
    monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", hermes_state._IMPORT_DEFAULT_DB_PATH)
    return tmp_path


def _wire(tmp_path: Path):
    store = _open_store(tmp_path)
    adapter = YuanbaoAdapter(PlatformConfig(enabled=True, token="***"))
    adapter.config.extra.update(**FLAGS)
    adapter.set_session_store(store)
    return store, adapter


def _original_source(adapter: YuanbaoAdapter):
    """The real group source of the message that is being processed when the recall arrives."""
    return adapter.build_source(
        chat_id=f"group:{GROUP_CODE}", chat_type="group",
        user_id=ORIGINAL_SENDER, thread_id="main",
    )


async def _noop() -> None:
    return None


async def _emit_recall(adapter: YuanbaoAdapter, from_account: str) -> str:
    """Drive the real decode + real recall producer; return the notice text queued for the slot."""
    raw = {
        "callback_command": "Group.CallbackAfterRecallMsg",
        "group_code": GROUP_CODE,
        "recall_msg_seq_list": [{"msg_id": RECALLED_MSG_ID}],
    }
    if from_account:  # otherwise exercise the supported callback without this key
        raw["from_account"] = from_account
    push = DecodeMiddleware.parse_json_push(raw)
    assert push is not None, "the decoder must admit this recall callback"
    assert push["callback_command"] == "Group.CallbackAfterRecallMsg"
    assert push["from_account"] == from_account

    slot = adapter._source_session_key(_original_source(adapter))
    adapter._processing_msg_ids[slot] = RECALLED_MSG_ID
    adapter._processing_msg_texts[slot] = ""  # blank: no content-redact poll
    adapter._active_sessions[slot] = asyncio.Event()

    await RecallGuardMiddleware().handle(InboundContext(adapter=adapter, push=push), _noop)

    notice = adapter._pending_messages.get(slot)
    assert notice is not None, "the recall interrupt must queue its notice on the matched slot"
    return notice.text


def _spool_keys(flush_dir: Path) -> list[str]:
    return sorted(json.loads(p.read_text(encoding="utf-8"))["session_key"]
                  for p in flush_dir.glob("pending-*.json"))


@pytest.mark.asyncio
@pytest.mark.parametrize("from_account", ["", ORIGINAL_SENDER])
async def test_recall_notice_replays_into_the_original_session(tmp_path, hermes_home, from_account):
    """The queued notice must survive a real adapter shutdown flush and land in the original row."""
    store, adapter = _wire(tmp_path)
    try:
        entry = store.get_or_create_session(_original_source(adapter))
        slot = adapter._source_session_key(_original_source(adapter))
        # Precondition: the slot key and the store key are the same string (flags agree).
        assert slot == entry.session_key == store._generate_session_key(_original_source(adapter))

        notice_text = await _emit_recall(adapter, from_account)

        # Real production flush seam: the exact changed line in base.py.
        await adapter.cancel_background_tasks()

        store.close_all_db_handles()
        reopened = _open_store(tmp_path)
        try:
            db = reopened._db_for_key(entry.session_key)
            recovered = recover_pending_to_db(
                db, session_resolver=reopened.resolve_session_id_for_key)
            contents = [m["content"] for m in db.get_messages(entry.session_id)]
        finally:
            reopened.close_all_db_handles()

        assert recovered == 1, (
            "the recall notice was not recovered; spool keys="
            f"{_spool_keys(tmp_path / 'pending_messages')}"
        )
        assert contents == [notice_text], f"notice text not in the original session: {contents!r}"
        assert not list((tmp_path / "pending_messages").glob("*.json")), "spool not drained"
    finally:
        store.close_all_db_handles()


@pytest.mark.asyncio
@pytest.mark.parametrize("from_account", ["", ORIGINAL_SENDER])
async def test_flushed_key_is_the_slot_the_notice_was_queued_under(tmp_path, hermes_home,
                                                                   from_account):
    """The spooled key must be the slot the notice was queued under, so recovery can resolve it."""
    store, adapter = _wire(tmp_path)
    try:
        store.get_or_create_session(_original_source(adapter))
        slot = adapter._source_session_key(_original_source(adapter))
        await _emit_recall(adapter, from_account)

        await adapter.cancel_background_tasks()

        assert _spool_keys(tmp_path / "pending_messages") == [slot]
    finally:
        store.close_all_db_handles()

Earlier report, preserved as historical scope

The expanded P2 verdict above supersedes any impression of unconditional endorsement from this earlier report. Its executed tests and inherited findings remain valid historical receipts.

Independent Ledger-backed review of the current head

Reviewed head: 5c3350a9b9fffb43ec90ca91eca97c723eeaca12. Captured current main: 97a9ae8ee55d31bebce49f1fb6b5ef775323976a. PR comparison merge base: f86276d2ebcccf0530873c11e2965c3f1cbd5a31. Ledger run: run_2ddd83b7a18c4fe6b02e0b635e4bcaa7.

Verdict and priority boundary

The P1-labelled issue #136189 is real. I reproduced its adapter/store key mismatch on the merge base and on captured current main, and verified that this head repairs the tested ordinary pending-text paths. I did not substantiate a newly introduced P0/P1 defect or an officially in-scope security vulnerability in this patch.

I did reproduce three conditional, inherited data-integrity failures, documented below with the complete reviewer probe. They remain on both main and this head; they must not be presented as regressions introduced by this contributor. Their fault-mode severity needs maintainer prioritization; these experiments do not justify inventing a new P0/P1 blocker. This is a COMMENT review, not a blocking review decision or a claim that the entire repository is green.

Scope, mechanism, and ownership

I inspected all five PR-owned files: gateway/shutdown_flush.py, gateway/platforms/base.py, gateway/run_shutdown.py, tests/gateway/test_shutdown_flush_store_key.py, and evals/shutdown_flush_adapter_key_strand.py, plus retention, canonical identity, session recovery, profile routing, and startup readers.

Adapter busy/batching slots derive isolation from PlatformConfig.extra (base.py:2557-2567), while the durable store derives it from gateway configuration (session_recovery.py:96-103). The two legal configurations can add or remove a user suffix independently. Recovery searches the exact routing/durable key and the owning DB, not an arbitrary same-chat row (session_recovery.py:196-243). Writing a slot key unchanged can therefore preserve an unresolvable file indefinitely.

The patch places conversion at the shared derived-write boundary (shutdown_flush.py:90-168) and wires adapter shutdown and runner head/overflow shutdown (base.py:4795-4801, run_shutdown.py:2011-2019). It leaves adapter isolation semantics and recovery's existing profile/reset fences intact. Keyless raw strings and unavailable/throwing/empty/non-string derivations retain the old slot key. That fallback preserves the payload, not a guarantee that the key is recoverable.

The author's eval and tests use a fake DB and direct pending-slot injection. I executed them, but do not call them platform/provider E2E. My additional normal probe uses real SessionStore/SQLite, production _enqueue_fifo retention (including its acceptance flag), actual shutdown/spooling, close/reopen, the production resolver, persisted content/order, and a second recovery pass. It is a retention-to-durable-reader contract, not a complete transport authorization/ACK/LLM-delivery test.

Confirmed inherited findings

  1. A failed spool write can erase the last pending text copy.

    • Head locations: shutdown_flush.py:77-87, base.py:4795-4803; runner sibling run_shutdown.py:2011-2024 also ignores the best-effort count before clearing.
    • _flush_value catches the write failure and returns False. Adapter shutdown still clears _pending_messages without requiring a successful durable copy.
    • Reproduction: genuinely retain one text event, inject OSError(ENOSPC) at _write_payload, then call cancel_background_tasks. Observed: pending slot empty, zero spool JSON files, zero SQLite messages. The preservation assertion fails on both head and main.
    • Boundary: a modeled write failure, not an exhausted host disk or complete network ACK reproduction. The proof is loss of the retained text copy; it does not establish any additional external side effect.
    • Follow-up direction: distinguish attempted backup from successful durable ownership, make failures observable, and decide a bounded retry/alternative retention/shutdown policy. Keeping only memory is not crash durability; a retry is not a solution to a permanently unavailable disk. No implementation is proved here.
  2. Append success followed by unlink failure duplicates the user row after a real process restart.

    • Head locations: _remove_replayed, shutdown_flush.py:311-320, and replay at 403-407.
    • _REPLAYED_UNREMOVABLE correctly suppresses repeat replay in the same process, but there is no durable replay receipt. The existing docstring explicitly describes the next-process duplication.
    • Reproduction: real SQLite append, selective PermissionError from the spool file's unlink, verify same-process second recovery is zero, close handles, then launch a fresh Python interpreter against the same temporary home and real reader. Observed: recovered=1, contents ['fault-probe', 'fault-probe'].
    • Same failure on head and main. This proves duplicate persisted user rows, not repeated tools, agent turns, or destructive actions. The unlink error is injected; native Windows ACL behavior and kill-between-commit/unlink are not separately exercised.
    • Follow-up direction: a stable spool identity and replay receipt committed atomically with the row, using existing durable infrastructure where possible. Content-based deduplication would incorrectly collapse legitimate repeated messages; an in-memory set or post-commit file marker cannot provide exactly-once crash recovery. No repair has been implemented or verified.
  3. Ordinary pending-message recovery can permanently invert FIFO after one failed head append.

    • Head locations: shutdown_flush.py:395-410, 430-456, 457-484.
    • The per-session blocked_sessions fence covers TRANSCRIPT_CAP_DROP_REASON, but the ordinary pending-text arm neither marks nor consults it after an append failure. The outer loop logs the failed head and proceeds to the tail.
    • Reproduction: retain head fault-probe and tail fault-tail via _enqueue_fifo, run actual runner.stop, reopen SQLite, inject one terminal sqlite3.OperationalError only for the head append, then perform two recovery passes. Observed durable row order: ['fault-tail', 'fault-probe'] instead of arrival order.
    • Same failure on head and main. This is not merely a filename-order concern: row-ID order remains wrong after the failed message is successfully retried. It models one exhausted transient append failure, not native SQLite contention or an executed-command consequence.
    • Follow-up direction: consider a shared failure-order fence across the relevant payload classes without blocking unrelated sessions. Permanent row rejection/quarantine, profile ownership, and normal replay require separate controls before recommending a patch.

Security assessment

I inspected canonical identity at ingress, topic-source rewriting, profile-key generation, exact-key recovery, and owning-store selection. Topic recovery updates the event source before keying and its adapter path preserves pinned identity. Existing recovery remains fail-closed when a named profile's DB cannot be resolved. The patch does not add an external surface, weaken an allowlist, introduce executable spool contents, change spool paths/permissions, or add credential use.

No unauthorized external caller, OS-isolation escape, credential exfiltration, or contrary-to-documentation security outcome was demonstrated. Per SECURITY.md §§2.2, 2.6, and 3, routing handles are not authorization by themselves. A synthetic source/helper exception is not proof of an authorization bypass. The inherited fault findings above are reliability evidence, not an official advisory or vulnerability classification. POSIX permission enforcement, Windows ACLs, all multiplexed layouts, and credential E2E were not exhaustively exercised.

Alternative designs and related work

  • Keep the old slot key: rejected by the actual baseline RED and stranded recovery probes.
  • Unify every adapter slot with the store key: larger behavioral change to deliberately platform-specific batching/busy isolation (base.py:2564-2566); not necessary for this durable-write mismatch.
  • Broaden recovery to approximate chat/user matches or strip suffixes: cannot reconstruct a missing user identifier in the inverse mismatch and weakens exact-key/profile/reset attribution. The existing resolver already supplies the appropriate owning-store infrastructure.
  • Bind an immutable canonical key/session ID at retention: potentially preferable for a separately demonstrated mid-flight rebind contract, but requires auditing admission, merges, serialization, resets, and every producer. I have not proved that broader design superior for this stable-config bug; do not dismiss or claim it solved based on these tests.
  • Shared write-boundary conversion: bounded and supported for this issue by both mismatch directions and both shutdown lanes. It does not make the whole spool subsystem fault-proof.

Inspected effective related diffs, not just titles: merged #114225 owns key-to-ID/DB resolution and fences; merged #133932 owns ordered/full-fidelity cap-drop recovery, profile-home replay, and the process-local unlink guard. Those are inherited capabilities, not newly implemented here. #123585 is closed/unmerged, not a merged predecessor. Open #123707 changes the off-loop cap-drop writer/lane; it is complementary, not the pending-slot-key conversion. No competing implementation/PR was published during this review.

Compatibility, operations, and residual trade-offs

  • This is prevention for newly written flushes, not migration of already-stranded old files. The issue explicitly calls that out; automatic suffix stripping would risk misattribution. Preserve existing files for operator-directed recovery.
  • Optional keyword callbacks preserve old caller shapes. Plain strings and failed derivation keep the old behavior, including potentially unresolved keys; the comment that the slot key "still works" should not be interpreted as an unconditional recovery guarantee.
  • One additional derivation per event is introduced; synchronous filesystem writes and their shutdown latency are inherited. I did not run a capacity/latency benchmark.
  • The tested contract assumes stable admitted source/configuration. Arbitrary hot reconfiguration or source rebinding was not proven safe. Normal pending text recovery is not full multimodal event replay or live turn execution.
  • Rollback restores old write semantics and can strand newly queued messages again; it does not undo committed transcript rows. Backup/recovery policy and durable exactly-once replay are separate from this narrow key fix.

Executed verification

Environment: Windows 11 x64, PM-built Python 3.14.7, pytest 9.1.1, pytest-asyncio 1.3.0, SQLite 3.53.1. Canonical Git Bash runner; 2 workers; HERMES_TEST_FILE_RETRIES=0; clean TZ=UTC, LANG=C.UTF-8, PYTHONHASHSEED=0. No live platform/provider requests. Test-only overlays in independent checkouts; original production head unchanged.

Frozen scope Actual result Exit Command wall time
Final real normal contract on main (-k retained_events) 2 aligned controls passed, 8 key-mismatch assertions failed; 3 fault cases deselected 1 10.607s
Identical final file/argv on head 10 passed, 0 failed; 3 fault cases deselected 0 12.266s
Three adverse contracts on head (-k adverse_fault) 0 passed, 3 intended preservation assertions failed; 10 normal cases deselected 1 11.301s
Same adverse contracts on main Same three behavioral failures 1 29.203s
Final unfiltered head cluster, 11 files 64 passed, 3 inherited-fault assertions failed, no skips 1 30.899s
Local main + exact candidate patch, 8 files, -k 'not adverse_fault' 57 passed, 0 failed; the 3 known adverse assertions explicitly deselected 0 27.258s
Final head scripts/check --base f86276d2ebcccf0530873c11e2965c3f1cbd5a31 11 checks OK; code health: 0 blocking, 0 advisory 0 62.628s

The final cluster contains 54 committed cases plus my 10 positive cases and 3 intentionally RED adverse cases. Repeated runs are not additional unique coverage. It is not a clean 67-test pass. The filtered 57-test integration is positive stable-config evidence only; the excluded failures are fully reported and reproduced, not removed or weakened. My reviewer file is not part of the submitted PR.

The identical final reviewer file in all three checkouts has SHA-256 4ca1a77c0398d93041673cd27a513f62557e394ed85a7d16c06e5e1571b01ee7. The exact PR-owned merge-base patch used for local integration has SHA-256 9489c4a0435959f92b054e5348445da15b7733e0175ec2b5c05d6d93a2a1e2e2.

An earlier unqualified all-check invocation hit a 300s outer timeout and supplied no final stage receipt. I do not count it as pass/fail behavioral evidence. The final explicit-comparison-base command above completed all 11 checks. Initial shared-object Git fetch attempts failed before testing; the investigation then used an independent repository. The primary dirty user checkout was not modified or used for results.

Reproduction commands and complete reviewer probe

Use the checkout's PM-built interpreter selected via HERMES_PYTHON, and the canonical runner, not bare pytest. Save the code below as tests/gateway/test_review_store_key_contracts.py in isolated main/head checkouts. No production edits are required.

HERMES_TEST_FILE_RETRIES=0 scripts/run_tests.sh tests/gateway/test_review_store_key_contracts.py -j 2 -k retained_events -- --tb=short
HERMES_TEST_FILE_RETRIES=0 scripts/run_tests.sh tests/gateway/test_review_store_key_contracts.py -j 2 -k adverse_fault -- --tb=short
python scripts/check --base f86276d2ebcccf0530873c11e2965c3f1cbd5a31

The second command is expected to fail on the preservation assertions on both snapshots. The subprocess arm is a genuine fresh interpreter, not an in-memory reset of the replay guard.

Complete executed reviewer probe (two parametrized functions, thirteen cases)
"""Reviewer-only real SQLite/retention/restart contracts; no platform or provider calls."""
import json
from unittest.mock import patch

import pytest

from gateway.config import GatewayConfig
from gateway.platforms.event import MessageEvent, MessageType
from gateway.session import SessionStore
from gateway.shutdown_flush import recover_pending_to_db
from tests.gateway.restart_test_helpers import make_restart_runner, make_restart_source


@pytest.mark.asyncio
@pytest.mark.parametrize('flags', [
    (True, False, True, False, 'dm', None),
    (True, False, True, True, 'group', '900'),
    (True, False, False, False, 'group', None),
    (False, False, True, False, 'group', None),
    (True, True, True, False, 'group', '900'),
])
@pytest.mark.parametrize('lane', ['adapter', 'runner'])
async def test_retained_events_roundtrip_real_sqlite(tmp_path, monkeypatch, flags, lane):
    import hermes_constants
    import hermes_state
    sg, st, ag, at, chat_type, thread = flags
    monkeypatch.setenv('HERMES_HOME', str(tmp_path))
    monkeypatch.setattr(hermes_constants, 'get_hermes_home', lambda: tmp_path)
    monkeypatch.setattr(hermes_state, 'DEFAULT_DB_PATH', hermes_state._IMPORT_DEFAULT_DB_PATH)
    config = GatewayConfig(group_sessions_per_user=sg, thread_sessions_per_user=st)
    store = SessionStore(sessions_dir=tmp_path / 'sessions', config=config)
    runner, adapter = make_restart_runner()
    runner.config = config
    runner.session_store = store
    adapter.set_session_store(store)
    adapter.config.extra.update(group_sessions_per_user=ag, thread_sessions_per_user=at)
    source = make_restart_source(chat_id='900', chat_type=chat_type, thread_id=thread)
    entry = store.get_or_create_session(source)
    db = store._db_for_key(entry.session_key)
    assert db is not None
    slot = adapter._source_session_key(source)
    assert entry.session_key == store._generate_session_key(source)
    contents = ['head'] if lane == 'adapter' else ['head', 'tail']
    try:
        for i, text in enumerate(contents):
            event = MessageEvent(text=text, message_type=MessageType.TEXT, source=source, message_id=f'review-{i}')
            # Actual production retention seam, not raw ledger/queue row injection.
            runner._enqueue_fifo(slot, event, adapter)
            assert event._gateway_accepted is True
        if lane == 'adapter':
            await adapter.cancel_background_tasks()
        else:
            with patch('gateway.status.remove_pid_file'), patch('gateway.status.publish_runtime_status'):
                await runner.stop()
        files = list((tmp_path / 'pending_messages').glob('*.json'))
        assert len(files) == len(contents)
        assert {json.loads(p.read_text(encoding='utf-8'))['session_key'] for p in files} == {entry.session_key}
        store.close_all_db_handles()
        store = SessionStore(sessions_dir=tmp_path / 'sessions', config=config)
        db = store._db_for_key(entry.session_key)
        assert db is not None
        assert recover_pending_to_db(db, session_resolver=store.resolve_session_id_for_key) == len(contents)
        assert recover_pending_to_db(db, session_resolver=store.resolve_session_id_for_key) == 0
        assert [m['content'] for m in db.get_messages(entry.session_id)] == contents
    finally:
        store.close_all_db_handles()
    # Re-open through the real reader and verify persisted order/content and index.
    reopened = SessionStore(sessions_dir=tmp_path / 'sessions', config=config)
    try:
        assert reopened.peek_session_id(entry.session_key) == entry.session_id
        restored_db = reopened._db_for_key(entry.session_key)
        assert [m['content'] for m in restored_db.get_messages(entry.session_id)] == contents
        assert not list((tmp_path / 'pending_messages').glob('*.json'))
    finally:
        reopened.close_all_db_handles()


@pytest.mark.asyncio
@pytest.mark.parametrize('fault', ['spool_write', 'unlink', 'append_once'])
async def test_review_adverse_fault_contract(tmp_path, monkeypatch, fault):
    """Adversarial preservation assertions: intentionally RED if an inherited limitation remains."""
    import errno
    import os
    import subprocess
    import sys
    from pathlib import Path
    import hermes_constants
    import hermes_state
    import gateway.shutdown_flush as flush

    monkeypatch.setenv('HERMES_HOME', str(tmp_path))
    monkeypatch.setattr(hermes_constants, 'get_hermes_home', lambda: tmp_path)
    monkeypatch.setattr(hermes_state, 'DEFAULT_DB_PATH', hermes_state._IMPORT_DEFAULT_DB_PATH)
    store = SessionStore(sessions_dir=tmp_path / 'sessions', config=GatewayConfig())
    runner, adapter = make_restart_runner()
    runner.session_store = store
    adapter.set_session_store(store)
    source = make_restart_source(chat_id='901', chat_type='dm')
    entry = store.get_or_create_session(source)
    event = MessageEvent(text='fault-probe', message_type=MessageType.TEXT, source=source, message_id='fault-1')
    runner._enqueue_fifo(adapter._source_session_key(source), event, adapter)
    assert event._gateway_accepted is True
    try:
        if fault == 'spool_write':
            def fail_write(*args, **kwargs):
                raise OSError(errno.ENOSPC, 'reviewer-injected spool write failure')
            monkeypatch.setattr(flush, '_write_payload', fail_write)
            await adapter.cancel_background_tasks()
            files = list((tmp_path / 'pending_messages').glob('*.json'))
            rows = store._db_for_key(entry.session_key).get_messages(entry.session_id)
            # A retained payload or durable copy must still exist; no raw-row injection.
            assert bool(adapter._pending_messages) or bool(files) or bool(rows), 'accepted event lost after failed spool write'
        elif fault == 'append_once':
            import sqlite3
            tail_event = MessageEvent(text='fault-tail', message_type=MessageType.TEXT, source=source, message_id='fault-2')
            runner._enqueue_fifo(adapter._source_session_key(source), tail_event, adapter)
            assert tail_event._gateway_accepted is True
            with patch('gateway.status.remove_pid_file'), patch('gateway.status.publish_runtime_status'):
                await runner.stop()
            store.close_all_db_handles()
            store = SessionStore(sessions_dir=tmp_path / 'sessions', config=GatewayConfig())
            db = store._db_for_key(entry.session_key)
            original_append = db.append_message
            failed = []
            def append_with_one_failed_head(**kwargs):
                if kwargs.get('content') == 'fault-probe' and not failed:
                    failed.append(True)
                    raise sqlite3.OperationalError('reviewer-injected exhausted transient append failure')
                return original_append(**kwargs)
            with monkeypatch.context() as scoped:
                scoped.setattr(db, 'append_message', append_with_one_failed_head)
                assert recover_pending_to_db(db, session_resolver=store.resolve_session_id_for_key) == 1
                assert failed == [True]
                assert recover_pending_to_db(db, session_resolver=store.resolve_session_id_for_key) == 1
            content = [row['content'] for row in db.get_messages(entry.session_id)]
            assert content == ['fault-probe', 'fault-tail'], f'pending FIFO replay inversion: {content}'
        else:
            await adapter.cancel_background_tasks()
            files = list((tmp_path / 'pending_messages').glob('*.json'))
            assert len(files) == 1
            original_unlink = Path.unlink
            def fail_unlink(path, *args, **kwargs):
                if path == files[0]:
                    raise PermissionError('reviewer-injected unlink failure')
                return original_unlink(path, *args, **kwargs)
            with monkeypatch.context() as scoped:
                scoped.setattr(Path, 'unlink', fail_unlink)
                db = store._db_for_key(entry.session_key)
                assert recover_pending_to_db(db, session_resolver=store.resolve_session_id_for_key) == 1
                assert len(db.get_messages(entry.session_id)) == 1
                assert files[0].exists()
                # Same-process guard really works; do not simulate restart by clearing it.
                assert recover_pending_to_db(db, session_resolver=store.resolve_session_id_for_key) == 0
            store.close_all_db_handles()
            child_code = (
                'import json,sys; from pathlib import Path; '
                'from gateway.config import GatewayConfig; from gateway.session import SessionStore; '
                'from gateway.shutdown_flush import recover_pending_to_db; '
                's=SessionStore(sessions_dir=Path(sys.argv[1])/"sessions",config=GatewayConfig()); '
                'd=s._db_for_key(sys.argv[2]); '
                'n=recover_pending_to_db(d,session_resolver=s.resolve_session_id_for_key); '
                'rows=d.get_messages(sys.argv[3]); '
                'print(json.dumps({"recovered":n,"content":[r["content"] for r in rows]})); '
                's.close_all_db_handles()'
            )
            env = dict(os.environ, HERMES_HOME=str(tmp_path), HERMES_NIX_BUILD='1')
            child = subprocess.run([sys.executable, '-c', child_code, str(tmp_path), entry.session_key, entry.session_id],
                                   cwd=Path(__file__).resolve().parents[2], env=env, capture_output=True,
                                   text=True, encoding='utf-8', timeout=45, check=True)
            receipt = json.loads(child.stdout.strip().splitlines()[-1])
            assert receipt['content'] == ['fault-probe'], f'fresh-process duplicate: {receipt}'
    finally:
        store.close_all_db_handles()

Current remote state and merge conditions

The inspected remote PR is OPEN, draft, MERGEABLE but BLOCKED, with no checks reported, and the comparison is 1 candidate commit ahead / 209 base commits behind. These are snapshot statements, not proof that CI was never executed or that the branch is approved.

The exact five-file patch applied cleanly to captured main and passed the filtered local integration above. That is an uncommitted patch-level integration result, not a rebased/pushed candidate, a validation of remote CI, or permission to merge. Before merging, maintainers still need to clear the draft when appropriate and obtain the required checks/review on the resulting current head. Revalidate if head/base changes.

The earlier review at 9f2ba1000b7052a2146e03f03689b4f4aa4206c4 identified lint and a non-string-result test gap. Both are corrected at this head: the four invalid-result cases are present at test_shutdown_flush_store_key.py:165-175 and pass; the final full local checker passes. I am not carrying those old blockers forward or claiming the other reviewer's broader test totals as my own.

Bottom line: the issue's P1 key-mismatch defect is substantiated and repaired in the tested scope; no new P0/P1 regression was substantiated. The three reproduced inherited fault modes remain explicit follow-up work, and the patch must not be advertised as exactly-once, complete multimodal, crash-proof, or platform/provider E2E recovery.


Final expanded decision: fix the demonstrated P2 partial-source recall recovery regression before merging this head; no additional new P1/P3 hard blocker was substantiated in the examined scope. The submission remains COMMENTED, not a formal blocking review decision.

Comment thread gateway/shutdown_flush.py Outdated
for session_key, value in list(pending.items()):
if value is not None:
flushed += _flush_value(flush_dir, "pending", session_key, value, reason=reason, ts=ts)
key = _recoverable_key(value, session_key, session_key_for)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P2] Preserve the matched owner of partial-source recall notices

For a supported Yuanbao group/thread with group_sessions_per_user and thread_sessions_per_user true, RecallGuardMiddleware accepts a callback without from_account, matches the live slot by message ID, and writes its notice directly into that matched slot. The notice source has user_id=None. Re-deriving here drops :u1 from the already-valid store/adapter key: agent:main:yuanbao:group:group:G:main:u1 becomes agent:main:yuanbao:group:group:G:main. Real SQLite recovery after close/reopen returns zero and strands the notice JSON. The same four-case decoder/producer/shutdown/recovery probe passes 4/4 on merge base and current main, but fails the two missing-sender assertions on this head and on main plus the exact PR patch; both known-sender controls pass. The full probe and limitations are in the review body. Preserve established ownership for this supported synthetic producer without undoing the ordinary adapter/store mismatch fix before merging. This is conditional recovery failure, not proven byte deletion or network/provider E2E.

`_recoverable_key` re-keyed every source-bearing pending value onto the store's
derivation, which loses the payload's established owner when the value was queued
by a synthetic producer whose `source` is partial.

`RecallGuardMiddleware._interrupt_for_recall` (gateway/platforms/yuanbao.py) writes
its notice straight into the matched in-flight slot, and a recall callback may arrive
with no `from_account` (`DecodeMiddleware.parse_json_push` admits that shape): the
notice's source then has `user_id=None` and derives the *userless* key, while the
slot it was written into carries the participant. With `group_sessions_per_user` and
`thread_sessions_per_user` both on, the store routes that slot key, so re-deriving
drops `:u1` and `recover_pending_to_db` strands the notice on a key no session owns
(its exact-key lookup matches neither the routing map nor a durable row). Before this
PR the same notice recovered — the re-key introduced the regression.

Fix: record the slot key when the store's own resolver can land it. It is the key the
producing adapter queued the value under and the key its live drain re-dispatches it
with (`base.py`'s drain passes the slot key), so it is the payload's established
owner; the #136189 repair survives for the case that motivated it, a slot key the
store cannot route. `store_slot_key_routes` builds that predicate from
`SessionStore.resolve_session_id_for_key` — the callable recovery itself uses — so a
key nothing can resolve never suppresses the repair, and a resolver failure or an
absent store falls back to the previous derivation. Both flush call sites stay at
their current line counts (base.py and run_shutdown.py are over the file-length
ratchet), via the keying-kwargs bundles the gateway's other call sites use.

Verified by a new test pair in tests/gateway/test_shutdown_flush_store_key.py that
drives the real producer, the real shutdown flush and the real durable-row recovery:
the missing-sender notice keeps `:u1` and recovers into the original session, while
the known-sender control is unchanged. Both assertions fail on the previous head
(spooled `agent:main:telegram:group:group:G:main`, recovered 0) and on the merge base
the same flow passes, matching the review's baseline.
@PRELVAS94

Copy link
Copy Markdown
Author

The conditional P2 is reproduced on this head and fixed in 40f62fa4

Thank you — this is a sharply aimed fixture: it names the one producer that queues a value whose
source is not the source its slot came from, and it keeps that separate from the ordinary
mismatch. I reproduced it independently before changing anything. New head:
40f62fa4920a048c562655c5e52781c62ee8325e on PRELVAS94:fix/shutdown-flush-store-key (2 commits,
5 files). Both of the missing-sender assertions you name — line 145 recovered == 1 and line 168
the spooled key keeping :u1 — hold there; the two known-sender controls are unchanged.

Reproduced on 5c3350a9b9 (the head you reviewed)

My probe drives the real producer (RecallGuardMiddleware._interrupt_for_recall, branch C), the
real adapter shutdown flush and the real durable-row recovery, with the store's exact-key resolver
(the one recover_pending_to_db uses). Aligned flags on both sides
(group_sessions_per_user + thread_sessions_per_user), a recall callback with
from_account="", and a durable row seeded under the matched slot key:

Tree spooled session_key recover_pending_to_db landed in
merge base f86276d2 agent:main:telegram:group:group:G:main:u1 1 the original session
head 5c3350a9b9 agent:main:telegram:group:group:G:main 0 — stranded, Cannot recover pending message for …: session_key-to-id resolution failed, payload left in the spool
head + fix, missing sender agent:main:telegram:group:group:G:main:u1 1 the original session
head + fix, from_account=u1 (control) agent:main:telegram:group:group:G:main:u1 1 the original session

That matches your reading exactly: the head introduced the regression, and the merge base is green
for the reason you gave.

What changed

_recoverable_key now records the slot key when the store can route it, and keeps the derivation
for the case that motivated #136189:

  • slot_key_routes (new, optional) is built by store_slot_key_routes from
    SessionStore.resolve_session_id_for_key — the same callable recovery hands payloads to. A slot
    key it lands is recorded as it stands; a slot key it cannot land is re-keyed by session_key_for
    exactly as before.
  • The slot key is the established owner: it is the key the producing adapter queued the value under,
    the key _interrupt_for_recall writes into, and the key the live drain re-dispatches it with
    (base.py passes the slot key to the drain), so preferring it when it is routable restores the
    pre-PR owner rather than inventing a third one.
  • The conservative edges are unchanged in both directions: a resolver that raises, a store without
    the resolver, a value with no source, or a derivation that does not return a non-empty str all
    keep the slot key; the derivation is used exactly when the slot key cannot be resolved.

Why this shape, not "record the slot key" and not a producer-side source repair

  • Recording the slot key unconditionally would undo gateway: shutdown flush files are written under the adapter's session key, so restart recovery can never map them when adapter and gateway per-user isolation flags differ #136189: there the slot key is precisely the key
    that resolves nowhere and the derivation is what makes the payload recoverable. Which of the two
    keys owns the payload is not a property of the keys' shape — both cases are "the derived key is a
    strict prefix of the slot key" — it is only knowable by asking the store, so the flush asks.
  • Carrying the accepted source through the synthetic replacement is the other credible direction you
    name, and I did not take it: the notice's source also feeds delivery and scope, and the defect is
    key selection at the flush seam, not attribution. The flush rule also covers a sibling producer you
    have not seen yet, without touching the recall path.

Evidence on the new head

  • scripts/run_tests.sh tests/gateway/test_shutdown_flush_store_key.py →
    === Summary: 1 files, 12 tests passed, 0 failed (100% complete) in 2.7s (8 workers) ===. All ten
    tests already in that file — the two-derivations-disagree case, the adapter-shutdown and
    runner-overflow recordings, the next-boot recovery, the no-source slot, the failing derivation and
    the four invalid-result parameters — pass unmodified; the two new ones are the missing-sender
    notice (keeps :u1, recovers into the original session) and the same rule through runner.stop().
  • The 14-file adjacent set (test_shutdown_flush*.py, test_pending_*, test_profile_spool_recovery.py,
    test_restart_resume_pending.py, test_gateway_shutdown.py, test_yuanbao_shutdown.py,
    test_queued_final_ledger.py, platforms/test_yuanbao_recall_db_only.py) →
    121 tests passed, 0 failed.
  • The PR's own eval entry point evals/shutdown_flush_adapter_key_strand.py still prints
    VERDICT=RECOVERED with the payload spooled under the store key: the ordinary repair is intact.
  • python scripts/check (11 checks, code-health ratchet included, base f86276d2) → ok,
    0 blocking. Both flush call sites keep their files at their current line counts, which is why the
    keying is passed as a bundle rather than a fourth line in base.py / run_shutdown.py.

Boundaries

This is recovery-key selection and nothing wider. I did not verify byte-level deletion, provider or
network E2E, or delivery-ordering beyond the existing ts/seq sort, and I have not re-examined the
three inherited fault modes in this commit. The routability check costs one resolver call per spooled
value at shutdown — the same call the next boot's recovery makes for that key, on the same
_db_for_key path — and every failure path in it falls back to today's derivation.

The new commit supersedes the reviews recorded on 5c3350a9b9 as head; that commit stays in the branch
history if you want to diff the two states directly.

…ct branches

The recall guard's notice is the producer whose source is not the source its slot came from:
``_interrupt_for_recall`` writes it straight into the matched in-fligh⟪HERMES-CONTEXT-COMPRESSION: 1,425 of 1,625 chars omitted here by Hermes's context compressor. This is NOT part of the original tool call and must never be reproduced in new output — always write full, untruncated content.⟫
@PRELVAS94

Copy link
Copy Markdown
Author

Verification pass on the new head 5b77013e2dc80254e19e4dc9f3ee91b8b02a0fcd, which adds a test-only commit
on top of 40f62fa4: your four recall cases pass there, and I drove a second reachable branch of the
same producer end to end. All numbers below are from real runs in this checkout (real decode, real
middleware, real adapter shutdown flush, real SessionStore/SQLite reopen), not from reading code.

Your probe, verbatim, on the head you reviewed vs the new head

tree tests/gateway/test_review_recall_spool_identity.py
5c3350a9b9 2 failed, 2 passed — the missing-sender recovery (recovered == 1) and the spooled key losing :u1
5b77013e2d 4 passed, 0 failed

The failing spool key I see is agent:main:yuanbao:group:group:G:main against the row's
agent:main:yuanbao:group:group:G:main:u1, so your reading of the mechanism reproduces here as
written.

Second branch of the same producer: C2C.CallbackAfterMsgWithDraw

RecallGuardMiddleware handles the direct-chat withdrawal as well, and there the missing
from_account costs the chat identity, not only the participant: with the isolation flags aligned on
both sides the notice's own derivation is agent:main:yuanbao:dm:direct: while the interrupted
session's key is agent:main:yuanbao:dm:direct:u1. That is the same PR-owned regression on a second
reachable path, and it is covered by the commit's test, which is parametrised over both recall
commands and both sender shapes:

tests/gateway/test_recall_notice_flush_identity.py

tree 6 tests
merge base f86276d2 6 passed
5c3350a9b9 3 failed, 3 passed — group missing-sender (recovery and spool key) plus direct missing-sender (recovery)
5b77013e2d 6 passed

Adjacent surface on the new head

test_shutdown_flush.py, test_shutdown_flush_recovery.py, test_shutdown_flush_store_key.py,
test_gateway_shutdown.py, test_yuanbao_shutdown.py, test_yuanbao_pipeline.py,
tests/gateway/platforms/test_yuanbao_recall_db_only.py, test_pending_queue_spool.py,
test_restart_resume_pending.py, plus the two files above:
159 tests passed, 0 failed. evals/shutdown_flush_adapter_key_strand.py prints
VERDICT=RECOVERED on 5b77013e2d and VERDICT=STRANDED on f86276d2. ruff check is clean on the
added file and scripts/check --only health exits 0 on this head.

Not claimed

The three inherited fault modes are untouched by this commit: your full roundtrip probe fails those
same three contracts on the merge base (11 failed, 2 passed at f86276d2) and on a fixed tree
(3 failed, 10 passed), so they remain follow-up work rather than part of this claim. I have not
re-examined delivery ordering, provider E2E or byte-level deletion here either.

Please re-run your Ledger probe on 5b77013e2d; the fix itself is 40f62fa4, the added commit is
coverage only.

@ehz0ah ehz0ah left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I re-reviewed 5b77013e2dc80254e19e4dc9f3ee91b8b02a0fcd after the recall-notice fix.

The shutdown spool now uses the store key when an adapter slot cannot route, while keeping a routed slot key when the queued event has only a partial source. That preserves the original owner for sender-less Yuanbao group and direct-message recall notices. The adapter and runner paths apply the same rule to pending and overflow events.

The 18 focused shutdown and recall tests passed on this head and after a clean merge into current main at 899f93b9b86c236ec73ff23aa9e5422270c0503e. All 11 repository checks also passed in both states with no code-health findings. The standalone recovery probe reported VERDICT=RECOVERED. GitHub reports no hosted checks for this head.

The earlier recall blocker is resolved. I found no new merge blocker in the changed scope. One small cleanup remains: tests/gateway/test_recall_notice_flush_identity.py has an extra blank line at EOF, but it does not affect behavior or the repository checks.

…e test

ehz0ah's re-review at 5b77013 noted one cleanup: tests/gateway/test_recall_notice_flush_identity.py
ends with an extra blank line. This removes it. Whitespace only — the file's 233 lines are otherwise
byte-identical, no added line carries trailing whitespace, and the strict ruff config
(ruff.strict.toml, W605 the only W rule selected) does not cover W391.

Re-run at this head:
  HERMES_PYTHON=/tmp/hermes-venv/bin/python scripts/run_tests.sh \
    tests/gateway/test_recall_notice_flush_identity.py tests/gateway/test_shutdown_flush_store_key.py
  === Summary: 2 files, 18 tests passed, 0 failed (100% complete) in 2.8s (8 workers) ===
@PRELVAS94

Copy link
Copy Markdown
Author

The one cleanup you flagged is real, and it is fixed in 02651f0931 — whitespace only

Thank you — the conclusion is the one I reach too, and the rule your note describes is the rule the
tests on this head pin: the store key when an adapter slot cannot route, the routed slot key kept when
the queued event has only a partial source, and the same rule on the adapter shutdown flush and on
runner.stop() (queue head and FIFO tail). Your one open item was real; it is now closed.

What I verified before touching anything (byte level). tests/gateway/test_recall_notice_flush_identity.py
at your head 5b77013e2d is 9728 bytes, 233 lines, and ends ...store.close_all_db_handles()\n\n —
the 233rd line is the empty one you named. It is the only file of the six this PR touches with that
shape: I checked all six for an EOF blank line, a missing final newline and trailing whitespace on
added lines, and nothing else carries any of the three.

What changed. 02651f0931, one commit on top of 5b77013e2d: 1 file, 0 additions / 1 deletion.
The blob served by the contents API at the new head is 9727 bytes and identical to the old one once
trailing newlines are stripped, so no line of it moved, and the removed byte is the empty line.

Evidence at the new head (real runs in a fresh clone of the fork branch, nothing inferred):

=== Summary: 2 files, 18 tests passed, 0 failed (100% complete) in 2.8s (8 workers) ===

That is the same 18 focused shutdown and recall tests you ran, through scripts/run_tests.sh;
ruff check --config ./ruff.strict.toml on both files prints All checks passed!; and a trial merge
into current main a12c3417c8 merges with 0 conflicts and reproduces the same 18-passed line.

On the weight of it. You were right that it touches neither behavior nor the repository checks:
ruff.strict.toml selects W605 as its only W rule, so no W391/W292, and the pre-commit hook
config never covered it either. I removed it because the file is ours and the fix is free, not because
it was a lint failure. I did not re-run the 11 repository checks — their inputs differ from your run by
that single byte, and your run stands.

Not claimed. Nothing else on this head was re-examined for this round, and I have no hosted-check
result for either head (GitHub reports none for the branch).

Your review was recorded at 5b77013e2d; the only delta to the new head is that one byte, so nothing
you read changed. The standing ask is unchanged: the PR is still draft: true because that is the only
mode our account can open a PR in, so it needs someone with the permission to clear the draft flag.

@JoaoMarcos44

JoaoMarcos44 commented Oct 11, 2026 •

Copy link
Copy Markdown

Follow-up review of PR #136600 at head 02651f0931022884208d1caadf93a5dd55e7b7d1.

The latest commit only removes the trailing blank line from the recall test; it does not change production code. The earlier Ruff/fallback-test concerns are fixed, and the two focused PR test files pass 18/18. I still would not merge this head: the key-selection helper has two conditional P2 ownership/recovery gaps.

  1. P2 — a temporary resolver miss is treated as proof the slot key is wrong

At _recoverable_key, any false result from slot_key_routes leads to re-deriving the key from value.source. store_slot_key_routes maps resolver failures/non-resolution to False (lines 153-160); SessionStore.resolve_session_id_for_key returns None when it cannot get the owning DB (session_recovery.py lines 214-216).

Repro: with per-user group/thread keys enabled, a supported Yuanbao recall callback without from_account queues its notice into the already-owned u1 slot, but the notice source is partial. I made _db_for_key return None only during the flush-time route check, restored it before SQLite recovery, and observed the spool key lose :u1; recovery returned 0 and retained the file. The identical expected-owner probe passed on main a12c3417; live main has since advanced to 4eba3eb, with no changes to the relevant shutdown/session files between those snapshots. This is a simulated temporary store-resolution failure, not a live profile outage; the bytes are retained, but automatic recovery is stranded.

  1. P2 — a routable slot key is not proof of ownership by this payload

A positive result from resolve(slot_key) can identify a different session from the source-derived store key. With adapter group_sessions_per_user=false, store group_sessions_per_user=true, an existing userless session under the shared slot key, and a queued u1 event, I drove production _enqueue_fifo, adapter shutdown flush, and real SQLite recovery. The message was appended to the userless transcript while u1's session remained empty. This inverse-mismatch probe also fails on main a12c3417; live main has since advanced to 4eba3eb without changes to these relevant files. I am not calling it a new regression against main, but it remains an unclosed branch of the issue's contract. The previous 5c3350a9 candidate passed this probe with unconditional source derivation, while the final route-preserving rule does not. SessionStore derives the per-user key from the source (session_recovery.py lines 96-103), and normal turn admission resolves that source to its own session (run_turn.py lines 446-449). resolve(slot_key) must establish that this payload belongs to that session, not merely that some session can be reached by that string.

These two cases need a rule that preserves the matched owner for partial-source producers without accepting a different routable owner or treating an unavailable lookup as a definitive miss. Please add paired regressions for both directions before merging.

Code locations for the two findings:

  • Resolver-miss branch: gateway/shutdown_flush.py:112-128 (_recoverable_key) and gateway/shutdown_flush.py:153-160 (store_slot_key_routes).
  • Resolver returns no owning DB: gateway/session_recovery.py:214-216 (resolve_session_id_for_key).
  • Source-derived per-user key: gateway/session_recovery.py:96-103 (_generate_session_key).
  • Destination session chosen from the payload source: gateway/run_turn.py:446-449 (get_or_create_session).

Other verification: scripts/check --only ruff passes. A seven-file adjacent run had 65 passed and one unrelated test_pid_exists_zombie_via_psutil_returns_false failure; the same assertion failed on main a12c3417. No full-suite result is claimed. GitHub currently reports no checks for this draft PR.

The three fault-mode findings already raised in the earlier thread (failed spool write followed by clearing, duplicate replay after unlink failure/restart, and same-session FIFO inversion after a failed head append) remain unchanged by this patch; they are inherited, not new regressions here, and I did not rerun their fault-injection probes on this head.

Review round 5 on this branch kept the merge gate on two P2s in the key rule that round
4 introduced. Both reproduce on 02651f0; both are repaired here.

The round-4 rule recorded the slot key whenever `resolve_session_id_for_key` could land
it. Routability is a fact about a session, not about this payload:

* the payload's own source can derive the key the store lands while the slot key
  resolves to a *different* live session. With the per-user flags overridden in the
  inverse direction (the platform shares group keys, the store isolates them) and a
  userless session already under the shared slot key, the queued message was appended to
  that userless transcript while its sender's session stayed empty — real
  `_pending_messages`, real adapter shutdown flush, real SQLite: `VERDICT=WRONG_OWNER`;
* a store that cannot answer for a key at flush time (`SessionStore._db_for_key` fails
  closed, no reachable home for the key) answers `None` for both keys. The rule read
  that as "the slot key is wrong", re-derived from the notice's partial source and
  recorded a key no session owns, so the notice stranded with its spool file kept.

Fix: ask the store about the key this payload's own source derives. `_recoverable_key`
records the derivation when the store can land it, and keeps the producing slot key
whenever the derivation is unconfirmed (`None`: an unavailable lookup decides nothing) or
when the store knows a session under the slot key and none under the derivation — the
partial-source producer round 4 repaired, whose derivation lands nowhere. When the store
knows no session under either key the derivation stands, as before: recovery keeps the
file either way and names the key it tried. `store_key_lands` answers with the resolver
recovery itself uses, plus `_db_for_key` to tell "the store answered: no session under
this key" apart from "the store could not look at all", so a miss is never read out of a
lookup that never happened. A store without the resolver, a value with no `source` and
any derivation failure keep the previous behaviour.

Verified: `evals/shutdown_flush_adapter_key_strand.py` still `VERDICT=RECOVERED`, so the
#136189 repair is intact; the two new regressions in
`tests/gateway/test_shutdown_flush_store_key.py` fail on the previous head with the very
keys the review reported (`…group:900` instead of `…group:900:u1`; `…group:G:main`
instead of its slot key) and pass here.
@PRELVAS94

Copy link
Copy Markdown
Author

Both P2s reproduce on 02651f0931, and both are fixed at the rule in e9d25e1746.

I reproduced each finding independently before changing anything, on the head you reviewed and with real SQLite, the way your repros do:

  • your finding 1 — partial-source recall notice, _db_for_key returning None for the flush-time check only and restored before recovery: spooled agent:main:telegram:group:group:G:main, recovered=0, spool file retained;
  • your finding 2 — adapter group_sessions_per_user=false / store true, a real userless session under the shared slot key next to a real u1 session, a queued u1 event through the real adapter shutdown flush: the message was appended to the userless transcript while u1's session stayed empty.

_recoverable_key was asking a question about a session ("does the store resolve this string?") and reading the answer as a fact about the payload. The rule now asks the store about the key the payload's own source derives — the key a turn for that source is admitted under (run_turn.py:446-449, the seam you cite) — and keeps the producing slot key otherwise. That is the three clauses you asked for, one branch each:

  • matched owner for partial-source producers: the derivation is recorded only when the store can land it; when the store knows a session under the slot key and none under the derivation, the slot key stands, so the recall notice keeps …:u1 and recovers into the original session;
  • no different routable owner: the slot key no longer wins merely because some session can be resolved by that string. When the payload's own key is one the store lands, that is what is recorded, so the u1 event reaches u1's session even with the userless session live under the slot key. You judged this shape inherited from main rather than a new regression; it is closed here anyway;
  • no unavailable lookup read as a miss: store_key_lands answers True / False / None. False is the store's own answer ("no session under this key"); None comes from SessionStore._db_for_key failing closed, i.e. the store could not look — so the resolver's None is never read as "the slot key is wrong". An unconfirmed derivation leaves the producer's key recorded and the next boot's recovery decides.

When the store knows no session under either key, the derivation stands as before: recovery keeps the file either way and names the key it tried.

Paired regressions for both directions in tests/gateway/test_shutdown_flush_store_key.py, each driven through the real flush and the real durable-row recovery:

  • test_a_resolvable_slot_key_naming_another_session_does_not_capture_the_payload
  • test_an_unavailable_lookup_does_not_move_the_notice_off_its_slot_key

Both fail on 02651f0931 with the keys you reported (…group:900 instead of …group:900:u1; …group:G:main instead of its slot key …group:G:main:u1) and pass on e9d25e1746.

Verification on e9d25e1746:

  • scripts/run_tests.sh tests/gateway/test_shutdown_flush_store_key.py tests/gateway/test_recall_notice_flush_identity.py → === Summary: 2 files, 20 tests passed, 0 failed (100% complete) in 3.5s (8 workers) ===
  • 14 adjacent shutdown/spool/recall files → === Summary: 14 files, 120 tests passed, 0 failed (100% complete) in 12.6s (8 workers) ===
  • evals/shutdown_flush_adapter_key_strand.py → VERDICT=RECOVERED, so the gateway: shutdown flush files are written under the adapter's session key, so restart recovery can never map them when adapter and gateway per-user isolation flags differ #136189 repair the rule exists for is intact;
  • scripts/check --base f86276d2eb → 11 checks, ok, code health 6 files vs f86276d2ebcc: 0 blocking, 0 advisory; scripts/check --only ruff → ok;
  • trial merge into live main 4eba3ebed1 → 0 unmerged paths, then the two PR test files → 20 tests passed, 0 failed, eval VERDICT=RECOVERED.

No full-suite result is claimed. The patch changes only which string is recorded — not the write, unlink or FIFO ordering — so the three inherited fault-mode findings are untouched by it, and I have not re-run their fault injections on this head either.

One residual, stated rather than left implicit: when both keys have a live session and they differ, something has to break the tie. The rule breaks it toward the store's own admission for the payload's source — what normal turn admission uses — and the ownership regression above pins that direction. Deciding it from the session side instead would need a source-vs-origin ownership check inside resolution itself, which is a wider change than this PR's scope.

PR is still draft: true / mergeable_state: blocked; undrafting remains the only open item on our side.

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

Labels

area/sessions Session lifecycle, resume, persistence, history comp/gateway Gateway runner, session dispatch, delivery P1 High — major feature broken, no workaround sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

4 participants