fix(runtime): preserve interrupted turns on gateway exit

This commit is contained in:
Xubin Ren
2026-08-24 00:58:04 +08:00
parent c7e2a474a0
commit 2cdfba38b2
8 changed files with 94 additions and 88 deletions
+2 -2
View File
@@ -164,8 +164,8 @@ async def test_dispatch_cancellation_restores_checkpoint():
@pytest.mark.asyncio
async def test_dispatch_cancellation_keeps_checkpoint_for_managed_restart(tmp_path: Path) -> None:
"""A restart preserves the checkpoint; an explicit stop still restores it."""
async def test_dispatch_cancellation_keeps_checkpoint_for_gateway_shutdown(tmp_path: Path) -> None:
"""Gateway shutdown preserves the checkpoint; an explicit stop restores it."""
loop = _make_loop(tmp_path)
loop.preserve_inflight_turns_on_shutdown()
loop._restore_runtime_checkpoint = MagicMock() # type: ignore[method-assign]
+7
View File
@@ -102,6 +102,9 @@ class _GatewayAgentContractStub:
) -> OutboundMessage | None:
return None
def preserve_inflight_turns_on_shutdown(self) -> None:
return None
class _EmptyGatewaySessionManager:
"""Minimal session-manager contract for gateway assembly tests."""
@@ -3712,6 +3715,9 @@ def test_gateway_agent_task_owns_initial_mcp_provider_close(
async def aclose(self) -> None:
seen["agent_closed"] = True
def preserve_inflight_turns_on_shutdown(self) -> None:
seen["inflight_turns_preserved"] = True
def stop(self) -> None:
seen["agent_stopped"] = True
@@ -3793,6 +3799,7 @@ def test_gateway_agent_task_owns_initial_mcp_provider_close(
assert result.exit_code == 0
assert seen["agent_stopped"] is True
assert seen["inflight_turns_preserved"] is True
assert seen["agent_closed"] is True
assert seen["agent_task_cleaned_up"] is True
assert seen["channels_stopped"] is True
+1 -16
View File
@@ -455,8 +455,7 @@ def test_restart_does_not_detach_a_foreground_gateway(tmp_path, monkeypatch):
assert result.message == "gateway_foreground_restart_required"
def test_restart_marks_the_exiting_gateway_for_turn_recovery(tmp_path, monkeypatch):
"""A managed restart must not look like an explicit stop to the child."""
def test_restart_stops_then_starts_the_background_gateway(tmp_path, monkeypatch):
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
status = GatewayStatus(
running=True,
@@ -469,9 +468,6 @@ def test_restart_marks_the_exiting_gateway_for_turn_recovery(tmp_path, monkeypat
def stop(*, timeout_s: int):
assert timeout_s == 20
assert json.loads(runtime._restart_intent_path.read_text(encoding="utf-8")) == {
"pid": 12345
}
return SimpleNamespace(ok=True, message="gateway_stopped", status=status)
monkeypatch.setattr(runtime, "_stop", stop)
@@ -484,17 +480,6 @@ def test_restart_marks_the_exiting_gateway_for_turn_recovery(tmp_path, monkeypat
result = runtime.restart(GatewayStartOptions(port=18790))
assert result.ok is True
assert not runtime._restart_intent_path.exists()
def test_restart_intent_only_applies_to_the_recorded_gateway_process(tmp_path):
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
runtime._write_restart_intent(os.getpid())
assert runtime.preserves_inflight_turns_on_exit() is True
runtime._write_restart_intent(os.getpid() + 1)
assert runtime.preserves_inflight_turns_on_exit() is False
def test_last_interactive_client_stops_an_on_demand_gateway(tmp_path, monkeypatch):
+41
View File
@@ -75,6 +75,47 @@ async def test_stale_incomplete_transcript_waits_for_confirmation(tmp_path: Path
assert event.status == "awaiting_user"
@pytest.mark.asyncio
async def test_materialized_interruption_can_continue_from_saved_context(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Older shutdowns may have cleared the checkpoint after saving partial history."""
sessions = SessionManager(tmp_path)
session = sessions.get_or_create("websocket:chat")
session.messages.extend(
[
{"role": "user", "content": "research this"},
{"role": "assistant", "content": "I will check."},
{"role": "tool", "tool_call_id": "search-1", "content": "saved result"},
]
)
_persist(sessions, session)
monkeypatch.setattr(
"nanobot.webui.transcript.has_unfinished_transcript_tail",
lambda _key: True,
)
coordinator, bus, restarted = _coordinator(tmp_path)
await coordinator.scan()
state = restarted.get_or_create("websocket:chat").metadata[RECOVERY_METADATA_KEY]
assert state["status"] == "awaiting_user"
assert state["reason"] == "interrupted_with_saved_context"
assert "can_continue" not in state
event = bus.outbound.get_nowait().event
assert isinstance(event, RecoveryStateEvent)
assert event.can_continue is None
await coordinator.handle_action(
"continue",
{"chat_id": "chat", "recovery_id": state["recovery_id"]},
)
continuation = bus.inbound.get_nowait()
assert continuation.session_key_override == "websocket:chat"
@pytest.mark.asyncio
async def test_transcript_only_interruption_is_discovered_without_materializing_completed_history(
tmp_path: Path,