From 49bea3ad0187f600c622c8fabd665f9a1d345b84 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sat, 16 May 2026 14:29:58 +0800 Subject: [PATCH 1/2] Clarify interrupted turn recovery marker --- CHANGELOG.md | 4 ++++ api/models.py | 28 ++++++++++++++++------------ tests/test_session_sidecar_repair.py | 11 ++++++++--- 3 files changed, 28 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fa25bab..7adff893 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- **PR TBD** by @franksong2702 (fixes #2370) — Recovered interrupted turns now explain that the WebUI process restarted before the agent finished, that the user message above was preserved, and that no agent output was recovered. Pre-fix, stale pending-turn repair appended the vague `Previous turn did not complete` marker, which looked like an unexplained assistant response after a WebUI restart killed the in-process worker. + ## [v0.51.74] — 2026-05-16 — Release AX (stage-367 — 4-PR safe-lane batch — #2362 table-cell spacing + #2363 run-state-consistency RFC + #2365 custom_providers list-format + #2367 settings sidebar i18n) ### Added diff --git a/api/models.py b/api/models.py index f448f7a3..0f6397c3 100644 --- a/api/models.py +++ b/api/models.py @@ -679,6 +679,20 @@ def _get_profile_home(profile) -> Path: return Path(os.environ.get('HERMES_HOME') or '~/.hermes').expanduser() +def _interrupted_recovery_marker() -> dict: + return { + 'role': 'assistant', + 'content': ( + '**Response interrupted.**\n\n' + 'The WebUI process restarted before this turn finished. ' + 'The user message above was preserved, but no agent output was recovered.' + ), + 'timestamp': int(time.time()), + '_error': True, + 'type': 'interrupted', + } + + def _apply_core_sync_or_error_marker( session, core_path, @@ -745,12 +759,7 @@ def _apply_core_sync_or_error_marker( session.pending_user_message = None session.pending_attachments = [] session.pending_started_at = None - session.messages.append({ - 'role': 'assistant', - 'content': '**Previous turn did not complete.**', - 'timestamp': int(time.time()), - '_error': True, - }) + session.messages.append(_interrupted_recovery_marker()) session.save() logger.info( "Session %s: recovered pending user turn (messages non-empty), added error marker", @@ -794,12 +803,7 @@ def _apply_core_sync_or_error_marker( session.pending_user_message = None session.pending_attachments = [] session.pending_started_at = None - session.messages.append({ - 'role': 'assistant', - 'content': '**Previous turn did not complete.**', - 'timestamp': int(time.time()), - '_error': True, - }) + session.messages.append(_interrupted_recovery_marker()) session.save() logger.info("Session %s: no core transcript found, added error marker", sid) return True diff --git a/tests/test_session_sidecar_repair.py b/tests/test_session_sidecar_repair.py index 4d575125..10a599ba 100644 --- a/tests/test_session_sidecar_repair.py +++ b/tests/test_session_sidecar_repair.py @@ -231,7 +231,7 @@ class TestRepairStalePendingNoDeadlock: class TestDraftRecovery: """When no core transcript exists, the pending user message is restored as a recovered user turn (_recovered=True) and the error marker says - 'Previous turn did not complete.' — NOT 'preserved as a draft'.""" + a clear restart interruption marker — NOT 'preserved as a draft'.""" def test_pending_message_recovered_as_user_turn(self, hermes_home, monkeypatch): """When core transcript is missing, the pending_user_message is appended @@ -310,7 +310,10 @@ class TestDraftRecovery: assert "preserved as a draft" not in content, ( f"Error marker should not say 'preserved as a draft', got: {content}" ) - assert "Previous turn did not complete" in content + assert "Response interrupted" in content + assert "WebUI process restarted" in content + assert "user message above was preserved" in content + assert error_msgs[0].get("type") == "interrupted" def test_pending_attachments_recovered(self, hermes_home, monkeypatch): """Attachments on the pending message are carried over to the recovered turn.""" @@ -604,7 +607,9 @@ class TestNonEmptyMessagesPendingCleared: # Exactly one error marker error_msgs = [m for m in s.messages if m.get("_error")] assert len(error_msgs) == 1 - assert "Previous turn did not complete" in error_msgs[0]["content"] + assert "Response interrupted" in error_msgs[0]["content"] + assert "WebUI process restarted" in error_msgs[0]["content"] + assert error_msgs[0].get("type") == "interrupted" # Pending fields fully cleared assert s.pending_user_message is None From c415c843dfd77ddb859838d5f30ada1dcc6958c7 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sat, 16 May 2026 20:05:47 +0800 Subject: [PATCH 2/2] Update interrupted recovery comment wording --- api/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/models.py b/api/models.py index 0f6397c3..e0e52004 100644 --- a/api/models.py +++ b/api/models.py @@ -815,7 +815,7 @@ def _apply_core_sync_or_error_marker( # pending_user_message and STREAMS.pop(stream_id). Without this guard, any # fast turn (e.g. command approval) that exits the thread before the on-disk # pending clear has flushed gets misdiagnosed as a crashed turn, producing a -# spurious "Previous turn did not complete." marker. +# spurious "Response interrupted." marker. # # 30s covers the worst-case post-loop persistence window: LLM finishing a tool # batch + lock contention with the checkpoint thread + a multi-MB session.save.