fix(gateway): replay the transcript spool in drop order with full fidelity on restart (supersedes #78323) - #84785
Conversation
… on restart
recover_pending_to_db walks sorted(flush_dir.glob("*.json")) to replay
spool files left behind by a restart. Spool files are named
pending-<uuid4>.json by _write_payload, so that sort is effectively
random: a transcript recovered after a crash comes back scrambled.
The damage is permanent rather than cosmetic. SessionDB restores a
conversation with "ORDER BY id" — AUTOINCREMENT insertion order, never
timestamp — precisely so a non-monotonic clock cannot sort an assistant
tool_calls row after its tool response, "breaking tool-call/response
adjacency and triggering an HTTP 400 on replay". Replaying the spool in
filename order writes exactly that inversion into the row ids, so the
session errors out on the next turn.
The payloads already carry the ordering fields: spool_dropped_transcript_
message stamps every one with "ts" and a monotonic "seq". The sibling
consumer of the same spool in this file, drain_transcript_spool, already
sorts on them via sorted(entries, key=lambda e: e[:3]); only the restart
path ignored them. Order on (ts, seq, filename) so the live drain and the
cross-restart drain agree.
Ordering by the payload rather than by a new file-naming scheme also
recovers spool files that were written before this change, which are the
ones an affected user already has on disk.
Payloads that cannot be parsed keep their previous treatment: they sort
last, are handed back unparsed, and are re-read inside the loop so the
existing handler reports and preserves them unchanged.
…pooled messages The cross-restart replay of a cap-dropped transcript message forwarded only four fields to append_message — session_id, role, content, timestamp — and then unlinked the spool file, making the loss permanent. Everything else on the message was discarded: tool_calls, tool_call_id, tool_name, the reasoning columns, codex items, platform_message_id, observed, and the api_content sidecar. append_message already accepts all of them, and the payload already carries them, because spool_dropped_transcript_message writes the full transcript message dict. Two of the dropped fields are load-bearing rather than decorative. Losing tool_calls/tool_call_id orphans a tool result from the call it answers, which is the adjacency SessionDB's "ORDER BY id" exists to protect. Losing api_content contradicts the requirement stated at the live writer, that the sidecar "must survive any gateway-side persistence path or the next turn's replay diverges at this row" — and this is such a path. The same spool is drained two ways: by drain_transcript_spool during live operation, which replays through SessionStore._append_transcript_message and keeps every field, and by this function after a restart. Same files, same payloads, different fidelity. Mirror _append_transcript_message field for field so the outcome no longer depends on whether the gateway happened to restart, including its role gate on the assistant-only reasoning columns and its message_id fallback for platform_message_id. Fields are whitelisted explicitly rather than splatted from the message dict: the payload is arbitrary JSON from disk, and an unexpected key would raise TypeError and abort the whole recovery pass. content is now passed through as-is instead of being coerced to "", since an assistant tool-call row legitimately has no content.
…pend Ordering the spool files fixes the happy path only. If append_message fails partway through — the DB is still unhealthy, which is the situation that produced the spool in the first place — the loop logs the failure, keeps that file for a later retry, and then carries on and writes the messages that come after it. Those later messages land now; the failed one lands on some future start. Because SessionDB orders a conversation by AUTOINCREMENT id, the retried message then gets a HIGHER row id than the messages it originally preceded, and the inversion this pass just prevented is written to disk anyway — this time permanently, since both files are gone. drain_transcript_spool, the live drain of the same spool, already states the rule: "On the first replay failure the drain stops and remaining files are kept for the next attempt (the DB is likely still unhealthy)." Apply it here too. The block is per-session rather than global. Replay order is only defined within a session, and this function drains every session's spool in one pass, so a single unhealthy session must not strand the others' messages on disk. Non-transcript pending payloads are unaffected.
Names the spool files so that filename order is the exact reverse of drop order, which is what uuid4 names produce on average, and asserts the replay comes back in drop order. A second case gives three payloads the same one-second ts so only the monotonic seq can separate them. Both fail on the previous sorted(glob()) implementation. A third case pins the pre-existing treatment of a corrupt payload — reported by the loop's own handler and left on disk — so the new ordering pass cannot silently swallow one. Lives in its own file rather than tests/gateway/test_shutdown_flush.py to keep the restart-recovery cases together with the spool fixtures they need.
…overy Round-trips an assistant tool-call row carrying every field the live writer persists and asserts each one reaches append_message: tool_calls, tool_call_id, tool_name, the reasoning and codex columns, platform_message_id, observed, timestamp, and the api_content sidecar. Also pins content=None passing through uncoerced, since an assistant tool-call row has no content, and the role gate that keeps the assistant-only reasoning columns off a user row. A separate case covers a failed replay: with the first of two messages for one session rejected, neither is written and both spool files stay on disk, while a third message for an unrelated session still recovers. All but the two behaviour-preservation cases fail on the previous implementation.
There was a problem hiding this comment.
Pull request overview
This PR fixes restart-time replay of cap-dropped transcript spool files so recovered transcripts preserve drop order, structured message fidelity, and failure semantics consistent with the live drain path. It tightens the gateway’s shutdown/restart recovery so that a restart cannot durably scramble transcripts or strip tool-call adjacency-critical fields.
Changes:
- Order restart-time replay of spooled transcript messages by
(ts, seq, filename)by parsing payload metadata rather than relying on UUID filename sort. - Replay transcript spool messages with the full set of
SessionDB.append_messagefields (tool call metadata, reasoning columns with role gating,api_contentsidecar, etc.). - Stop replaying further messages for a session after the first append failure, preserving remaining spool files for retry; add a dedicated regression test suite.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| gateway/shutdown_flush.py | Orders restart-time spool replay by payload metadata, replays full-fidelity transcript message fields, and blocks per-session replay after first failure. |
| tests/gateway/test_shutdown_flush_recovery.py | Adds focused regression tests for ordering, field fidelity, corrupt payload handling, and failure-stop semantics in restart-time recovery. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| "observed": bool(message.get("observed")), | ||
| "timestamp": message.get("timestamp") or payload.get("ts"), | ||
| # The api_content sidecar is the exact bytes sent to the API for this |
There was a problem hiding this comment.
Good catch — verified and fixed in 62206b0bcd7 (current head).
timestamp now falls back only when the field is genuinely absent:
timestamp = message.get("timestamp")
if timestamp is None:
timestamp = payload.get("ts")Regression test: test_epoch_zero_timestamp_is_not_replaced_by_the_fallback in tests/gateway/test_shutdown_flush_recovery.py. It spools a message with timestamp: 0 under a payload ts of 999 and asserts 0 reaches append_message. It fails on the previous or expression and passes now.
One deliberate non-change for the record: platform_message_id a few lines up still uses or. That is not an oversight — it mirrors SessionStore._append_transcript_message (gateway/session.py:3671) verbatim, and having the restart drain agree with the live drain field for field is the point of this PR. Diverging there would reintroduce the split this change removes.
|
CI audit — the red test slices are an infrastructure failure, not a test failure. The failing jobs all die in the That is a 503 from the GitHub release CDN serving The affected slices are non-deterministic across runs of the same code, which is the signature of a flaky external download rather than a defect:
Same tree apart from a three-line timestamp fix, different random victims. Every slice that got past the download passed. On That same slice also carried I can't re-run upstream workflows from a fork. If the download keeps flaking, say the word and I'll push a rebase to retrigger. |
… absent
_transcript_append_kwargs chose the spooled message's timestamp with
`message.get("timestamp") or payload.get("ts")`, carried over from the
code this PR replaces. Truthiness is the wrong test: epoch 0 is a valid
timestamp and would be silently rewritten to the payload's ts, which is
the fidelity loss this PR exists to remove.
Fall back only when the field is genuinely absent.
|
Thanks for the credit and the careful re-grounding against current main. Agreed that #78323 is stale for the right reasons (post- Closing #78323 as superseded by this PR (FTS write-path half of #78182 already landed via #82719). |
|
@686f6c61 Appreciated — and thanks for closing #78323 cleanly rather than leaving it to rot. For the record I re-checked both: #78323 is closed unmerged (2026-08-12T20:44:12Z), and the FTS write-path half of #78182 did land via #82719, merged 2026-08-09T21:10:21Z. So the split you describe is exactly what happened. Confirming the
A filename-time scheme would have worked for messages written after it shipped and for nothing already spooled, which is the population that matters on a restart. Correction to my CI audit above — it is now stale, and the diagnosis in it no longer describes this PR's checks. That comment blamed the
All 12
So: a different third-party CDN artifact than last time ( I have left the previous audit's table in place rather than editing it, but it describes the earlier run and should not be read as current. |
PR: fix(gateway): replay the transcript spool in drop order with full fidelity on restart (supersedes #78323)
|
… crashes the remaining file scans Same class as the Bot Chat drain wedge already on this branch: every JSON-file scan guarded "did it parse?" and then assumed the value was a dict. A file holding `42`, `"oops"` or `[1,2,3]` (corruption, truncated write, foreign tool) passed the guard and raised AttributeError/TypeError at the first `.get()`, usually before a single healthy sibling was processed. Each site now treats a non-object payload like a corrupt file under that subsystem's existing policy: - tools/bot_relay.py::_expire_if_stale / claim_pending_envelopes — the envelope is skipped by the sweep and not claimed (same as unparseable). - tools/browser_lightpanda.py::reap_orphaned_lightpanda — record unlinked, scan continues. - tools/write_approval.py::list_pending / get_pending — record skipped with the existing "unreadable pending record" warning / None. - tui_gateway/methods_session.py::_legacy_spawn_tree_entry / spawn_tree.load — scalar snapshot reads as empty / returns the existing 5000 error instead of violating the SpawnTreeLoadResult contract. - hermes_cli/local_runtime/binaries.py::manifest_verified — False. - plugins/platforms/a2a/protocol.py::load_conversation — non-dict lines are dropped, keeping the declared list[dict] return. - batch_runner.py::_load_dataset / _scan_completed_prompts_by_content / _combine_batch_files — line skipped and counted as filtered. - trajectory_compressor.py::process_entry_async — scalar entry passed through unchanged. Ported from the source hunks of PR #114241; its gateway/shutdown_flush.py drain_transcript_spool hunk is left to open PR #84785, and its recover_pending_to_db / cron / bot_live_delivery / bot_mode_dm hunks are already on this branch or on main. (cherry picked from commit d4b5456)
…orts drain_transcript_spool A scalar or list JSON file under pending_messages/ passed json.loads and then hit `payload.get(...)`, raising AttributeError out of the drain and leaving every healthy cap-dropped message unreplayed. Such a file cannot be attributed to any session, so it is skipped exactly like unparseable JSON (file preserved), and the remaining spool entries replay in order. Closes the last open drain_transcript_spool atom of #114240 in this PR instead of deferring it to #84785.
… crashes the remaining file scans Same class as the Bot Chat drain wedge already on this branch: every JSON-file scan guarded "did it parse?" and then assumed the value was a dict. A file holding `42`, `"oops"` or `[1,2,3]` (corruption, truncated write, foreign tool) passed the guard and raised AttributeError/TypeError at the first `.get()`, usually before a single healthy sibling was processed. Each site now treats a non-object payload like a corrupt file under that subsystem's existing policy: - tools/bot_relay.py::_expire_if_stale / claim_pending_envelopes — the envelope is skipped by the sweep and not claimed (same as unparseable). - tools/browser_lightpanda.py::reap_orphaned_lightpanda — record unlinked, scan continues. - tools/write_approval.py::list_pending / get_pending — record skipped with the existing "unreadable pending record" warning / None. - tui_gateway/methods_session.py::_legacy_spawn_tree_entry / spawn_tree.load — scalar snapshot reads as empty / returns the existing 5000 error instead of violating the SpawnTreeLoadResult contract. - hermes_cli/local_runtime/binaries.py::manifest_verified — False. - plugins/platforms/a2a/protocol.py::load_conversation — non-dict lines are dropped, keeping the declared list[dict] return. - batch_runner.py::_load_dataset / _scan_completed_prompts_by_content / _combine_batch_files — line skipped and counted as filtered. - trajectory_compressor.py::process_entry_async — scalar entry passed through unchanged. Ported from the source hunks of PR #114241; its gateway/shutdown_flush.py drain_transcript_spool hunk is left to open PR #84785, and its recover_pending_to_db / cron / bot_live_delivery / bot_mode_dm hunks are already on this branch or on main. (cherry picked from commit d4b5456)
…orts drain_transcript_spool A scalar or list JSON file under pending_messages/ passed json.loads and then hit `payload.get(...)`, raising AttributeError out of the drain and leaving every healthy cap-dropped message unreplayed. Such a file cannot be attributed to any session, so it is skipped exactly like unparseable JSON (file preserved), and the remaining spool entries replay in order. Closes the last open drain_transcript_spool atom of #114240 in this PR instead of deferring it to #84785.
…drains A JSON file that parses but is not an object (a scalar, string, or list from corruption or a foreign writer) slipped past every scan's "bad JSON" guard and crashed the sweep at the first subscript: - cron/bot_chat_pending: one such receipt wedged the deferred Bot Chat drain on EVERY tick — sorted() raised TypeError on record["sequence"] before any sibling was delivered, and defer() could not allocate a sequence either; violates the file's own "one bad file must not wedge the dir" rule. - Exact-id reads in bot_chat_delivery, scheduler_delivery, bot_live_delivery, and bot_mode_dm crashed with TypeError instead of the established "different payload" ValueError; they now fail closed and never overwrite a malformed receipt. - The same shape wedged shutdown-flush recovery (no per-file guard at all — even unparseable JSON aborted the pass), the transcript spool drain, bot_relay outbox claim + stale sweep, the lightpanda reaper, write-approval listing, spawn_tree.list/load (a scalar snapshot would violate the declared RPC result contract), manifest_verified, a2a load_conversation, batch_runner dataset/resume/combine scans, and trajectory_compressor pass-through. Each site now rejects non-dict payloads under its own existing contract: warn-and-preserve evidence for receipt dirs, quarantine on claim for the relay outbox, delete under the existing cleanup policy for reaper state, fail closed for exact-id reads. Malformed lines in JSONL scans are skipped with honest filtered_entries bookkeeping. Regression tests cover every site, including an end-to-end drain_in_background run that delivers the healthy sibling with a non-dict receipt in the dir. Related: NousResearch#87661 covers the recovery-processing boundary of one of these sites (recover_pending_to_db) with a broader except; NousResearch#84785 carries an equivalent check inside its spool-ordering rewrite. The other sites are uncovered.
…drains A JSON file that parses but is not an object (a scalar, string, or list from corruption or a foreign writer) slipped past every scan's "bad JSON" guard and crashed the sweep at the first subscript: - cron/bot_chat_pending: one such receipt wedged the deferred Bot Chat drain on EVERY tick — sorted() raised TypeError on record["sequence"] before any sibling was delivered, and defer() could not allocate a sequence either; violates the file's own "one bad file must not wedge the dir" rule. - Exact-id reads in bot_chat_delivery, scheduler_delivery, bot_live_delivery, and bot_mode_dm crashed with TypeError instead of the established "different payload" ValueError; they now fail closed and never overwrite a malformed receipt. - The same shape wedged shutdown-flush recovery (no per-file guard at all — even unparseable JSON aborted the pass), the transcript spool drain, bot_relay outbox claim + stale sweep, the lightpanda reaper, write-approval listing, spawn_tree.list/load (a scalar snapshot would violate the declared RPC result contract), manifest_verified, a2a load_conversation, batch_runner dataset/resume/combine scans, and trajectory_compressor pass-through. Each site now rejects non-dict payloads under its own existing contract: warn-and-preserve evidence for receipt dirs, quarantine on claim for the relay outbox, delete under the existing cleanup policy for reaper state, fail closed for exact-id reads. Malformed lines in JSONL scans are skipped with honest filtered_entries bookkeeping. Regression tests cover every site, including an end-to-end drain_in_background run that delivers the healthy sibling with a non-dict receipt in the dir. Related: NousResearch#87661 covers the recovery-processing boundary of one of these sites (recover_pending_to_db) with a broader except; NousResearch#84785 carries an equivalent check inside its spool-ordering rewrite. The other sites are uncovered.
supersedes #78323Credit
@686f6c61's #78323 found both of these defects first, and diagnosed both correctly. Its
_next_spool_file_id()docstring names the ordering bug exactly — "Recovery walkssorted(glob("*.json")). uuid4 names re-insert out of order after a burst of pending-cap spools" — and itsappend_kwargsloop is a direct attempt at the fidelity bug. That PR is not stale because it was wrong. It is stale because the ground moved under it.de0f20ff05b(2026-08-09) landed the runtime transcript spool independently:spool_dropped_transcript_message/drain_transcript_spool, plus a newTRANSCRIPT_CAP_DROP_REASONbranch insiderecover_pending_to_db. That superseded #78323's ownspool_transcript_messages. #78323 now readsmergeable: false,mergeable_state: dirty, has been cold since 2026-08-05 with zero reviews, and its diff still deletesimport uuidand rewrites regions that no longer exist.Both of the defects it identified are still live on main. This PR ships them against the code that exists now. Three things had to change in the execution, and they are matters of substance rather than rebase mechanics:
time_ns+ counter). Main's payloads already carrytsand a monotonicseq, stamped byspool_dropped_transcript_message. Sorting on the payload orders the spool files a user already has on disk — which are exactly the files the incident produced, and which are namedpending-<uuid4>.jsonand cannot be retroactively renamed. A filename scheme only helps files written after it ships.data["text"]branch. On current main a cap-drop payload never reaches that code: it leaves theTRANSCRIPT_CAP_DROP_REASONbranch viacontinueatshutdown_flush.py:350.drain_transcript_spoolalready orders the identical payloads withsorted(entries, key=lambda e: e[:3])over(ts, seq, path.name). Matching it makes the two drains agree instead of introducing a third convention.What does this PR do?
gateway/shutdown_flush.pyhas two consumers of the transcript spool, and they disagree about what the spool means.drain_transcript_spool(:193) is the live drain, called fromgateway/session.pyonce a transcript flush succeeds. It sorts on(ts, seq, path.name)and replays the full message dict throughSessionStore._append_transcript_message, which forwards 15 fields.recover_pending_to_db(:286) is the restart drain, called unconditionally fromgateway/run.py:27979afterrunner.start(). On the same files, it did two things wrong:1 — Order.
flush_files = sorted(flush_dir.glob("*.json"))(:308)._write_payloadnames filespending-<uuid4().hex>.json, so this sort is random. The payloads carryts/seq; this path ignored them.This is not cosmetic.
SessionDBrestores a conversation withORDER BY id— AUTOINCREMENT insertion order, never timestamp — and the comment athermes_state.py:8691-8699says why: sorting otherwise risks "breaking tool-call/response adjacency and triggering an HTTP 400 on replay." Replaying the spool in filename order writes that inversion straight into the row ids.2 — Fidelity. The replay forwarded four fields —
session_id,role,content,timestamp— and thenpath.unlink()at:349made the loss permanent. Discarded:tool_calls,tool_call_id,tool_name, the reasoning and codex columns,platform_message_id,observed, and theapi_contentsidecar.append_message(hermes_state.py:7643) already accepts every one, and the payload already carries them.Two of those are load-bearing. Losing
tool_calls/tool_call_idorphans a tool result from the call it answers — the exact adjacencyORDER BY idexists to protect. Losingapi_contentcontradicts the requirement stated at the live writer (gateway/session.py:3673): the sidecar "must survive any gateway-side persistence path or the next turn's replay diverges at this row." This is such a path.3 — Partial-failure ordering. Ordering the files only fixes the happy path. If
append_messagefailed partway through — the DB is still unhealthy, which is the situation that created the spool — the loop kept going and wrote the later messages. The failed one is retried on a future start and gets a higher row id than the messages it originally preceded, so the inversion lands anyway, permanently.drain_transcript_spoolalready states the rule for the same spool: "On the first replay failure the drain stops and remaining files are kept for the next attempt (the DB is likely still unhealthy)."This restores the contract
de0f20ff05bset out in its own commit message — to "drain and replay spooled messages in drop order", with "replay failures keep the spool files for the next attempt" — on the restart path, which is the path that never implemented it.User-visible symptom: after recovering from the FTS corruption of #78182, restarting the gateway brings the session back scrambled, with tool calls stripped of their results, and the next turn fails with an HTTP 400.
Related Issue
Refs #78182, #82616 (both closed by
de0f20ff05b; the two defects above are in the recovery path that commit added and are still present onmain).Type of Change
Changes Made
gateway/shutdown_flush.py—_order_flush_files()+_sort_number(): parse each recovery payload once and order by(ts, seq, filename), mirroringdrain_transcript_spool. Unparseable payloads sort last and are handed back unparsed so the existing loop reports and preserves them exactly as before.gateway/shutdown_flush.py—_transcript_append_kwargs(): build theappend_messagecall by mirroringSessionStore._append_transcript_messagefield for field, including its role gate on the assistant-only reasoning columns and itsmessage_idfallback forplatform_message_id. Fields are whitelisted explicitly rather than splatted from the message dict, because the payload is arbitrary JSON from disk and an unexpected key would raiseTypeErrorand abort the recovery pass.gateway/shutdown_flush.py—blocked_sessions: after a failed replay, skip that session's remaining spooled messages and leave them on disk. Scoped per session, since replay order is only defined within a session and this function drains every session in one pass. Non-transcript pending payloads are unaffected.tests/gateway/test_shutdown_flush_recovery.py— new file, 10 tests.Sibling sweep:
recover_pending_to_dbanddrain_transcript_spoolare the only two consumers of this spool (grepforTRANSCRIPT_CAP_DROP_REASON/pending-*.jsonover non-test sources).drain_transcript_spoolwas already correct on all three points, which is where the idiom came from; this PR brings the second consumer up to it and touches nothing else.How to Test
Fails-before / passes-after, verified per hunk by reverting each production change individually against the rest of the branch:
_order_flush_files)test_replays_in_drop_order_when_names_disagree,test_seq_breaks_ties_within_the_same_second_transcript_append_kwargs)message_idfallback, epoch-0 timestampblocked_sessions)test_failure_blocks_later_messages_for_that_session_onlyorigin/main)The 2 that pass on
mainare deliberate behaviour-preservation assertions, not regression coverage: corrupt payloads stay on disk and are still reported, andpayload["ts"]remains thetimestampfallback.One planned commit was dropped after checking it. "Keep the spool file when replay fails" looked like a fourth defect, but on
mainpath.unlink()already sits inside thetryafterappend_message, so a failed replay never reaches it. Probed directly against unmodifiedorigin/main— the file is retained and the test passes without any change. It would have been a commit that passes with or without the fix, so it is not here.Checklist
Code
fix(scope):,feat(scope):, etc.)pytest tests/ -qand all tests pass — ran the targeted suites listed under "How to Test" (20 passed), not the full tree locally; leaving this unticked rather than claiming it.Documentation & Housekeeping
docs/, docstrings) — docstrings on the new helpers and onrecover_pending_to_dbcli-config.yaml.exampleif I added/changed config keys — N/A, no config keysCONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — N/Ajson/pathlib/sort, no platform-specific calls addedRelated / Positioning
Deduped two ways, because neither net is sufficient alone —
gh search prsindexes PR title/body text and never changed paths, whilegh pr list --json filesreturns newest-first and only samples the head of a ~17.8k-PR queue.By file —
gateway/shutdown_flush.pyis touched by exactly 5 open PRs: #78323, #75536, #69980, #83620, #84131.By text/symbol —
recover_pending_to_db→ #75536, #78323.drain_transcript_spool→ #84131.shutdown_flush,TRANSCRIPT_CAP_DROP_REASON,spool_dropped_transcript_message→ nothing. Note the text net missed #83620 entirely, because that PR's title is aboutSessionDBconnection leaks; only the by-file pass caught it. Conversely the by-file recency query returned empty here — its 300-PR window spans #84168–#84776, so every one of these five is older than it can see. Both passes were necessary.Dispositions:
dirty, cold since 08-05, zero reviews. Credited above.@@ -316,6 +316,14 @@, but for a different concern: closing an ownedSessionDBon exception paths. Its hunks are at:316,:389,:396; mine are at:308and:342. Disjoint, and complementary.shutdown_flush.pyhunks are@@ -190,7 @@and@@ -199,14 @@, both insidedrain_transcript_spool, and it fixes ordering between the spool tier and the in-memory queue during live operation. It contains zero references torecover_pending_to_db,flush_files, orTRANSCRIPT_CAP_DROP_REASON. Neither PR changes a line the other touches.session_keyresolution on the legacy branch.Structural argument, verified rather than asserted: the
TRANSCRIPT_CAP_DROP_REASONbranch this PR fixes did not exist untilde0f20ff05b(2026-08-09). Checking each rival head withgit merge-base --is-ancestor de0f20ff05b <head>:The three that predate it cannot touch this branch at all. The two that postdate it are the two disclosed above, and both are line-disjoint from this diff.
Test files: deliberately placed in a new file.
tests/gateway/test_shutdown_flush.pyis appended near EOF by #75536, #83620 and #69980, andtests/gateway/test_pending_queue_spool.pyby #84131. This PR touches neither.Commits
Each independently green (verified by checking out every intermediate SHA and running the touched suites — 11 / 11 / 11 / 14 / 20 / 21 passing):
fix(gateway): replay cap-dropped transcript spool files in drop order on restartfix(gateway): preserve structured transcript fields when recovering spooled messagesfix(gateway): stop a session's spool replay after the first failed appendtest(gateway): cover spool replay ordering across a restarttest(gateway): cover field fidelity and failure handling in spool recoveryfix(gateway): fall back to the payload clock only when a timestamp is absent— addresses the review finding below; epoch 0 is a valid timestamp and the inheritedorexpression would have rewritten it.