Files
hermes-agent/tests/tui_gateway/test_auto_continue.py
finn763 30e025c0c4 fix(tui_gateway): record a turn marker's writer and defer to it while alive (#94778)
A marker proved a turn had started, never that its writer had died, so a
second backend resuming the same session over a shared HERMES_HOME read a
sibling's live marker as crash evidence and started a duplicate turn over it.

record_turn_start now stamps writer_pid and, when readable, writer_start_time
(process create time, so a recycled pid cannot pass as the original writer)
and logs the identity — never the prompt. New marker_writer_state(entry)
answers alive/dead/unknown from active_sessions._pid_liveness, and
_maybe_schedule_auto_continue holds back when the writer is alive and is not
this process: no continuation, no "Resuming interrupted turn…" frame, and the
marker stays for its owner to clear.

Adds the two-backend regression: a real second process writes the marker and
keeps running while B resumes S (nothing scheduled, marker intact), then exits
and the same resume does schedule — so the live-writer gate is what defers B,
not some other switch.
2026-09-26 17:01:44 -05:00

626 lines
22 KiB
Python

"""Crash-interrupted turns auto-continue on the next session.resume.
A turn's durable marker (tui_gateway/turn_marker.py) is written when the turn
starts running and cleared when it concludes — success, handled error, or
interrupt. Only a process death leaves it behind, so a marker found at resume
time is positive proof the turn never finished. Contract pinned here:
* the marker module round-trips, prunes stale entries, and tolerates a
corrupt sidecar;
* ``_run_prompt_submit`` writes the marker before the turn and clears it in
the ``finally`` on both the success and exception paths (a handled failure
is a concluded turn — its terminal frame + retained snapshot own recovery);
* ``_maybe_schedule_auto_continue`` re-submits a fresh interrupted prompt as
a continuation note (display_kind ``auto_continue``), refuses stale /
disabled / crash-looping / already-running cases, and bounds attempts via
the marker's attempt counter;
* a marker whose writer is still alive is ownership evidence, not crash
evidence: a second backend resuming the same session over one HERMES_HOME
schedules nothing and leaves the marker for its owner to clear (#94778).
"""
from __future__ import annotations
import os
import subprocess
import sys
import threading
import time
import types
from pathlib import Path
import pytest
from tui_gateway import server
from tui_gateway.turn_marker import (
clear_turn_marker,
marker_writer_state,
read_turn_marker,
record_turn_start,
)
class _InlineThread:
"""Run threads synchronously so tests observe final state."""
def __init__(self, target=None, daemon=None, args=(), kwargs=None, name=None):
self._target = target
self._args = args
self._kwargs = kwargs or {}
def start(self):
if self._target is not None:
self._target(*self._args, **self._kwargs)
def is_alive(self):
return False
def join(self, timeout=None):
return None
def _session(agent=None, **extra):
return {
"agent": agent if agent is not None else types.SimpleNamespace(),
"session_key": "session-key",
"history": [],
"history_lock": threading.Lock(),
"history_version": 0,
"running": False,
"attached_images": [],
"image_counter": 0,
"cols": 80,
"slash_worker": None,
"show_reasoning": False,
"tool_progress_mode": "all",
"inflight_turn": None,
**extra,
}
@pytest.fixture()
def emits(monkeypatch):
captured: list = []
monkeypatch.setattr(
server,
"_emit",
lambda event, sid, payload=None: captured.append((event, sid, payload)),
)
return captured
@pytest.fixture()
def marker_home(monkeypatch, tmp_path):
"""Point the server's marker storage at a temp HERMES_HOME."""
monkeypatch.setattr(server, "_hermes_home", tmp_path)
return tmp_path
@pytest.fixture()
def turn_env(monkeypatch, tmp_path, marker_home):
"""Neutralize the turn pipeline's environment-heavy side paths."""
monkeypatch.setattr(server.threading, "Thread", _InlineThread)
monkeypatch.setattr(server, "_wire_callbacks", lambda sid: None)
monkeypatch.setattr(server, "_sync_agent_model_with_config", lambda sid, session: None)
monkeypatch.setattr(server, "_session_cwd", lambda session: str(tmp_path))
monkeypatch.setattr(server, "_register_session_cwd", lambda session: None)
monkeypatch.setattr(server, "_tts_stream_begin", lambda: None)
monkeypatch.setattr(server, "_sync_session_key_after_compress", lambda *a, **k: None)
monkeypatch.setattr(server, "_get_usage", lambda agent: {})
# ── Marker module ──────────────────────────────────────────────────────
def test_marker_roundtrip(tmp_path):
record_turn_start(tmp_path, "abc", "fix the bug", attempts=1)
marker = read_turn_marker(tmp_path, "abc")
assert marker is not None
assert marker["prompt"] == "fix the bug"
assert marker["attempts"] == 1
assert marker["started_at"] == pytest.approx(time.time(), abs=5)
clear_turn_marker(tmp_path, "abc")
assert read_turn_marker(tmp_path, "abc") is None
def test_marker_survives_corrupt_sidecar(tmp_path):
path = tmp_path / "desktop" / "interrupted_turns.json"
path.parent.mkdir(parents=True)
path.write_text("{not json")
assert read_turn_marker(tmp_path, "abc") is None
record_turn_start(tmp_path, "abc", "prompt")
assert read_turn_marker(tmp_path, "abc")["prompt"] == "prompt"
def _patch_local_interrupt(monkeypatch, session):
monkeypatch.setattr(server, "_tts_stream_stop", lambda: None)
monkeypatch.setattr(server, "_sess_nowait", lambda params, rid: (session, None))
monkeypatch.setattr(server, "_sess", lambda params, rid: (session, None))
monkeypatch.setattr(server, "_session_uses_compute_host", lambda current: False)
monkeypatch.setattr(server, "_clear_pending", lambda sid=None: None)
def test_interrupt_ack_retires_marker_before_run_thread_exits(monkeypatch, marker_home):
"""A confirmed Stop must not auto-continue if the backend dies afterward."""
class _AliveThread:
def is_alive(self):
return True
interrupted = []
agent = types.SimpleNamespace(interrupt=lambda: interrupted.append(True))
session = _session(
agent=agent,
running=True,
_run_thread=_AliveThread(),
_active_turn_marker_key="original-key",
)
session["session_key"] = "rotated-key"
session_home = marker_home / "remote-profile"
session["profile_home"] = str(session_home)
record_turn_start(session_home, "original-key", "do not resume me")
_patch_local_interrupt(monkeypatch, session)
response = server._methods["session.interrupt"]("request-1", {"session_id": "runtime-1"})
assert response["result"]["status"] == "interrupted"
assert interrupted == [True]
assert read_turn_marker(session_home, "original-key") is None
assert read_turn_marker(session_home, "rotated-key") is None
assert "_active_turn_marker_key" not in session
def test_interrupt_racing_marker_write_cannot_leave_recovery_state(
monkeypatch, emits, turn_env, marker_home
):
"""Stop before the disk write must still prevent later auto-continue."""
interrupted = []
agent = types.SimpleNamespace(
session_id="session-key",
clear_interrupt=lambda: None,
interrupt=lambda: interrupted.append(True),
run_conversation=lambda message, **kwargs: {"final_response": "stopped"},
)
session = _session(agent=agent, running=True)
_patch_local_interrupt(monkeypatch, session)
def write_after_stop(home, key, prompt, *, attempts=0, auto_continue=True):
response = server._methods["session.interrupt"](
"stop-during-write", {"session_id": "runtime-race"}
)
assert response["result"]["status"] == "interrupted"
record_turn_start(home, key, prompt, attempts=attempts, auto_continue=auto_continue)
monkeypatch.setattr(server, "record_turn_start", write_after_stop)
server._run_prompt_submit("request-race", "runtime-race", session, "race me")
assert interrupted == [True]
assert read_turn_marker(marker_home, "session-key") is None
assert "_active_turn_marker_key" not in session
# ── Turn lifecycle owns the marker ─────────────────────────────────────
def test_concluded_turn_clears_marker(emits, turn_env, marker_home):
seen_mid_turn: list = []
def _run(message, **kwargs):
seen_mid_turn.append(read_turn_marker(marker_home, "session-key"))
return {"final_response": "done"}
agent = types.SimpleNamespace(
session_id="session-key", run_conversation=_run, clear_interrupt=lambda: None
)
session = _session(agent=agent, running=True)
server._run_prompt_submit("rid", "sid", session, "do the thing")
# Written before the turn ran (this is what survives a process death) …
assert seen_mid_turn and seen_mid_turn[0] is not None
assert seen_mid_turn[0]["prompt"] == "do the thing"
assert seen_mid_turn[0]["attempts"] == 0
# … and cleared once the turn concluded.
assert read_turn_marker(marker_home, "session-key") is None
def test_handled_failure_still_clears_marker(emits, turn_env, marker_home):
"""An exception is a CONCLUDED turn (terminal frame + retained snapshot own
recovery) — only a process death may leave the marker behind."""
def _boom(message, **kwargs):
raise RuntimeError("provider exploded")
agent = types.SimpleNamespace(
session_id="session-key", run_conversation=_boom, clear_interrupt=lambda: None
)
session = _session(agent=agent, running=True)
server._run_prompt_submit("rid", "sid", session, "do the thing")
assert read_turn_marker(marker_home, "session-key") is None
def test_hosted_terminal_receipt_commits_before_marker_retire(
emits, turn_env, marker_home
):
observed = []
def _run(message, **kwargs):
return {"final_response": "done"}
def _terminal(receipt):
observed.append((receipt, read_turn_marker(marker_home, "session-key")))
agent = types.SimpleNamespace(
session_id="session-key", run_conversation=_run, clear_interrupt=lambda: None
)
session = _session(agent=agent, running=True, source="bot_room")
server._run_prompt_submit(
"rid",
"sid",
session,
"do the thing",
terminal_callback=_terminal,
)
assert observed[0][0]["status"] == "settled"
assert observed[0][1] is not None
assert read_turn_marker(marker_home, "session-key") is None
def test_hosted_terminal_receipt_failure_keeps_crash_marker(
emits, turn_env, marker_home
):
def _run(message, **kwargs):
return {"final_response": "done"}
def _terminal(_receipt):
raise RuntimeError("state store unavailable")
agent = types.SimpleNamespace(
session_id="session-key", run_conversation=_run, clear_interrupt=lambda: None
)
session = _session(agent=agent, running=True, source="bot_room")
server._run_prompt_submit(
"rid",
"sid",
session,
"do the thing",
terminal_callback=_terminal,
)
assert read_turn_marker(marker_home, "session-key") is not None
def test_continuation_turn_records_attempt_and_original_prompt(
emits, turn_env, marker_home
):
"""A continuation's marker must carry the attempt count (crash-loop
breaker) and the ORIGINAL prompt — recording its own recovery note would
nest note inside note on a second crash."""
seen: list = []
def _run(message, **kwargs):
seen.append(read_turn_marker(marker_home, "session-key"))
return {"final_response": "done"}
agent = types.SimpleNamespace(
session_id="session-key", run_conversation=_run, clear_interrupt=lambda: None
)
session = _session(
agent=agent,
running=True,
_auto_continue_attempt=2,
_auto_continue_prompt="the original prompt",
)
server._run_prompt_submit("rid", "sid", session, server._auto_continue_note("the original prompt"))
assert [(m["attempts"], m["prompt"]) for m in seen] == [(2, "the original prompt")]
# Consumed, so the NEXT user turn starts from a clean slate.
assert "_auto_continue_attempt" not in session
assert "_auto_continue_prompt" not in session
def test_older_agent_still_gets_the_post_turn_stamp(emits, turn_env, marker_home):
"""An agent whose run_conversation predates turn-start typing keeps the
original behavior — the row is typed once the turn concludes."""
stamped: list = []
class _LegacyDB:
def set_latest_matching_message_display_kind(self, session_id, **kwargs):
stamped.append((session_id, kwargs["display_kind"]))
return True
def _run(message, conversation_history=None, stream_callback=None, **_kwargs):
return {"final_response": "done"}
agent = types.SimpleNamespace(
session_id="session-key",
run_conversation=_run,
clear_interrupt=lambda: None,
_session_db=_LegacyDB(),
)
note = server._auto_continue_note("the original prompt")
server._run_prompt_submit(
"rid", "sid", _session(agent=agent, running=True), note,
display_kind="auto_continue",
)
assert stamped == [("session-key", "auto_continue")]
# ── Scheduling decision ────────────────────────────────────────────────
@pytest.fixture()
def schedule_env(monkeypatch, marker_home):
monkeypatch.setattr(server.threading, "Thread", _InlineThread)
monkeypatch.setattr(server, "_start_agent_build", lambda sid, session: None)
monkeypatch.setattr(server, "_wait_agent", lambda session, rid, timeout=30.0: None)
monkeypatch.setattr(server, "_load_cfg", lambda: {})
submitted: list = []
monkeypatch.setattr(
server,
"_run_prompt_submit",
lambda rid, sid, session, text, **kw: submitted.append((text, kw)),
)
return submitted
def test_fresh_marker_schedules_continuation(emits, schedule_env, marker_home):
record_turn_start(marker_home, "session-key", "fix the flaky test")
session = _session()
result = server._maybe_schedule_auto_continue("sid", session, "session-key")
assert result is not None
assert result["attempt"] == 1
assert session["running"] is True
assert session["_auto_continue_attempt"] == 1
(text, kwargs), = schedule_env
assert text.startswith("[System note: Your previous turn was interrupted")
assert "fix the flaky test" in text
assert kwargs["display_kind"] == "auto_continue"
assert ("message.start", "sid", None) in [(e, s, p) for e, s, p in emits]
def test_hosted_room_marker_is_left_to_the_driver(schedule_env, marker_home):
record_turn_start(marker_home, "session-key", "hosted prompt")
result = server._maybe_schedule_auto_continue(
"sid",
_session(source="bot_room"),
"session-key",
)
assert result is None
assert not schedule_env
assert read_turn_marker(marker_home, "session-key") is not None
def test_stale_marker_is_cleared_not_continued(schedule_env, marker_home, monkeypatch):
record_turn_start(marker_home, "session-key", "old prompt")
monkeypatch.setattr(
server, "time", types.SimpleNamespace(time=lambda: time.time() + 3600)
)
result = server._maybe_schedule_auto_continue("sid", _session(), "session-key")
assert result is None
assert not schedule_env
assert read_turn_marker(marker_home, "session-key") is None
def test_config_widens_freshness_window(emits, schedule_env, marker_home, monkeypatch):
record_turn_start(marker_home, "session-key", "old prompt")
monkeypatch.setattr(
server,
"_load_cfg",
lambda: {"desktop": {"auto_continue": {"freshness_minutes": 120}}},
)
monkeypatch.setattr(
server, "time", types.SimpleNamespace(time=lambda: time.time() + 3600)
)
result = server._maybe_schedule_auto_continue("sid", _session(), "session-key")
assert result is not None
assert len(schedule_env) == 1
def test_exhausted_attempts_break_the_loop(schedule_env, marker_home):
record_turn_start(marker_home, "session-key", "crashy prompt", attempts=2)
result = server._maybe_schedule_auto_continue("sid", _session(), "session-key")
assert result is None
assert not schedule_env
assert read_turn_marker(marker_home, "session-key") is None
def test_disabled_by_config(schedule_env, marker_home, monkeypatch):
record_turn_start(marker_home, "session-key", "prompt")
monkeypatch.setattr(
server,
"_load_cfg",
lambda: {"desktop": {"auto_continue": {"enabled": False}}},
)
result = server._maybe_schedule_auto_continue("sid", _session(), "session-key")
assert result is None
assert not schedule_env
def test_no_marker_means_no_continuation(schedule_env, marker_home):
assert server._maybe_schedule_auto_continue("sid", _session(), "session-key") is None
assert not schedule_env
def test_running_session_wins_over_continuation(emits, schedule_env, marker_home):
"""A real user prompt that raced the kickoff keeps its turn; the marker is
left for that turn's own conclusion to clear."""
record_turn_start(marker_home, "session-key", "prompt")
session = _session(running=True)
result = server._maybe_schedule_auto_continue("sid", session, "session-key")
# Scheduled (the descriptor is returned), but the kickoff bailed.
assert result is not None
assert not schedule_env
assert session["_auto_continue_scheduled"] is False
assert read_turn_marker(marker_home, "session-key") is not None
# Nothing left behind for the racing user turn to inherit.
assert "_auto_continue_attempt" not in session
assert "_auto_continue_prompt" not in session
def test_double_schedule_is_guarded(emits, schedule_env, marker_home):
record_turn_start(marker_home, "session-key", "prompt")
session = _session()
first = server._maybe_schedule_auto_continue("sid", session, "session-key")
second = server._maybe_schedule_auto_continue("sid", session, "session-key")
assert first is not None
assert second is None
assert len(schedule_env) == 1
def test_failed_agent_build_leaves_marker_for_retry(
emits, schedule_env, marker_home, monkeypatch
):
record_turn_start(marker_home, "session-key", "prompt")
monkeypatch.setattr(
server,
"_wait_agent",
lambda session, rid, timeout=30.0: {"error": {"message": "boom"}},
)
session = _session()
result = server._maybe_schedule_auto_continue("sid", session, "session-key")
assert result is not None
assert not schedule_env
assert session["_auto_continue_scheduled"] is False
assert read_turn_marker(marker_home, "session-key") is not None
# ── End to end: continuation runs a real turn and clears the marker ────
# ── Marker writer identity: a live writer is an owner, not a corpse ────
#
# The scheduler used to treat every marker it found as proof the writing
# process died. Two backends over one HERMES_HOME break that assumption: A is
# mid-turn on session S while B resumes S — B read A's live marker as a crash
# and started a second turn over it (#94778). Ownership now comes from the
# writer's pid + create time.
_CHILD_WRITER = """
import os, sys, time
from pathlib import Path
from tui_gateway.turn_marker import record_turn_start
home = Path(sys.argv[1])
record_turn_start(home, "session-key", "interrupted prompt")
print(f"ready {os.getpid()}", flush=True)
# Linger until the test drops a sentinel. Self-exit, not terminate(): a venv
# python.exe on Windows re-execs the real interpreter, so Popen.pid is the
# launcher and killing it does not reliably reach the process that holds the
# marker.
deadline = time.time() + 120
while not (home / "writer-exit").exists() and time.time() < deadline:
time.sleep(0.05)
"""
def _wait_for_marker(home, key, timeout=30.0):
"""Bounded poll — the marker is written by another process, so a fixed
sleep in the parent would just be a flaky test."""
deadline = time.time() + timeout
while time.time() < deadline:
if read_turn_marker(home, key) is not None:
return
time.sleep(0.05)
raise AssertionError(f"child writer produced no marker within {timeout}s")
def test_marker_writer_state_rejects_a_recycled_pid():
"""Same pid, different process: the create time is what makes the pid an
identity. A start time from yesterday cannot be this process."""
assert marker_writer_state(
{"writer_pid": os.getpid(), "writer_start_time": time.time() - 86400}
) != "alive"
def test_second_backend_defers_to_a_live_marker_writer(emits, schedule_env, marker_home):
"""Two backends, one HERMES_HOME, one session: A is mid-turn (alive writer)
B resumes S and must read the marker as ownership evidence, not crash
evidence — no continuation, no misleading "Resuming interrupted turn…"
frame, no duplicate turn, and A's marker left for A to clear. Once A is
really gone the same call does schedule, so the live-writer gate is what
held B back and not some other switch.
"""
repo_root = Path(server.__file__).resolve().parents[1]
child = subprocess.Popen(
[sys.executable, "-c", _CHILD_WRITER, str(marker_home)],
cwd=str(repo_root),
env={**os.environ, "PYTHONPATH": str(repo_root)},
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
try:
ready = child.stdout.readline().strip()
if not ready.startswith("ready "):
child.kill()
_, stderr = child.communicate(timeout=10)
raise AssertionError(f"child writer never started: {ready!r} / {stderr!r}")
writer_pid = int(ready.split()[1])
_wait_for_marker(marker_home, "session-key")
assert writer_pid != os.getpid()
written = read_turn_marker(marker_home, "session-key")
assert written["writer_pid"] == writer_pid
assert marker_writer_state(written) == "alive"
session = _session()
assert server._maybe_schedule_auto_continue("sid", session, "session-key") is None
assert not schedule_env # nothing queued behind the live writer
assert session.get("_auto_continue_scheduled") is None # not even claimed
assert not [e for e in emits if e[0] in ("status.update", "message.start")]
assert read_turn_marker(marker_home, "session-key") is not None # A's marker intact
child.terminate() # the launcher; the real writer exits on the sentinel below
child.wait(timeout=10)
(marker_home / "writer-exit").write_text("go", encoding="utf-8")
deadline = time.time() + 30
while time.time() < deadline and marker_writer_state(
read_turn_marker(marker_home, "session-key")
) != "dead":
time.sleep(0.05)
assert marker_writer_state(read_turn_marker(marker_home, "session-key")) == "dead", (
f"writer pid {writer_pid} (child pid {child.pid}) still reads live"
)
assert server._maybe_schedule_auto_continue("sid", _session(), "session-key") is not None
assert len(schedule_env) == 1
finally:
(marker_home / "writer-exit").touch() # release the child even on an early failure
if child.poll() is None:
child.kill()
child.wait(timeout=10)