Merge PR #7682 into stage/7682-stale-cancel

This commit is contained in:
nesquena-hermes committed 2026-09-23 18:23:27 +00:00
commit e3e304edae
4 files changed
+1764

No files matched your search

+16
View File
@@ -38,6 +38,7 @@ from api.config import (
from api.helpers import _redact_text, redact_session_data
from api.models import clear_process_wakeup_pause, get_session, merge_session_messages_append_only
from api.run_journal import RunJournalWriter, bound_run_journal_snapshot_args
from api.turn_journal import append_turn_journal_event_for_stream
logger = logging.getLogger(__name__)
@@ -1404,6 +1405,21 @@ def _run_gateway_chat_streaming(
if cancel_event.is_set():
_restore_cancelled_success_writeback()
return
# #6366 re-gate: record the durable same-stream completion
# event in the crash-safe turn journal. The run journal's
# terminal state is only reached on its own ``stream_end``
# write path, so a Gateway run whose terminal write is lost
# would otherwise leave no completion evidence at all and
# stale-cancel recovery would re-append a duplicate
# recovered row after the valid final answer.
try:
append_turn_journal_event_for_stream(
session_id,
stream_id,
{"event": "completed", "created_at": time.time()},
)
except Exception:
logger.debug("Failed to append completed turn journal event", exc_info=True)
success_writeback_committed = True
try:
from api.goals import evaluate_goal_after_turn, has_active_goal
+256
View File
@@ -2442,6 +2442,191 @@ def _latest_user_matches_pending_text(messages, pending_text):
return False
def _pending_active_turn_token(session):
"""Return the exact WebUI active-turn token for the pending turn.
The token is ``"<stream_id>:<pending_started_at>"`` — the same value the
eager user-message checkpoint and the agent-result merge stamp onto the
materialized user row (``stamp_message_source``). It is unforgeable
per-turn identity: two distinct streams that submit the same prompt
inside the same second produce different tokens.
"""
from api.process_event_utils import build_active_turn_token
return build_active_turn_token(
getattr(session, 'active_stream_id', None),
getattr(session, 'pending_started_at', None),
)
def _transcript_user_row_is_pending_turn(message, session, pending_token) -> bool:
"""Return True only when a transcript user row provably IS the pending turn.
Identity is bound to the active-turn token, never to integer-second
timestamp equality: two different streams that send the same prompt
within one second truncate to the same ``int(timestamp)``, so the
checkpoint matcher alone would accept the *other* stream's row as the
pending turn. A token mismatch therefore always loses, and a row that
carries no token at all cannot be proven to be this turn — which means
the caller must recover, not suppress.
"""
if not isinstance(message, dict) or message.get('role') != 'user':
return False
row_token = message.get('_active_turn_token')
if pending_token:
if not row_token:
# Ambiguous legacy identity: the pending turn has a resolvable
# token but this row predates token stamping. We cannot prove it
# is the same turn, so recovery must run.
return False
return row_token == pending_token
if row_token:
# The row belongs to a different, token-bearing turn. Never let a
# same-second timestamp collision override that mismatch.
return False
# Neither side carries a token (legacy/dead-stream pending state): fall
# back to the strict content + timestamp + source + attachments
# checkpoint, which is the only identity signal available.
return _message_matches_pending_checkpoint(
message,
getattr(session, 'pending_user_message', None),
getattr(session, 'pending_started_at', None),
getattr(session, 'pending_user_source', None),
getattr(session, 'pending_attachments', None),
)
def _pending_turn_has_final_assistant_answer(messages, start_idx: int) -> bool:
"""Return True when the matched user turn ends in a genuine final answer.
Reuses the established final-answer semantics
(``_assistant_message_has_final_visible_text``) instead of "an assistant
row exists". An assistant row only settles the turn when it carries real
visible answer text: empty rows, tool-call-only rows, interim
``_partial`` progress rows, rows recovered from the run journal and
compaction reference cards are all rejected, so a turn that was
interrupted mid-tool-execution is never mistaken for a completed one.
The scan stops at the next real user row — a final answer that belongs
to a *later* turn must not settle this pending turn.
"""
from api.streaming import (
_assistant_message_has_final_visible_text,
_is_synthetic_control_message,
)
for later in messages[start_idx + 1:]:
if not isinstance(later, dict):
continue
if _is_synthetic_control_message(later):
continue
role = later.get('role')
if role == 'user':
if is_context_compression_marker(later):
# Synthetic compaction cards are not a user-turn boundary.
continue
return False
if role != 'assistant':
# Tool rows never prove a final answer.
continue
if (
later.get('_partial')
or later.get('_error')
or later.get('_recovered')
or later.get('_recovered_from_run_journal')
):
continue
if is_context_compression_marker(later):
continue
if _assistant_message_has_final_visible_text(later):
return True
return False
def _transcript_already_advanced_past_pending(session) -> bool:
"""#6366: a stale pending user message whose own user row already appears
in the durable transcript, followed by a genuine final assistant answer
inside that same user-turn boundary, means the transcript has already
advanced past this pending turn. Returning True here lets the recovery
path skip the recovered user / ``_partial`` clone / journal replay /
generic no-response error that would otherwise append after the valid
final answer. The tail-only check at line ~3485 (``session.messages[-1]``)
misses this case: when the pending turn's user row is older than
the transcript tail, the tail is a *newer* assistant row that
can never match the pending **user** checkpoint, and the recovery
path falls through into the append branch.
Identity is bound to the pending stream's exact active-turn token
(``_transcript_user_row_is_pending_turn``), so a repeated prompt from a
different stream — even one submitted in the same integer second —
can never be matched against this pending turn and silently dropped.
Completion requires a genuine final visible assistant answer
(``_pending_turn_has_final_assistant_answer``): empty, tool-call-only,
interim ``_partial``, journal-recovered and compaction tails all keep
recovery active, because that is precisely the interrupted-turn shape
the recovery path exists for.
The transcript heuristic alone is not durable: an unflagged interim
prose row ("Let me check the logs first.") followed by tool rows that
were never followed by a final answer in the transcript passes the
predicate as a "completed" turn, but the stream may have died mid-
tool and the run journal was never marked done. 9/23 re-gate: also
require durable same-stream terminal evidence. The run journal
cannot supply that evidence on this path — the caller in
``_apply_core_sync_or_error_marker`` already returns early whenever
the run journal reports ``completed`` for the stream, so the branch
that reaches this predicate can never observe that state (which is
why the previous run-journal gate was unreachable dead code). The
evidence therefore has to come from the turn journal, which records
the exact-stream ``completed`` event for the pending turn
(``_turn_journal_records_completion``). Without it, fall through to
the normal journal-recovery path so the partial output is replayed
and the interruption is marked.
The suppression is therefore deliberately conservative: whenever the
turn's identity, its completion, or its durable terminal evidence
cannot be positively proven, this helper returns False and the
prompt is recovered instead of discarded. A false negative leaves a
visible cosmetic duplicate; a false positive silently destroys a
prompt or a response.
Idempotent: this is a pure read that does not mutate the session.
Running recovery twice therefore produces the same outcome.
"""
pending_text = getattr(session, 'pending_user_message', None)
if not pending_text:
return False
messages = getattr(session, 'messages', None)
if not isinstance(messages, list):
return False
pending_token = _pending_active_turn_token(session)
for idx, message in enumerate(messages):
if not _transcript_user_row_is_pending_turn(message, session, pending_token):
continue
# Found the pending turn's own user row at ``idx``. A genuine
# final answer inside that turn's boundary is necessary but not
# sufficient: durable same-stream terminal evidence must also
# exist, otherwise unflagged interim prose
# ("Let me check the logs first.") followed by tool rows can be
# mistaken for a finished turn.
if not _pending_turn_has_final_assistant_answer(messages, idx):
continue
stream_id = getattr(session, 'active_stream_id', None)
# The turn journal — not the run journal — carries the
# completion evidence that is still observable here: the caller
# (``_apply_core_sync_or_error_marker``) already consumed the
# run-journal ``completed`` state on its own early return, so a
# run-journal gate at this depth can never fire.
if not _turn_journal_records_completion(session, stream_id):
# No durable same-stream terminal evidence — the turn may
# have died mid-tool. Fall through to the journal-recovery
# path instead of suppressing it.
return False
return True
return False
def _partial_message_signature(message: dict) -> tuple:
"""Return a stable identity for partial assistant markers recovered on load."""
if not isinstance(message, dict):
@@ -2643,6 +2828,54 @@ def _run_journal_terminal_state(session, stream_id: str | None) -> str | None:
return str(terminal.get('terminal_state') or '') or None
def _turn_journal_records_completion(session, stream_id: str | None) -> bool:
"""Return True when the crash-safe turn journal proves the turn for
``stream_id`` reached its terminal ``completed`` state.
The turn journal is written per stream/turn id by the exact-stream
workers and by :mod:`api.routes` on submission, so a ``completed``
event for the *pending stream id* is completion evidence that
survives a run-journal write failure: the run journal only reaches
``done`` / ``stream_end`` on its own write path, and a process that
dies between the turn journal write and the run-journal terminal
append leaves the run journal without terminal state while the turn
journal still records that the turn actually finished.
Bound strictly to ``stream_id`` — a ``completed`` turn belonging to
another stream of the same session never settles this one — and to
the latest turn recorded for that stream, so a turn that was
cancelled or crashed (``interrupted``) keeps recovery active even
when an earlier turn of the same stream completed normally.
"""
if not stream_id:
return False
try:
from api.turn_journal import (
derive_turn_journal_states,
read_turn_journal,
)
journal = read_turn_journal(session.session_id)
states, _ = derive_turn_journal_states(journal.get('events') or [])
except Exception:
return False
latest: tuple[float, str, dict] | None = None
for turn_id, event in states.items():
if str(event.get('stream_id') or '') != str(stream_id):
continue
try:
created_at = float(event.get('created_at') or 0)
except (TypeError, ValueError):
created_at = 0.0
# ``>=`` on both elements keeps the winner deterministic when two
# turns of the same stream share a ``created_at``.
candidate = (created_at, turn_id, event)
if latest is None or candidate[:2] >= latest[:2]:
latest = candidate
if latest is None:
return False
return latest[2].get('event') == 'completed'
def _recoverable_unsaved_gateway_terminal_error(
session,
stream_id: str | None,
@@ -3519,6 +3752,29 @@ def _apply_core_sync_or_error_marker(
)
return True
if not _tail_user_already_checkpointed:
# #6366 re-gate: when the durable transcript has already
# advanced past this pending turn into a newer settled
# user/assistant boundary, the recovery path must NOT
# append a recovered user row + ``_partial`` clone +
# journal replay + generic no-response error after the
# valid final answer. The tail-only check above misses
# that case (the tail is a newer assistant row that can
# never match the pending user checkpoint). Clear only
# the stale pending fields and return; the transcript is
# already correct and durable.
if _transcript_already_advanced_past_pending(session):
session.active_stream_id = None
session.pending_user_message = None
session.pending_attachments = []
session.pending_started_at = None
session.pending_user_source = None
session.save(touch_updated_at=touch_updated_at)
logger.info(
"Session %s: cleared stale pending state for stream %s — transcript already advanced past this turn",
sid,
_stream_id,
)
return True
_append_recovered_pending_turn(session, timestamp=_recovered_ts)
else:
recovered = {
File diff suppressed because it is too large. Load diff
+68
View File
@@ -25,6 +25,7 @@ from api.gateway_chat import (
webui_chat_backend_mode,
webui_gateway_chat_enabled,
)
from api.turn_journal import derive_turn_journal_states, read_turn_journal
def test_gateway_chat_backend_is_default_off_for_truthy_values():
@@ -397,6 +398,73 @@ def test_gateway_chat_worker_translates_sse_and_persists_session(tmp_path, monke
assert all(len(item) == 3 and item[2] for item in events)
def test_gateway_chat_worker_records_turn_journal_completion(tmp_path, monkeypatch):
"""#6366 re-gate: a successful Gateway run must record durable
same-stream completion evidence in the crash-safe turn journal.
The stale-cancel recovery predicate derives its completion evidence
from the turn journal, so a Gateway run that persisted its final
answer but lost the run journal's terminal write would otherwise
have no completion evidence at all — and recovery would re-append a
duplicate recovered row after the valid final answer.
"""
session_dir = tmp_path / "sessions"
session_dir.mkdir()
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
monkeypatch.setattr(models, "SESSIONS", OrderedDict())
class FakeResponse:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def __iter__(self):
yield b'data: {"choices":[{"delta":{"content":"hello"}}]}\n\n'
yield b"data: [DONE]\n\n"
monkeypatch.setenv("HERMES_WEBUI_GATEWAY_BASE_URL", "http://gateway.local")
monkeypatch.setenv("HERMES_WEBUI_GATEWAY_API_KEY", "secret-token")
monkeypatch.setattr(
gateway_chat.urllib.request,
"urlopen",
lambda req, timeout=0: FakeResponse(),
)
s = new_session()
stream_id = "stream-gateway-turn-journal-completed"
s.active_stream_id = stream_id
s.pending_user_message = "Say hello"
s.pending_attachments = []
s.pending_started_at = 456
s.save()
channel = create_stream_channel()
STREAMS[stream_id] = channel
gateway_chat._run_gateway_chat_streaming(
s.session_id,
"Say hello",
"test-model",
str(tmp_path),
stream_id,
[],
)
journal = read_turn_journal(s.session_id, session_dir=session_dir)
states, _ = derive_turn_journal_states(journal.get("events") or [])
stream_events = [
event
for event in states.values()
if str(event.get("stream_id") or "") == stream_id
]
assert stream_events, "expected turn journal events for the gateway stream"
assert any(
event.get("event") == "completed" for event in stream_events
), "the gateway success writeback must record a completed turn journal event"
def test_gateway_chat_worker_classifies_terminal_provider_error_without_text(tmp_path, monkeypatch):
"""Gateway terminal errors must survive an empty assistant stream."""
from unittest.mock import MagicMock