fix(sessions): recover orphaned compression parents without continuations (#80337) - #80487
Conversation
…open fix) Adversarial review of the salvaged recovery found a reachable fail-open: compression continuations inherit the rotated agent's model_config verbatim (publish_compression_child callers pass agent._session_init_model_config), so a delegate subagent's continuation carries _delegate_from=<the delegate's own parent>. The marker-PRESENCE filters in reopen_orphaned_compression_session and find_live_compression_child misclassified such a REAL continuation as a delegate child: - reopen: parent 'orphaned' -> reopened while a live continuation exists -> two live heads in one lineage (verified with a live repro) - find_live: adoption misses the continuation (fail-closed, masked the fork pre-PR; the PR made it active) Fix: markers only disqualify a child when they point at the queried parent (shared _NON_CONTINUATION_CHILD_FILTER_SQL fragment, also resolving the duplicated-SQL drift risk flagged by the reuse reviewer). Both directions regression-tested: reopen fails closed on an inherited-marker continuation; find_live adopts it. Also from review: reopen-failure log raised debug->warning (the failure hard-fails the turn moments later), commit-semantics hardening comment on the lease DELETE path, blank-line nit. The three read-only projection walks (get_compression_tip, list_sessions_rich chain, resume walk) share the marker-presence shape but fail closed (skip a continuation -> resume shows the parent), and the fixed adoption path self-heals that case at turn start; left as-is.
|
Clean close of the recovery member — the lease-reclaim-inside-one-txn and the marker-target (not presence) disqualification are both the right calls, and factoring Which leaves two residuals from the class map on #80337 (posted just before this PR):
|
|
Closing the loop on the reader-boundary offer above: reviewed the three reader fragments in depth — #80423 already routes ACP resume through the canonical resolver (endorsed there), and the heartbeat member has a duplicate pair (#80225/#80208, adjudication with a compose suggestion posted on #80225). A fourth implementation would be noise, so the offer resolves as coordination instead. The cross-profile source hypothesis on #80337 remains the open tap question. |
…ousResearch#80921) Deterministic, LLM-free conformance cells against the real SessionDB with real SIGKILL mid-write, per the tracking issue's spot-probe method: - cell 1: acknowledged-append durability + recovery determinism (adapted from the issue's 29.5K probe, scaled kill window, identical assertions) - cell 2: consume-once under 8-process concurrent claim_handoff - cell 3 (new): compression-rotation atomicity — never a compression-ended parent without a continuation (NousResearch#80337 contract; NousResearch#80487 recovery context) - cells 4-5: documented stubs interlocked with NousResearch#82956-NousResearch#82959 and NousResearch#83197/NousResearch#83557 Journal-mode matrix (resolver default / DELETE / WAL-with-skip-gate) per cell; every wait deadline-bounded; writers asserted alive at kill time.
Summary
Recovers compression-parent sessions whose continuation was never persisted — the "orphan" state from #80337 where every write raises
CompressionSessionClosedError, every turn endssession_persistence_failed, and the only fix was manual SQL onstate.db. Salvages @izumi0uu's #80380 (2 commits, authorship preserved) plus one follow-up fixing a reachable fail-open found in adversarial review.Changes
hermes_state.py:SessionDB.reopen_orphaned_compression_session()— reopens a compression-ended parent only when, inside oneBEGIN IMMEDIATEwrite txn: no canonical continuation child exists (ended or live), and no active compression lease exists (expired leases are reclaimed holder-identity-and-all, so a stale compressor's refresh can't resurrect them). Fails closed on any ambiguity. (@izumi0uu)agent/conversation_compression.py:recover_rotated_compression_session()invokes the reopener only after child adoption finds nothing and no live lease remains — recovery happens transparently at turn start. (@izumi0uu)model_configverbatim, so a delegate subagent's continuation carries_delegate_from=<the delegate's own parent>. The original marker-presence filters misclassified such a real continuation as a delegate child — reopen returned True with a live continuation present, forking the lineage (verified with a live repro). Markers now only disqualify a child when they point at the queried parent, via a shared_NON_CONTINUATION_CHILD_FILTER_SQLfragment used by bothfind_live_compression_child(adoption) and the reopener — also removing the duplicated-SQL drift risk. Reopen-failure log raised debug→warning (the failure hard-fails the turn moments later).The three read-only projection walks (
get_compression_tip,list_sessions_richchain, resume walk) keep marker-presence semantics: they can only under-follow (fail closed → resume shows the parent), and the fixed adoption path self-heals at first turn.Validation
_delegate_frompublish_compression_child) serializes viaBEGIN IMMEDIATE— no window;model_configshape probes all fail closed except the inherited-marker case fixed here; archived-rows/idempotency/lease races all passfind_live_compression_child+0.005 ms vs before (identical query plan); reopener runs once per recovery attempt, never inside the retry looptests/state/,tests/agent/test_compression_orphan_recovery.py,tests/hermes_state/; 69 passed intests/gateway/test_session.py+tests/agent/test_turn_context.py; regression pair added for the inherited-marker case (reopen fails closed / adoption succeeds)Credit
Fixes #80337. Supersedes #80380 (salvage), #80399.