mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-13 21:27:41 +02:00
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01En8dRGsJPLrJCQBEkTH4Mp
235 lines
11 KiB
Python
235 lines
11 KiB
Python
"""An outage longer than the in-turn ladder must park the turn, not end it.
|
|
|
|
The 335s of CAPACITY_BACKOFFS is a blip's worth of patience. A closed lid, a switched network or a
|
|
provider's bad ten minutes outlasts it, and the user then comes back to a task that stopped for a
|
|
reason that was never theirs. These pin the widening retry, its bound, and the persistence that
|
|
makes quitting mid-wait survivable. Cross-platform by construction: file state and asyncio only,
|
|
no signals and no platform paths.
|
|
"""
|
|
|
|
import asyncio
|
|
|
|
import backend.apps.agents.core.ws_manager as ws_mod
|
|
from backend.apps.agents.core.models import AgentSession, Message
|
|
from backend.apps.agents.manager.run.handle_run_error import handle_run_error
|
|
from backend.apps.agents.manager.run.reconnect_resume import (
|
|
RECONNECT_BACKOFFS,
|
|
RECONNECT_MAX_DELAY_S,
|
|
arm_reconnect_resume,
|
|
clear_reconnect_wait,
|
|
)
|
|
from backend.apps.agents.manager.streaming.state import TurnState
|
|
|
|
|
|
def p_session() -> AgentSession:
|
|
s = AgentSession(name="t", model="sonnet", dashboard_id="d")
|
|
s.messages.append(Message(role="user", content="long task", branch_id=s.active_branch_id))
|
|
return s
|
|
|
|
|
|
def p_drive(monkeypatch, exc, session=None, stderr=None):
|
|
events = []
|
|
|
|
async def fake_send(session_id, event, data):
|
|
events.append((event, data))
|
|
|
|
monkeypatch.setattr(ws_mod.ws_manager, "send_to_session", fake_send, raising=True)
|
|
import backend.apps.service.client as service_client
|
|
monkeypatch.setattr(service_client, "submit_diagnostic", lambda payload: None, raising=True)
|
|
session = session or p_session()
|
|
asyncio.run(handle_run_error(exc, session, session.id, TurnState(), stderr or []))
|
|
return session, events
|
|
|
|
|
|
def test_an_outage_parks_the_turn_instead_of_ending_it(monkeypatch):
|
|
session, events = p_drive(monkeypatch, ConnectionError("Connection reset by peer"))
|
|
assert session.pending_continuation is True, "the work is queued to continue"
|
|
assert session.pending_continuation_delay_s == RECONNECT_BACKOFFS[0]
|
|
assert session.awaiting_reconnect is True
|
|
assert session.needs_respawn is True, "the CLI died with the outage; a new process resumes the transcript"
|
|
assert not [m for m in session.messages if m.role == "system"], "no card: nothing is over yet"
|
|
assert "agent:reconnect_wait" in [e for e, _ in events]
|
|
assert "agent:rate_limited" not in [e for e, _ in events]
|
|
|
|
|
|
def test_the_wait_widens_and_then_concedes(monkeypatch):
|
|
session = p_session()
|
|
for expected in RECONNECT_BACKOFFS:
|
|
session.pending_continuation = False
|
|
p_drive(monkeypatch, ConnectionError("network is unreachable"), session=session)
|
|
assert session.pending_continuation_delay_s == expected
|
|
|
|
# Budget spent: the honest pill fires rather than a fourth, longer silence.
|
|
session.pending_continuation = False
|
|
session, events = p_drive(monkeypatch, ConnectionError("network is unreachable"), session=session)
|
|
assert session.pending_continuation is False
|
|
assert "agent:rate_limited" in [e for e, _ in events]
|
|
assert session.status == "completed"
|
|
|
|
|
|
def test_a_provider_reset_hint_outranks_our_schedule_but_is_capped():
|
|
s = p_session()
|
|
assert arm_reconnect_resume(s, retry_after_s=600) == 605
|
|
s2 = p_session()
|
|
assert arm_reconnect_resume(s2, retry_after_s=99999) == RECONNECT_MAX_DELAY_S
|
|
|
|
|
|
def test_a_shorter_hint_never_shrinks_the_wait():
|
|
"""Negative control: a 1s hint during a real outage must not become a 1s hot loop."""
|
|
s = p_session()
|
|
assert arm_reconnect_resume(s, retry_after_s=1) == RECONNECT_BACKOFFS[0]
|
|
|
|
|
|
def test_an_armed_continuation_is_never_stomped():
|
|
"""Negative control: something else already queued the next turn, so this must stand down."""
|
|
s = p_session()
|
|
s.pending_continuation = True
|
|
s.pending_continuation_prompt = "someone else's continuation"
|
|
assert arm_reconnect_resume(s) is None
|
|
assert s.pending_continuation_prompt == "someone else's continuation"
|
|
|
|
|
|
def test_a_turn_that_got_through_returns_the_budget():
|
|
s = p_session()
|
|
arm_reconnect_resume(s)
|
|
assert s.awaiting_reconnect is True
|
|
clear_reconnect_wait(s)
|
|
assert s.awaiting_reconnect is False
|
|
|
|
|
|
def test_quitting_mid_wait_leaves_an_owed_turn(monkeypatch, tmp_path):
|
|
"""The whole point of persisting the flag: the app going down DURING the wait must not be how
|
|
a task quietly evaporates. Boot-restore has to see it, even though the status says completed."""
|
|
from backend.apps.agents.agent_manager import AgentManager
|
|
import backend.apps.agents.manager.session.SessionPersistence as sp
|
|
|
|
parked = {
|
|
"id": "sess-parked", "status": "completed", "awaiting_reconnect": True,
|
|
"closed_at": None, "active_branch_id": "main",
|
|
"messages": [{"role": "user", "content": "go", "branch_id": "main"}],
|
|
}
|
|
saved = {}
|
|
monkeypatch.setattr(sp, "load_all_session_data", lambda: [("sess-parked", parked)], raising=True)
|
|
monkeypatch.setattr(sp, "save_session", lambda sid, data: saved.update({sid: data}), raising=True)
|
|
|
|
mgr = AgentManager()
|
|
asyncio.run(mgr.reconcile_on_startup())
|
|
assert "sess-parked" in mgr.crash_resume_queue, "a parked turn is an owed turn"
|
|
assert saved["sess-parked"]["awaiting_reconnect"] is False, "the flag is consumed, not left to re-fire"
|
|
|
|
|
|
def test_the_breaker_stops_a_task_that_keeps_killing_the_app(monkeypatch):
|
|
"""If the work itself is what takes the process down, a second boot must hand it to the manual
|
|
chip rather than launching it again."""
|
|
import backend.apps.agents.manager.session.SessionPersistence as sp
|
|
from backend.apps.agents.agent_manager import AgentManager
|
|
|
|
parked = {
|
|
"id": "sess-bad", "status": "completed", "awaiting_reconnect": True,
|
|
"closed_at": None, "active_branch_id": "main", "crash_interrupt_count": 1,
|
|
"messages": [{"role": "user", "content": "go", "branch_id": "main"}],
|
|
}
|
|
monkeypatch.setattr(sp, "load_all_session_data", lambda: [("sess-bad", parked)], raising=True)
|
|
monkeypatch.setattr(sp, "save_session", lambda sid, data: None, raising=True)
|
|
|
|
mgr = AgentManager()
|
|
asyncio.run(mgr.reconcile_on_startup())
|
|
assert mgr.crash_resume_queue == [], "second consecutive death: no third automatic run"
|
|
|
|
|
|
def test_the_wait_ends_the_moment_the_provider_answers(monkeypatch):
|
|
"""The backoff is a CEILING, not a sentence. A blind sleep would leave someone watching a
|
|
spinner for fourteen more minutes after their wifi came back, which is worse than the button it
|
|
replaced (Eric, 2026-08-20)."""
|
|
import backend.apps.agents.manager.run.reconnect_resume as rr
|
|
|
|
calls = {"probes": 0, "slept": 0.0}
|
|
|
|
async def fake_sleep(secs, *a, **k):
|
|
calls["slept"] += secs
|
|
|
|
async def reachable_on_third_look(host):
|
|
calls["probes"] += 1
|
|
return calls["probes"] >= 3
|
|
|
|
monkeypatch.setattr(rr.asyncio, "sleep", fake_sleep, raising=False)
|
|
monkeypatch.setattr(rr, "provider_reachable", reachable_on_third_look, raising=True)
|
|
|
|
s = p_session()
|
|
asyncio.run(rr.wait_for_reconnect(s, 900))
|
|
assert calls["probes"] == 3, "it stops looking as soon as the answer is yes"
|
|
assert calls["slept"] == 3 * rr.RECONNECT_POLL_S, "it waited 9s of a 900s ceiling"
|
|
|
|
|
|
def test_an_outage_that_never_heals_still_honours_the_ceiling(monkeypatch):
|
|
"""Negative control: if nothing ever answers, the wait must END at the ceiling and try anyway,
|
|
not poll forever."""
|
|
import backend.apps.agents.manager.run.reconnect_resume as rr
|
|
|
|
slept = {"total": 0.0}
|
|
|
|
async def fake_sleep(secs, *a, **k):
|
|
slept["total"] += secs
|
|
|
|
async def never(host):
|
|
return False
|
|
|
|
monkeypatch.setattr(rr.asyncio, "sleep", fake_sleep, raising=False)
|
|
monkeypatch.setattr(rr, "provider_reachable", never, raising=True)
|
|
|
|
asyncio.run(rr.wait_for_reconnect(p_session(), 60))
|
|
assert slept["total"] == 60, "the ceiling is honoured exactly, not overshot"
|
|
|
|
|
|
def test_the_probe_targets_the_provider_not_our_own_localhost_router():
|
|
"""A router lane points the CLI at localhost, which answers happily while the machine is
|
|
offline: probing it would return the one wrong answer at the only moment it matters."""
|
|
import backend.apps.agents.manager.run.reconnect_resume as rr
|
|
|
|
s = p_session()
|
|
for model, expected in (
|
|
("sonnet-5", "api.anthropic.com"),
|
|
("gpt-5.6-terra", "api.openai.com"),
|
|
("gemini-3-pro", "generativelanguage.googleapis.com"),
|
|
):
|
|
s.model = model
|
|
host = rr.provider_probe_host(s)
|
|
assert host == expected, f"{model} -> {host}"
|
|
assert "localhost" not in host and "127.0.0.1" not in host
|
|
|
|
|
|
def test_a_dead_socket_respawns_the_cli_but_a_429_does_not(monkeypatch):
|
|
"""Both arrive as 'transient', and they want different recoveries. A dead transport leaves the
|
|
CLI holding a corpse, so the retry needs a fresh one. A 429 is a healthy pipe carrying a NO, and
|
|
respawning for that spends a whole process to be told the same thing (caught by the existing
|
|
test_rate_limit_does_not_respawn_the_cli when this shipped ungated)."""
|
|
dead, _ = p_drive(monkeypatch, ConnectionError("Connection reset by peer"))
|
|
assert dead.needs_respawn is True
|
|
assert dead.awaiting_reconnect is True
|
|
|
|
throttled, _ = p_drive(monkeypatch, Exception("429 rate_limit_error: overloaded"))
|
|
assert throttled.needs_respawn is False, "a refusal is not a broken pipe"
|
|
assert throttled.awaiting_reconnect is True, "but it is still worth waiting out"
|
|
|
|
|
|
def test_a_spent_budget_stops_pretending_the_turn_is_parked(monkeypatch):
|
|
"""Live catch, 2026-08-20 (rate-limited Gemini): after three outage rounds the budget is gone
|
|
and the ask is over, but awaiting_reconnect stayed True. The terminal floor deliberately keeps
|
|
quiet for parked turns, so the stale flag muzzled it and the run ended in total silence: the
|
|
precise failure both features exist to prevent, created by one of them."""
|
|
session = p_session()
|
|
for _ in RECONNECT_BACKOFFS:
|
|
session.pending_continuation = False
|
|
p_drive(monkeypatch, ConnectionError("network is unreachable"), session=session)
|
|
assert session.awaiting_reconnect is True, "still parked while the budget lasts"
|
|
|
|
session.pending_continuation = False
|
|
p_drive(monkeypatch, ConnectionError("network is unreachable"), session=session)
|
|
assert session.awaiting_reconnect is False, "budget spent: the turn is over, not parked"
|
|
|
|
# And with the flag honest, the floor can finally speak for this session.
|
|
from backend.apps.agents.manager.run.turn_spoke import ensure_turn_spoke
|
|
session.messages.append(Message(role="tool_call", content={"tool": "Read"},
|
|
branch_id=session.active_branch_id))
|
|
assert ensure_turn_spoke(session, "sid") is True
|