diff --git a/hermes_state_portability.py b/hermes_state_portability.py index f4cdbc6044..ca8df96809 100644 --- a/hermes_state_portability.py +++ b/hermes_state_portability.py @@ -394,6 +394,26 @@ class SessionPortabilityMixin: if not seg_id: continue try: + # TOCTOU close-out: the guard above compared EXPORT-TIME + # counts, but another backend can append donor messages + # between export and this loop. Re-read both stores right + # before stamping; a donor-ahead signal here skips the + # stamp so growth never lands behind a non-recoverable + # archive. (Count comparison cannot see equal-count + # CONTENT divergence — e.g. a donor rewind+rewrite; that + # residual case is accepted: bytes stay in the donor + # store either way, only reachability differs.) + donor_now = len(donor_db.get_messages(seg_id)) + local_now = len(self.get_messages(seg_id)) + if donor_now > local_now: + retire_ok = False + logger.warning( + "adoption divergence at retire time: donor " + "segment %s grew to %d messages (local %d) — " + "leaving donor unretired", + seg_id, donor_now, local_now, + ) + continue # First end_reason wins in end_session(); reopen first so # the adoption boundary is stamped even on ended segments # (e.g. 'compression' parents). diff --git a/tests/tui_gateway/test_stranded_session_adoption.py b/tests/tui_gateway/test_stranded_session_adoption.py index 874673c4cc..a1ae859e11 100644 --- a/tests/tui_gateway/test_stranded_session_adoption.py +++ b/tests/tui_gateway/test_stranded_session_adoption.py @@ -389,3 +389,35 @@ def test_donor_retired_reports_false_on_retirement_failure(stores, monkeypatch): assert result["adopted"] is True assert result["donor_retired"] is False assert not default_db.get_session(STRANDED_ID)["archived"] + + +def test_donor_growth_between_export_and_retire_blocks_retirement(stores, monkeypatch): + """TOCTOU close-out (review on #93369): messages appended to the donor + AFTER export but BEFORE retirement must block the non-recoverable + stamp — the retire loop re-reads live counts, not export-time ones.""" + default_db, profile_db = stores + _seed_stranded(default_db) + + real_export = default_db.export_session_lineage + + def _export_then_append(session_id): + payload = real_export(session_id) + # Another backend appends AFTER the export snapshot is taken. + default_db.append_message(STRANDED_ID, "user", "raced question") + default_db.append_message(STRANDED_ID, "assistant", "raced answer") + return payload + + monkeypatch.setattr(default_db, "export_session_lineage", _export_then_append) + result = profile_db.adopt_session_lineage_from(default_db, STRANDED_ID) + + # Adoption itself still serves (profile copy has the snapshot)... + assert result["adopted"] is True + # ...but the grown donor is NOT stamped behind a non-recoverable archive. + assert result["donor_retired"] is False + donor = default_db.get_session(STRANDED_ID) + assert not donor["archived"], "raced donor growth must stay reachable" + assert len(default_db.get_messages(STRANDED_ID)) == 8 + # The next resume retries: donor now ahead → export-time guard catches it. + second = profile_db.adopt_session_lineage_from(default_db, STRANDED_ID) + assert second["donor_retired"] is False + assert not default_db.get_session(STRANDED_ID)["archived"]