fix: settle steer on every terminal path and preserve recovery drafts

This commit is contained in:
Anthony Ruiz committed 2026-09-20 17:07:58 +00:00
1 parent 1ee4fc1601
commit e3db8f7e01
10 files changed
+160 -36

No files matched your search

+6 -1
View File
@@ -87,7 +87,12 @@ Follow that checklist's safety rules:
Do not re-register a cancelled worker; finalize outside the stream lock. Do not re-register a cancelled worker; finalize outside the stream lock.
Local Steer accepts only explicit starting/running phases. Close admission Local Steer accepts only explicit starting/running phases. Close admission
by publishing finalizing under STREAMS_LOCK before the last pending-steer by publishing finalizing under STREAMS_LOCK before the last pending-steer
drain; earlier accepted guidance is drained, later guidance is rejected. drain; earlier accepted guidance is drained, later guidance is rejected with
`not_running` while the owned stream is still live (not `stream_dead`). Use
one idempotent terminal settlement before done/error/end and final cleanup,
covering returned errors, exceptions and self-heal, not only the success path.
Merge Agent-returned `pending_steer` with the registered worker's final slot
drain and emit leftovers before terminal events, outside registry locks.
Test both drain/Steer orderings, including compression-rotated identities. Test both drain/Steer orderings, including compression-rotated identities.
- Inactive-session recovery is separate from live Steer. Resolve durable - Inactive-session recovery is separate from live Steer. Resolve durable
compression lineage in the session's profile database, read-only, even when compression lineage in the session's profile database, read-only, even when
+4 -1
View File
@@ -45,7 +45,10 @@ def durable_compression_continuation(session):
return True, None return True, None
# Only the observed automatic idle closure is resumable here. Explicit # Only the observed automatic idle closure is resumable here. Explicit
# resets/closures and unknown future reasons must not become redirects. # resets/closures and unknown future reasons must not become redirects.
if child.get("ended_at") is not None and child.get("end_reason") != "idle_timeout": reason = child.get("end_reason")
if reason not in (None, "", "idle_timeout"):
return True, None
if child.get("ended_at") is not None and reason != "idle_timeout":
return True, None return True, None
if db.get_compression_tip(sid) != tip: if db.get_compression_tip(sid) != tip:
return True, None return True, None
+47 -25
View File
@@ -9541,8 +9541,42 @@ def _run_agent_streaming(
_metering_thread = threading.Thread(target=_metering_ticker, daemon=True) _metering_thread = threading.Thread(target=_metering_ticker, daemon=True)
_success_writeback_committed = False _success_writeback_committed = False
_steer_settled = False
_returned_pending_steer = []
def _remember_pending_steer_result(value):
text = value.get('pending_steer') if isinstance(value, dict) else None
if isinstance(text, str) and text:
_returned_pending_steer.append(text)
def _settle_pending_steer():
# Single terminal boundary for success, returned errors, exceptions and
# successful self-heal. Admission closes before the last slot consumer;
# registry locks never cover SSE writes or Agent drain callbacks.
nonlocal _steer_settled
with STREAMS_LOCK:
if _steer_settled:
return
_steer_settled = True
target = AGENT_INSTANCES.get(stream_id) or agent
if stream_id in STREAMS and not cancel_event.is_set():
update_active_run(stream_id, phase="finalizing")
leftovers = list(_returned_pending_steer)
try:
drain = getattr(target, '_drain_pending_steer', None)
text = drain() if callable(drain) else None
if text:
leftovers.append(str(text))
except Exception:
logger.debug("Failed to drain pending steer for session %s", session_id)
if leftovers:
put('pending_steer_leftover', {
'session_id': session_id, 'text': '\n'.join(leftovers),
})
def put(event, data): def put(event, data):
if event in ('done', 'apperror', 'stream_end'):
_settle_pending_steer()
# If cancelled, drop all further events except the cancel event itself # If cancelled, drop all further events except the cancel event itself
if cancel_event.is_set() and not _success_writeback_committed and event not in ('cancel', 'apperror'): if cancel_event.is_set() and not _success_writeback_committed and event not in ('cancel', 'apperror'):
return return
@@ -11272,6 +11306,7 @@ def _run_agent_streaming(
_run_conversation_kwargs["user_message"] = user_message _run_conversation_kwargs["user_message"] = user_message
_result_partial_pre_call_context = list(_previous_context_messages) _result_partial_pre_call_context = list(_previous_context_messages)
result = agent.run_conversation(**_run_conversation_kwargs) result = agent.run_conversation(**_run_conversation_kwargs)
_remember_pending_steer_result(result)
_active_turn_identity = _resolve_active_turn_authority( _active_turn_identity = _resolve_active_turn_authority(
_active_turn_identity, _active_turn_identity,
result=result, result=result,
@@ -11807,6 +11842,7 @@ def _run_agent_streaming(
_heal_context_messages _heal_context_messages
) )
_heal_result = agent.run_conversation(**_heal_kwargs) _heal_result = agent.run_conversation(**_heal_kwargs)
_remember_pending_steer_result(_heal_result)
_active_turn_identity = _resolve_active_turn_authority( _active_turn_identity = _resolve_active_turn_authority(
_active_turn_identity, _active_turn_identity,
result=_heal_result, result=_heal_result,
@@ -12753,30 +12789,7 @@ def _run_agent_streaming(
else None else None
) )
# (reasoning trace already attached + saved above, before s.save()) # (reasoning trace already attached + saved above, before s.save())
# Leftover-steer delivery: if a /steer was queued (via _settle_pending_steer()
# api/chat/steer) but the agent finished its turn before
# reaching a tool-result boundary that would consume it,
# the text is still stashed in agent._pending_steer. Drain
# it now and emit a pending_steer_leftover SSE event so the
# frontend can queue it for the next turn — same fallback
# path as the CLI in cli.py:8788-8794.
# Close Steer admission before its last consumer runs. Enqueues
# share this lock: earlier guidance is drained below; later requests
# fail closed instead of reporting success on an undeliverable input.
# Stop may have claimed first; never overwrite its cancelling phase.
with STREAMS_LOCK:
if stream_id in STREAMS:
update_active_run(stream_id, phase="finalizing")
try:
_drain_pending_steer = getattr(agent, '_drain_pending_steer', None)
_leftover = _drain_pending_steer() if _drain_pending_steer else None
if _leftover:
put('pending_steer_leftover', {
'session_id': session_id,
'text': str(_leftover),
})
except Exception:
logger.debug("Failed to drain pending steer for session %s", session_id)
# /goal parity: after a successful assistant turn, run the Hermes # /goal parity: after a successful assistant turn, run the Hermes
# GoalManager judge before terminal done/stream_end events. The # GoalManager judge before terminal done/stream_end events. The
# frontend surfaces the status line and queues continuation_prompt as # frontend surfaces the status line and queues continuation_prompt as
@@ -13180,6 +13193,7 @@ def _run_agent_streaming(
_heal_context_messages _heal_context_messages
) )
_heal_result = _heal_agent.run_conversation(**_heal_kwargs2) _heal_result = _heal_agent.run_conversation(**_heal_kwargs2)
_remember_pending_steer_result(_heal_result)
_active_turn_identity = _resolve_active_turn_authority( _active_turn_identity = _resolve_active_turn_authority(
_active_turn_identity, _active_turn_identity,
result=_heal_result, result=_heal_result,
@@ -13433,6 +13447,7 @@ def _run_agent_streaming(
_error_payload['old_session_id'] = session_id _error_payload['old_session_id'] = session_id
put('apperror', _error_payload) put('apperror', _error_payload)
finally: finally:
_settle_pending_steer()
# #4633/#2476: symmetric metering teardown. begin_session() (top of the # #4633/#2476: symmetric metering teardown. begin_session() (top of the
# outer try) had no paired end_session(), so zero-token turns leaked a # outer try) had no paired end_session(), so zero-token turns leaked a
# _sessions[stream_id] entry that get_stats() pruning never reclaims (its # _sessions[stream_id] entry that get_stats() pruning never reclaims (its
@@ -13460,7 +13475,6 @@ def _run_agent_streaming(
if (s is not None if (s is not None
and getattr(s, 'active_stream_id', None) == stream_id and getattr(s, 'active_stream_id', None) == stream_id
and getattr(s, 'pending_user_message', None)): and getattr(s, 'pending_user_message', None)):
update_active_run(stream_id, phase="finalizing")
_last_resort_sync_from_core(s, stream_id, _agent_lock) _last_resort_sync_from_core(s, stream_id, _agent_lock)
_clear_thread_env() # TD1: always clear thread-local context _clear_thread_env() # TD1: always clear thread-local context
if _streaming_cron_profile_home_token is not None: if _streaming_cron_profile_home_token is not None:
@@ -13580,6 +13594,11 @@ def _steer_bound_stream(sid: str, stream_id: str, text: str) -> dict | None:
or run.get("phase") == "cancelling"): or run.get("phase") == "cancelling"):
return {"accepted": False, "fallback": "stream_dead", "stream_id": None} return {"accepted": False, "fallback": "stream_dead", "stream_id": None}
return {"accepted": False, "fallback": "gateway_steer_queued", "stream_id": stream_id} return {"accepted": False, "fallback": "gateway_steer_queued", "stream_id": stream_id}
if (stream_id in cfg.STREAMS and owner == sid
and run.get("session_id") == sid
and run.get("backend") == WEBUI_LOCAL_CHAT_BACKEND
and run.get("phase") == "finalizing"):
return {"accepted": False, "fallback": "not_running", "stream_id": stream_id}
if agent is None: if agent is None:
if ((owner and owner != sid) if ((owner and owner != sid)
or (run.get("session_id") and run["session_id"] != sid) or (run.get("session_id") and run["session_id"] != sid)
@@ -13734,6 +13753,9 @@ def _handle_chat_steer(handler, body: dict) -> bool:
# Gateway owns transport; a local cache object is never steered. # Gateway owns transport; a local cache object is never steered.
result = {"accepted": False, "fallback": "gateway_steer_queued", result = {"accepted": False, "fallback": "gateway_steer_queued",
"stream_id": active_stream_id} "stream_id": active_stream_id}
elif backend == WEBUI_LOCAL_CHAT_BACKEND and run.get("phase") == "finalizing":
result = {"accepted": False, "fallback": "not_running",
"stream_id": active_stream_id}
elif (backend == WEBUI_LOCAL_CHAT_BACKEND elif (backend == WEBUI_LOCAL_CHAT_BACKEND
and run.get("phase") in _LOCAL_STEERABLE_PHASES): and run.get("phase") in _LOCAL_STEERABLE_PHASES):
try: try:
+1 -1
View File
@@ -1368,7 +1368,7 @@ function _restoreComposerDraftAfterFailedSend(draftText, filesSnapshot, sid, cle
} else if(!restoredVisible){ } else if(!restoredVisible){
// Background failure (sid was never the visible session): no live // Background failure (sid was never the visible session): no live
// composer to read, so persist the captured snapshot — it's the only copy. // composer to read, so persist the captured snapshot — it's the only copy.
_saveComposerDraftNow(sid, restore, []); _saveComposerDraftNow(sid, restore, files);
} }
// else: restored the visible composer, then the user switched away — the // else: restored the visible composer, then the user switched away — the
// session-switch save path already saved sid's composer; skip stale write. // session-switch save path already saved sid's composer; skip stale write.
+27 -3
View File
@@ -12,7 +12,11 @@ from api import routes, profiles
@pytest.fixture @pytest.fixture
def lineage(tmp_path, monkeypatch): def lineage(tmp_path, monkeypatch):
SessionDB = pytest.importorskip("hermes_state").SessionDB state = pytest.importorskip("hermes_state")
# Worker tests restore sys.path after loading Agent lazily. Scope its
# sibling-module lookup to this fixture, never the global test importer.
monkeypatch.syspath_prepend(str(Path(state.__file__).parent))
SessionDB = state.SessionDB
db = SessionDB(tmp_path / "state.db") db = SessionDB(tmp_path / "state.db")
db.create_session("sealedparent", source="webui") db.create_session("sealedparent", source="webui")
db.create_session("idlechild", source="tui", parent_session_id="sealedparent") db.create_session("idlechild", source="tui", parent_session_id="sealedparent")
@@ -69,9 +73,10 @@ def test_idle_tip_accepts_normal_persistence_without_reopening_parent(lineage):
@pytest.mark.parametrize("reason", ["reset", "user_closed", "compression"]) @pytest.mark.parametrize("reason", ["reset", "user_closed", "compression"])
def test_unknown_or_sealed_terminal_tip_is_not_redirected(lineage, reason): @pytest.mark.parametrize("ended_at", [None, 1234.0])
def test_unknown_or_sealed_terminal_tip_is_not_redirected(lineage, reason, ended_at):
db, session = lineage db, session = lineage
db._conn.execute("UPDATE sessions SET end_reason=? WHERE id='idlechild'", (reason,)) db._conn.execute("UPDATE sessions SET end_reason=?, ended_at=? WHERE id='idlechild'", (reason, ended_at))
db._conn.commit() db._conn.commit()
assert routes._pre_compression_continuation_session_id(session) is None assert routes._pre_compression_continuation_session_id(session) is None
@@ -128,3 +133,22 @@ assert.equal(calls.length,2);
})().catch(e=>{console.error(e);process.exit(1)}); })().catch(e=>{console.error(e);process.exit(1)});
''' '''
subprocess.run(['node', '-e', helper + script], check=True, timeout=15) subprocess.run(['node', '-e', helper + script], check=True, timeout=15)
def test_background_failed_draft_preserves_attachments_after_navigation_race():
source = (Path(__file__).parents[1] / 'static/messages.js').read_text()
helper = source[source.index('function _restoreComposerDraftAfterFailedSend('):source.index('async function send(){')]
script = r'''
const assert=require('node:assert/strict');
const S={session:{session_id:'unrelated'},pendingFiles:[]};
const $=()=>{throw Error('unrelated visible composer must not be touched')};
const calls=[];
const _saveComposerDraftNow=(...args)=>calls.push(args);
(async()=>{
const files=[{name:'drawing.png',path:'/uploads/drawing.png'}];
_restoreComposerDraftAfterFailedSend('retained',files,'continuation',Promise.resolve());
await Promise.resolve();
assert.deepEqual(calls,[['continuation','retained',files]]);
})().catch(e=>{console.error(e);process.exit(1)});
'''
subprocess.run(['node','-e',helper+script], check=True, timeout=15)
+1 -1
View File
@@ -1026,7 +1026,7 @@ class TestLeftoverDelivery:
on the same turn.""" on the same turn."""
src = (Path(__file__).parent.parent / "api" / "streaming.py").read_text(encoding="utf-8") src = (Path(__file__).parent.parent / "api" / "streaming.py").read_text(encoding="utf-8")
# Find the drain invocation and the next put('done', ...) AFTER it # Find the drain invocation and the next put('done', ...) AFTER it
drain_idx = src.find("_drain_pending_steer()") drain_idx = src.find("_settle_pending_steer()", src.index(" def put(event, data):", src.index("def _run_agent_streaming(")))
assert drain_idx >= 0 assert drain_idx >= 0
done_idx = src.find("put('done'", drain_idx) done_idx = src.find("put('done'", drain_idx)
assert done_idx >= 0 assert done_idx >= 0
+1 -1
View File
@@ -247,4 +247,4 @@ def test_local_steer_requires_an_explicit_consuming_phase(scene, registered, pha
assert result["accepted"] is (phase in {"starting", "running"}) assert result["accepted"] is (phase in {"starting", "running"})
assert agent.steer.call_count == (1 if result["accepted"] else 0) assert agent.steer.call_count == (1 if result["accepted"] else 0)
if not result["accepted"]: if not result["accepted"]:
assert result["fallback"] == "stream_dead" assert result["fallback"] == ("not_running" if phase == "finalizing" else "stream_dead")
+1 -1
View File
@@ -264,6 +264,6 @@ def test_cache_only_selection_revalidates_phase_before_enqueue(scene, monkeypatc
return matched return matched
monkeypatch.setattr(streaming, "_cached_agent_matches_session", select_then_finalize) monkeypatch.setattr(streaming, "_cached_agent_matches_session", select_then_finalize)
assert steer() == {"accepted": False, "fallback": "stream_dead", "stream_id": None} assert steer() == {"accepted": False, "fallback": "not_running", "stream_id": "run"}
assert selected == [agent] assert selected == [agent]
agent.steer.assert_not_called() agent.steer.assert_not_called()
+68
View File
@@ -0,0 +1,68 @@
"""All terminal worker exits must close Steer and preserve accepted guidance."""
import pytest
from api import config
from tests import test_steer_worker_boundaries as boundaries
worker_scene = boundaries.worker_scene
@pytest.mark.parametrize('outcome', ['success', 'returned-error', 'exception'])
def test_terminal_exit_fences_before_event_and_keeps_guidance(worker_scene, monkeypatch, outcome):
scene = worker_scene
observed = []
put = scene.events.put_nowait
def observe(item):
if item[0] in ('done', 'apperror'):
observed.append(config.ACTIVE_RUNS.get('run', {}).get('phase'))
return put(item)
monkeypatch.setattr(scene.events, 'put_nowait', observe)
def running():
scene.agent.session_id = 'compressed-child'
scene.agent.steer('retained guidance')
if outcome == 'exception':
raise RuntimeError('terminal worker failure')
if outcome == 'returned-error':
scene.result = {'messages': [], 'error': 'terminal worker failure'}
else:
# The real Agent consumes this slot into its returned result.
text = scene.agent._drain_pending_steer()
scene.result = {'messages': [
{'role': 'user', 'content': 'Do the task.'},
{'role': 'assistant', 'content': 'Finished.'},
], 'pending_steer': text}
scene.on_run = running
scene.run()
assert observed and all(phase == 'finalizing' for phase in observed)
events = list(scene.events.queue)
leftovers = [data['text'] for event, data in events if event == 'pending_steer_leftover']
assert leftovers == ['retained guidance']
assert next(i for i, item in enumerate(events) if item[0] == 'pending_steer_leftover') < next(i for i, item in enumerate(events) if item[0] in ('done', 'apperror'))
assert scene.agent.pending == []
def test_finalizing_response_preserves_browser_live_stream():
import subprocess
from pathlib import Path
source = (Path(__file__).parents[1] / 'static/commands.js').read_text()
fn = source[source.index('async function _trySteer('):source.index('async function cmdTitle(')]
predicate = source[source.index('function _steerFallbackIsDeadRun('):source.index('function _steerOwnerStreamIsCurrent(')]
script = r'''
const assert=require('node:assert/strict');
const S={session:{session_id:'s',active_stream_id:'run'},activeStreamId:'run',busy:true,pendingFiles:[]};
const INFLIGHT={s:{stream_id:'run'}},inp={value:''};
const $=()=>inp,t=x=>x,showToast=()=>{};
const _steerTextWithPendingFiles=async x=>x,_steerOwnerIsCurrent=()=>true,_steerRestoreText=x=>x;
const _steerFailureMessageKey=x=>x,_showSteerRecovery=()=>{};
const _steerClearCurrentOwnerDeadRun=()=>{S.busy=false;S.activeStreamId=null;delete INFLIGHT.s};
const api=async()=>({accepted:false,fallback:'not_running',stream_id:'run'});
(async()=>{
assert.equal(await _trySteer('guidance',true),false);
assert.equal(S.busy,true);assert.equal(S.activeStreamId,'run');
assert.equal(INFLIGHT.s.stream_id,'run');assert.equal(inp.value,'guidance');
})().catch(e=>{console.error(e);process.exit(1)});
'''
subprocess.run(['node','-e',fn+predicate+script],check=True,timeout=15)
+4 -2
View File
@@ -53,7 +53,7 @@ def worker_scene(tmp_path, monkeypatch):
maps["STREAM_SESSION_OWNERS"]["run"] = "original" maps["STREAM_SESSION_OWNERS"]["run"] = "original"
scene = SimpleNamespace(session=session, events=events, lock=lock, agent=None, scene = SimpleNamespace(session=session, events=events, lock=lock, agent=None,
on_init=lambda: None, on_run=lambda: None, on_init=lambda: None, on_run=lambda: None,
on_drain=lambda: None, calls=[], drained=[]) on_drain=lambda: None, calls=[], drained=[], result=None)
class FakeAgent: class FakeAgent:
def __init__(self, **kwargs): def __init__(self, **kwargs):
@@ -71,6 +71,8 @@ def worker_scene(tmp_path, monkeypatch):
def run_conversation(self, **kwargs): def run_conversation(self, **kwargs):
scene.calls.append("run") scene.calls.append("run")
scene.on_run() scene.on_run()
if scene.result is not None:
return scene.result
return {"messages": [ return {"messages": [
{"role": "user", "content": "Do the task."}, {"role": "user", "content": "Do the task."},
{"role": "assistant", "content": "Finished."}, {"role": "assistant", "content": "Finished."},
@@ -261,7 +263,7 @@ def test_final_drain_fences_steer(worker_scene, monkeypatch, registered, rotated
if first == "steer": if first == "steer":
assert result == {"accepted": True, "fallback": None, "stream_id": "run"} assert result == {"accepted": True, "fallback": None, "stream_id": "run"}
else: else:
assert result == {"accepted": False, "fallback": "stream_dead", "stream_id": None} assert result == {"accepted": False, "fallback": "not_running", "stream_id": "run"}
assert scene.agent.pending == [] assert scene.agent.pending == []
finally: finally:
release.set() release.set()