[eric] agents: an outage parks the turn and resumes when the provider answers; OAuth connects survive a backend restart

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wtspwSFzZmjCx9UNPAorQ
This commit is contained in:
ciregenz
2026-08-20 08:39:41 -07:00
co-authored by Claude Opus 5
parent d5934b1da1
commit 58e376496e
14 changed files with 652 additions and 4 deletions
+10 -1
View File
@@ -50,7 +50,13 @@ class AgentManager(SessionLifecycle, SessionPersistence, Messaging, SessionContr
already resumes the work, so the stale continuation quietly stands down."""
if delay_s > 0:
p_before = len(getattr(self.sessions.get(session_id), "messages", []) or [])
await asyncio.sleep(delay_s)
p_parked = self.sessions.get(session_id)
if p_parked is not None and getattr(p_parked, "awaiting_reconnect", False):
# An outage wait is a CEILING, not a sentence: a blind sleep strands the user long after their wifi returned. Rotation waits keep the flat sleep, where the window IS the point.
from backend.apps.agents.manager.run.reconnect_resume import wait_for_reconnect
await wait_for_reconnect(p_parked, delay_s)
else:
await asyncio.sleep(delay_s)
p_session = self.sessions.get(session_id)
if p_session is None:
return
@@ -229,6 +235,9 @@ class AgentManager(SessionLifecycle, SessionPersistence, Messaging, SessionContr
force_respawn=p_force_respawn,
)
session.status = "completed"
# The turn got through, so the outage is over: the next unrelated blip starts from a full budget.
from backend.apps.agents.manager.run.reconnect_resume import clear_reconnect_wait
clear_reconnect_wait(session)
# Silent-quit seal: a turn that ran tools and ended with no visible answer gets ONE hidden continue nudge (dispatched by the auto-continuation block below); a second silent quit in the same ask surfaces as-is rather than looping.
try:
@@ -328,6 +328,17 @@ def is_cert_failure(exc: BaseException, extra_text: str = "") -> bool:
return bool(CERT_FAILURE_PATTERNS.search(f"{exc!s}\n{extra_text}"))
@typechecked
def is_connection_lost(exc: BaseException) -> bool:
"""True when the transport itself died, as opposed to the provider answering with a refusal.
Both arrive as "transient", but they want different recoveries: a dead socket leaves the CLI
holding a corpse and must respawn, while a 429 is a healthy connection carrying a NO, where
respawning just spends a process to be told the same thing.
"""
return isinstance(exc, p_get_transient_exc_types())
@typechecked
def is_transient_capacity_error(exc: BaseException, extra_text: str = "") -> bool:
# The Claude CLI's underlying ProcessError stringifies to a generic "Command failed with exit code 1 / Check stderr output for details"; the real cause (rate_limit_error / No pool capacity available / 429 / overloaded) only surfaces in the subprocess's stderr stream, which we capture via the SDK's `stderr` callback and pass in as extra_text. Classify against both so we catch capacity errors regardless of which channel carried the message.
+4
View File
@@ -152,6 +152,10 @@ class AgentSession(BaseModel):
suppress_recap_once: bool = False
# Consecutive dirty deaths this session was MID-TURN for; the crash auto-resume breaker (hermes #30719 pairing: auto-resume must never outrun its circuit breaker).
crash_interrupt_count: int = 0
# Outage rounds spent on this ask: the in-turn ladder covers only 335s, and the work is checkpointed, so a longer drop is waited out rather than ending the task.
reconnect_attempts: int = 0
# True while a turn is parked waiting for the connection back; persisted so a quit DURING the wait is still an owed turn at next boot.
awaiting_reconnect: bool = False
# Seconds the auto-continuation dispatcher sleeps before sending (codex rotation windows last 1-2 min; an instant retry lands inside the same window and burns the one-shot budget).
pending_continuation_delay_s: int = 0
# Memory prompt block frozen at first compose (prefix-cache discipline: mid-chat fact writes must
+3
View File
@@ -152,6 +152,9 @@ class Messaging(AgentManagerProtocol):
session.empty_finish_progress_mark = 0
session.empty_finish_surfaced = False
session.auth_retry_used = False
# A human is here and driving, so an earlier outage stops counting against the next one.
session.reconnect_attempts = 0
session.awaiting_reconnect = False
# Fire a background aux LLM call to generate a 3-6 word verb-phrase describing this turn ("Auditing the pull request", "Drafting your email"). The narrator pill swaps from its heuristic verb to this label as soon as it lands, usually ~500ms-1s into the turn, which is exactly when "Thinking…" starts feeling generic. Provider-agnostic via resolve_aux_model. Non-blocking; failure is silent and the heuristic stays.
if not hidden and prompt:
try:
@@ -21,6 +21,7 @@ from backend.apps.agents.core.error_classify import (
is_auth_error,
is_cert_failure,
is_cli_binary_missing,
is_connection_lost,
is_unknown_model_error,
parse_retry_after,
)
@@ -178,6 +179,17 @@ async def handle_run_error(e: Exception, session: AgentSession, session_id: str,
logger.debug("submit_diagnostic cli_binary_missing failed", exc_info=True)
elif is_transient_capacity_error(e, extra_text=p_stderr_tail):
# A genuine throttle (429/overload/capacity) that already burned the whole silent-backoff budget (the only way one reaches here). It's a limit, not a failure, so don't append a system-message card; emit a transient signal for the muted pill and mark the turn completed so it doesn't read as an error.
# 335s of ladder is a blip's worth of patience, and a closed lid or switched network outlasts it, so park and retry before conceding a turn the user never chose to end.
from backend.apps.agents.manager.run.reconnect_resume import arm_reconnect_resume
p_delay = arm_reconnect_resume(session, parse_retry_after(e, p_stderr_tail), is_connection_lost(e))
if p_delay is not None:
logger.info(f"Agent {session_id}: connection lost past the in-turn budget; retrying in {p_delay}s")
await ws_manager.send_to_session(session_id, "agent:reconnect_wait", {
"session_id": session_id,
"retry_in_s": p_delay,
"attempt": session.reconnect_attempts,
})
return
session.status = "completed"
if turn.stream_text_msg_id:
try:
@@ -0,0 +1,136 @@
"""Keep a turn alive across an outage that outlasts the turn's own retry budget.
The in-turn ladder (CAPACITY_BACKOFFS) spends 335s and then gives up, which is the right call for
a blip. It is the wrong call for a closed lid, a switched network, a hotel captive portal or a
provider having a bad ten minutes: the user comes back to a task that stopped for a reason that was
never theirs, and has to retype it.
Nothing about that is unrecoverable. The transcript is already checkpointed, the tools that ran are
still recorded, and the continuation seam that the auth self-heal uses will pick the work back up
mid-task. So an outage becomes a wait on a widening schedule rather than an ending.
Two properties this deliberately keeps:
- it is bounded (three rounds, then the honest pill), because a retry loop with no end is how you
burn a user's quota on a provider that is genuinely gone;
- the wait is PERSISTED, so quitting mid-wait leaves an owed turn that boot-restore resumes,
instead of a task that evaporated while nobody was looking.
"""
import asyncio
import logging
from typing import Optional
from typeguard import typechecked
from backend.apps.agents.core.models import AgentSession
logger = logging.getLogger(__name__)
# Widening but short at the start: most outages are seconds, and a user should see it heal itself rather than learn to press a button.
RECONNECT_BACKOFFS = (60, 300, 900)
# A provider's own "reset after" hint outranks our schedule, but capped, because a bad hint must not park a turn for an hour.
RECONNECT_MAX_DELAY_S = 1800
RECONNECT_PROMPT = (
"The connection to the model dropped and has just come back. Continue exactly where you left "
"off; do not redo completed steps."
)
@typechecked
def arm_reconnect_resume(session: AgentSession, retry_after_s: Optional[int] = None,
connection_lost: bool = False) -> Optional[int]:
"""Park the turn and queue one more attempt. Returns the delay armed, or None when the budget
is spent and the caller should surface the honest pill instead.
Retrying the work IS the connectivity test, so there is no separate reachability oracle to get
wrong: either the next attempt goes through, or it fails and buys the next (longer) round.
"""
if session.pending_continuation:
return None
attempts = int(getattr(session, "reconnect_attempts", 0) or 0)
if attempts >= len(RECONNECT_BACKOFFS):
return None
delay = RECONNECT_BACKOFFS[attempts]
if retry_after_s and retry_after_s > 0:
delay = max(delay, min(int(retry_after_s) + 5, RECONNECT_MAX_DELAY_S))
session.reconnect_attempts = attempts + 1
session.awaiting_reconnect = True
# Only a dead transport leaves the CLI holding a corpse; a 429 is a healthy pipe carrying a NO, and respawning for that spends a process to be told the same thing.
if connection_lost:
session.needs_fresh_session = True
session.pending_continuation = True
session.pending_continuation_prompt = RECONNECT_PROMPT
session.pending_continuation_delay_s = delay
return delay
@typechecked
def clear_reconnect_wait(session: AgentSession) -> None:
"""A turn that got through ends the outage: drop the parked flag so a later, unrelated blip
starts from a full budget rather than inheriting this one's."""
session.awaiting_reconnect = False
# How often to look while parked: short enough that a wifi blip costs seconds, long enough to stay cheap over a 15 minute outage.
RECONNECT_POLL_S = 3
# The probe must be fast: a hung connect would turn "check every 3s" into "check whenever the socket gives up".
RECONNECT_PROBE_TIMEOUT_S = 1.5
@typechecked
def provider_probe_host(session: AgentSession) -> str:
"""The host whose reachability actually decides whether a retry can succeed.
Router-backed lanes point the CLI at localhost, so probing THAT would come back healthy while
the machine is offline, which is the wrong answer at the only moment it matters. Probe the
provider the router is proxying to instead.
"""
p_model = (getattr(session, "model", "") or "").lower()
if p_model.startswith(("cx/", "gpt-")) or "openai" in p_model:
return "api.openai.com"
if p_model.startswith(("gc/", "ag/", "gemini")) or "gemini" in p_model:
return "generativelanguage.googleapis.com"
return "api.anthropic.com"
@typechecked
async def provider_reachable(host: str) -> bool:
"""True when a TCP connection to the provider completes. Deliberately not an HTTP request: no
auth, no cost, no quota, and nothing that a retry would have spent anyway."""
try:
p_fut = asyncio.open_connection(host, 443)
reader, writer = await asyncio.wait_for(p_fut, timeout=RECONNECT_PROBE_TIMEOUT_S)
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
return True
except Exception:
return False
@typechecked
async def wait_for_reconnect(session: AgentSession, ceiling_s: int) -> None:
"""Sleep until the provider answers again, or until the ceiling, whichever comes FIRST.
The backoff is a bound on patience, never a fixed sentence: a blind sleep would leave a user
watching a spinner for fourteen more minutes after their wifi already came back, which is worse
than the button it replaced. A captive portal can still answer TCP and fail the real request;
that costs one round and lands us exactly where a blind wait would have been anyway.
"""
p_host = provider_probe_host(session)
p_waited = 0
while p_waited < ceiling_s:
p_step = min(RECONNECT_POLL_S, ceiling_s - p_waited)
await asyncio.sleep(p_step)
p_waited += p_step
if await provider_reachable(p_host):
logger.info(f"reconnect: {p_host} answered after {p_waited}s (ceiling was {ceiling_s}s)")
return
logger.info(f"reconnect: ceiling {ceiling_s}s reached without {p_host} answering; trying anyway")
@@ -49,6 +49,19 @@ class SessionPersistence(AgentManagerProtocol):
self.crash_resume_queue.append(sid)
else:
logger.warning(f"crash-resume breaker: session {sid} was mid-turn at {count} consecutive dirty deaths; leaving it for the manual chip")
elif data.get("awaiting_reconnect") and data.get("closed_at") is None:
# Parked mid-outage when the app went down. The file says "completed" only because
# the wait was dispatched as a continuation, so the status check above cannot see
# it; without this the task the user never chose to end just evaporates.
data["awaiting_reconnect"] = False
dirty = True
count = int(data.get("crash_interrupt_count", 0) or 0) + 1
data["crash_interrupt_count"] = count
if count <= 1:
self.crash_resume_queue.append(sid)
else:
logger.warning(f"crash-resume breaker: session {sid} was parked mid-outage at {count} consecutive dirty deaths; leaving it for the manual chip")
# Mode migration: Chat was merged into Ask. Rewrite mode="chat" so old sessions keep loading after the chat.json file is gone.
if data.get("mode") == "chat":
data["mode"] = "ask"
+100 -2
View File
@@ -1,10 +1,108 @@
# In-memory store for pending OAuth flows (state -> {provider, code_verifier, redirect_uri})
pending_oauth: dict[str, dict] = {}
"""Pending and recently-completed OAuth flows.
The pending map used to live only in memory, and the gap that opens is the whole of ENG-363: the
user clicks Connect, the browser leaves for the provider, the backend restarts for ANY reason
(uvicorn reload in dev, the ENG-357 frozen-loop exit, a watchdog respawn, a crash), and the state
that proves the returning callback is ours is simply gone. The callback then lands on the
unknown-state branch and renders "Session expired", the Settings row spins forever, and the user
concludes the product cannot connect to Anthropic. Haik reported exactly that.
Retrying is not the fix, because the user is not the one who failed. Making the state outlive a
restart is: the callback then completes on its own and there is nothing to click.
The verifier is a short-lived, single-use secret, so it is written 0600, expires on a TTL, and is
deleted the moment it is consumed. It never becomes a durable credential lying around on disk.
"""
import json
import os
import time
from typing import Dict, Optional
from typeguard import typechecked
from backend.config.paths import DATA_ROOT
# One OAuth round trip is a browser hop and a login; a quarter hour is generous for a human doing that, and short enough that an abandoned flow's verifier does not linger.
PENDING_TTL_S = 15 * 60
PENDING_PATH = os.path.join(DATA_ROOT, "pending_oauth.json")
@typechecked
def p_load() -> Dict[str, dict]:
"""Read the durable map, dropping anything past its TTL. Unreadable state is treated as empty: a corrupt file must not make Connect permanently impossible."""
try:
with open(PENDING_PATH, encoding="utf-8") as fh:
raw = json.load(fh)
except Exception:
return {}
if not isinstance(raw, dict):
return {}
now = time.time()
return {
k: v for k, v in raw.items()
if isinstance(v, dict) and float(v.get("stored_at", 0) or 0) + PENDING_TTL_S > now
}
@typechecked
def p_store(entries: Dict[str, dict]) -> None:
"""Write 0600 and replace atomically, so a crash mid-write cannot leave a half-parsed file that strands every later Connect."""
try:
os.makedirs(DATA_ROOT, exist_ok=True)
tmp = f"{PENDING_PATH}.tmp"
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(entries, fh)
os.chmod(tmp, 0o600)
os.replace(tmp, PENDING_PATH)
except OSError:
pass
class PendingOAuth:
"""Dict-shaped so every existing call site keeps working, but backed by disk.
Deliberately not a plain dict subclass: the whole point is that reads come from the file, so an
entry written before a restart is still found by the process that comes back.
"""
@typechecked
def __setitem__(self, state: str, value: dict) -> None:
entries = p_load()
entries[state] = {**value, "stored_at": time.time()}
p_store(entries)
@typechecked
def get(self, state: str, default: Optional[dict] = None) -> Optional[dict]:
return p_load().get(state, default)
@typechecked
def pop(self, state: str, default: Optional[dict] = None) -> Optional[dict]:
entries = p_load()
found = entries.pop(state, None)
if found is None:
return default
# Consumed: the verifier is single-use, so it stops existing here rather than aging out later.
p_store(entries)
return found
@typechecked
def __contains__(self, state: str) -> bool:
return state in p_load()
@typechecked
def __len__(self) -> int:
return len(p_load())
pending_oauth = PendingOAuth()
# Recently-completed OAuth states so the /api/subscriptions/callback handler can distinguish a legitimate duplicate callback (browser prefetch, refresh, or Google redirect retry after a slow first response) from a truly stale request. Bounded FIFO, drops the oldest entries once it grows past MAX_COMPLETED_OAUTH so it can't leak memory.
completed_oauth: list[str] = []
MAX_COMPLETED_OAUTH = 64
@typechecked
def mark_oauth_completed(state: str) -> None:
if state in completed_oauth:
return
+68
View File
@@ -0,0 +1,68 @@
"""ENG-363: a Connect that survives a backend restart.
The user clicks Connect, the browser leaves for Anthropic, the backend restarts for any reason, and
the returning callback used to find nothing and render "Session expired" while Settings spun
forever. Haik reported that as "Model connection for anthropic does not work". The state is ours to
keep, so keeping it is the fix; there is nothing for the user to retry.
"""
import json
import os
import time
import pytest
@pytest.fixture()
def p_store(monkeypatch, tmp_path):
import backend.apps.oauth_state as st
monkeypatch.setattr(st, "PENDING_PATH", str(tmp_path / "pending_oauth.json"), raising=True)
monkeypatch.setattr(st, "DATA_ROOT", str(tmp_path), raising=True)
return st
def test_a_pending_flow_survives_a_restart(p_store):
p_store.pending_oauth["state-abc"] = {
"provider": "claude", "code_verifier": "v", "redirect_uri": "http://localhost:20128/cb",
}
# A restart is a brand new object over the same file; the old process kept nothing.
fresh = p_store.PendingOAuth()
found = fresh.pop("state-abc")
assert found is not None, "the callback must still recognise its own flow"
assert found["code_verifier"] == "v"
def test_consuming_a_flow_deletes_the_verifier(p_store):
p_store.pending_oauth["state-abc"] = {"provider": "claude", "code_verifier": "secret"}
p_store.pending_oauth.pop("state-abc")
on_disk = json.load(open(p_store.PENDING_PATH))
assert on_disk == {}, "a single-use secret must not outlive its use"
def test_an_abandoned_flow_ages_out(p_store):
p_store.pending_oauth["stale"] = {"provider": "claude", "code_verifier": "x"}
raw = json.load(open(p_store.PENDING_PATH))
raw["stale"]["stored_at"] = time.time() - (p_store.PENDING_TTL_S + 60)
open(p_store.PENDING_PATH, "w").write(json.dumps(raw))
assert p_store.pending_oauth.get("stale") is None
assert "stale" not in p_store.pending_oauth
def test_the_verifier_is_not_world_readable(p_store):
p_store.pending_oauth["state-abc"] = {"provider": "claude", "code_verifier": "secret"}
mode = os.stat(p_store.PENDING_PATH).st_mode & 0o777
assert mode == 0o600, f"pending verifiers must be owner-only, got {oct(mode)}"
def test_a_corrupt_file_never_blocks_connecting(p_store):
"""Negative control: unreadable state must degrade to 'no pending flow', never to an exception
that makes Connect impossible forever."""
open(p_store.PENDING_PATH, "w").write("{not json")
assert p_store.pending_oauth.get("anything") is None
p_store.pending_oauth["fresh"] = {"provider": "claude", "code_verifier": "v"}
assert p_store.pending_oauth.get("fresh") is not None, "a new flow still works"
def test_an_unknown_state_is_still_unknown(p_store):
"""Negative control: durability must not make the handler accept a state nobody issued."""
assert p_store.pending_oauth.pop("never-issued") is None
+212
View File
@@ -0,0 +1,212 @@
"""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_fresh_session is True, "the CLI died with the outage; resume on a fresh one"
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_fresh_session is True
assert dead.awaiting_reconnect is True
throttled, _ = p_drive(monkeypatch, Exception("429 rate_limit_error: overloaded"))
assert throttled.needs_fresh_session is False, "a refusal is not a broken pipe"
assert throttled.awaiting_reconnect is True, "but it is still worth waiting out"
@@ -69,7 +69,7 @@ import { isShowUiPair, isAskUiPair, extractPendingAskUi, callToolUseId, resultTo
import { composerPlaceholder } from './composerPlaceholder';
import ApprovalBar, { BatchApprovalBar } from './shell/ApprovalBar';
import ForceStopAgentBar from './ForceStopAgentBar';
import { ProviderRetryPill, RateLimitPill } from './shell/RateLimitPill';
import { ProviderRetryPill, RateLimitPill, ReconnectWaitPill } from './shell/RateLimitPill';
import { ContextRecoveredPill } from './shell/ContextRecoveredPill';
import ChatInput, { ChatInputHandle } from './ChatInput';
import FollowupChips from './FollowupChips';
@@ -2111,6 +2111,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
)}
<RateLimitPill sessionId={session.id} />
<ReconnectWaitPill sessionId={session.id} />
<ProviderRetryPill sessionId={session.id} />
<ContextRecoveredPill sessionId={session.id} />
@@ -99,3 +99,46 @@ export const RateLimitPill: React.FC<{ sessionId: string }> = ({ sessionId }) =>
</Fade>
);
};
/** The connection dropped for longer than the in-turn retry ladder covers, so the turn is PARKED
* rather than finished: it wakes itself on a widening schedule and continues where it left off.
* This is the one pill that must NOT auto-clear on a short timer, because the wait it describes can
* be fifteen minutes; an agent sitting silent that long is exactly what makes people force-quit and
* lose the task. It clears when the next turn actually lands. */
export const ReconnectWaitPill: React.FC<{ sessionId: string }> = ({ sessionId }) => {
const c = useClaudeTokens();
const rw = useAppSelector((s) => s.agents.sessions[sessionId]?.reconnect_wait);
const label = (() => {
const secs = rw?.retry_in_s ?? 0;
if (!secs) return 'Connection lost, retrying';
const mins = Math.round(secs / 60);
return mins >= 1 ? `Connection lost, retrying in ~${mins} min` : 'Connection lost, retrying shortly';
})();
const lastLabel = useRef(label);
if (rw) lastLabel.current = label;
return (
<Fade in={!!rw} timeout={{ enter: 200, exit: 220 }} unmountOnExit>
<Box
title="Your work is saved. The agent is waiting for the connection and will pick up where it left off, with nothing for you to click."
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.6,
alignSelf: 'flex-start',
mx: 2,
mb: 1,
px: 1.25,
py: 0.5,
borderRadius: 999,
bgcolor: c.bg.secondary,
color: c.text.tertiary,
}}
>
<AutorenewIcon sx={{ fontSize: 14, animation: 'osw-retry-spin 1.6s linear infinite', '@keyframes osw-retry-spin': { to: { transform: 'rotate(360deg)' } } }} />
<Typography sx={{ fontSize: '0.75rem', fontWeight: 500 }}>{lastLabel.current}</Typography>
</Box>
</Fade>
);
};
+23
View File
@@ -118,6 +118,8 @@ export interface AgentSession {
framework_overhead_tokens?: number;
context_overflow?: { reason: string; message: string; at: string } | null;
rate_limited?: { retry_after_s: number | null; at: string } | null;
// Parked waiting for the connection back; unlike the pills above this can last minutes, so the UI has to say so.
reconnect_wait?: { retry_in_s: number | null; attempt: number | null; at: string } | null;
provider_retrying?: { attempt: number | null; delay_ms: number | null; at: string } | null;
context_recovered?: { at: string } | null;
// Set when a view-builder turn installed/changed deps, so the app card does a HARD reload (Vite restart) at turn-finish instead of the soft one. Reset when the next turn starts.
@@ -1051,6 +1053,25 @@ const agentsSlice = createSlice({
if (session) session.rate_limited = null;
},
setReconnectWait(
state,
action: PayloadAction<{ sessionId: string; retryInS: number | null; attempt: number | null }>
) {
const session = state.sessions[action.payload.sessionId];
if (session) {
session.reconnect_wait = {
retry_in_s: action.payload.retryInS,
attempt: action.payload.attempt,
at: new Date().toISOString(),
};
}
},
clearReconnectWait(state, action: PayloadAction<{ sessionId: string }>) {
const session = state.sessions[action.payload.sessionId];
if (session) session.reconnect_wait = null;
},
setAppDepsChanged(state, action: PayloadAction<{ sessionId: string }>) {
const session = state.sessions[action.payload.sessionId];
if (session) session.app_deps_changed = true;
@@ -1543,6 +1564,8 @@ export const {
setContextOverflow,
setRateLimited,
clearRateLimited,
setReconnectWait,
clearReconnectWait,
setProviderRetrying,
clearProviderRetrying,
setContextRecovered,
@@ -14,6 +14,8 @@ import {
updateSessionContext,
setContextOverflow,
setRateLimited,
setReconnectWait,
clearReconnectWait,
setProviderRetrying,
setContextRecovered,
setAppDepsChanged,
@@ -474,6 +476,8 @@ class WebSocketManager {
}
if (data.status === 'running' && session_id) {
store.dispatch(trackAgentNotification(session_id));
// The parked-for-reconnect pill describes a wait that just ended; leaving it up outlives the recovery it was announcing.
store.dispatch(clearReconnectWait({ sessionId: session_id }));
}
// Native OS notification when an agent finishes while the user is elsewhere: workflows already had this; long chat tasks deserve the same "it's done" tap on both platforms. Sub-agents stay silent (their parent's finish is the story).
if (data.status === 'completed' && session_id && document.hidden) {
@@ -715,6 +719,17 @@ class WebSocketManager {
}
break;
case 'agent:reconnect_wait':
// The turn is PARKED, not over: it retries itself on a widening schedule, and an agent that looks idle for fifteen minutes reads as broken.
if (session_id) {
store.dispatch(setReconnectWait({
sessionId: session_id,
retryInS: typeof data.retry_in_s === 'number' ? data.retry_in_s : null,
attempt: typeof data.attempt === 'number' ? data.attempt : null,
}));
}
break;
case 'agent:context_recovered':
// The backend hit a context-overflow crash mid-turn, rebuilt from its local copy, and retried on its own. Transient muted pill so the recovery is visible without reading like an error.
if (session_id) {