From c11df8eea33bd0a1b641feb308c4c50a91904f5e Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 14 Aug 2026 17:03:53 -0700 Subject: [PATCH] [eric] agents: a frozen core tool recovers in 25s and the agent retries the step it lost --- .../manager/streaming/unwedge_sidecar.py | 34 +++++++++++--- backend/tests/test_unwedge_sidecar.py | 44 ++++++++++++++++++- 2 files changed, 71 insertions(+), 7 deletions(-) diff --git a/backend/apps/agents/manager/streaming/unwedge_sidecar.py b/backend/apps/agents/manager/streaming/unwedge_sidecar.py index 933e144e..ece8944e 100644 --- a/backend/apps/agents/manager/streaming/unwedge_sidecar.py +++ b/backend/apps/agents/manager/streaming/unwedge_sidecar.py @@ -18,10 +18,11 @@ from typeguard import typechecked logger = logging.getLogger(__name__) -# Generous by design: the quick class answers in milliseconds (memory, settings, schedule CRUD), -# so a minute of silence is not slowness, it is a frozen process. Raising this only lengthens the -# outage a user sits through; lowering it risks shooting a healthy-but-busy sidecar. -WEDGE_SECONDS = 75.0 +# The quick class answers in MILLISECONDS (memory, settings, schedule CRUD), so 25s is already a +# thousandfold margin: long enough that nothing healthy trips it, short enough that a user reads +# the recovery as a hiccup rather than a hang. Anything that legitimately blocks (a human, a +# delegated run) is exempt by name below, so this deadline never races real work. +WEDGE_SECONDS = 25.0 P_CORE_PREFIX = "mcp__openswarm-core__" @@ -70,6 +71,27 @@ def find_sidecar_pids(session_id: str) -> list: return pids +RETRY_PROMPT = ( + "Your last tool call never returned because its server had frozen; that server has been " + "restarted and works now. Retry that one step, then carry on where you left off." +) + + +@typechecked +def arm_retry(session: object) -> bool: + """Queue one hidden continuation so the agent redoes the lost step. Reuses the seam the + silent-quit nudge already owns, and never stacks on a continuation that is already pending. + Takes the live session the hook holds; there is no global registry to look one up in.""" + if session is None or getattr(session, "pending_continuation", False): + return False + try: + session.pending_continuation = True # type: ignore[attr-defined] + session.pending_continuation_prompt = RETRY_PROMPT # type: ignore[attr-defined] + return True + except Exception: + return False + + @typechecked def unwedge(session_id: str, tool_name: str, outstanding_s: float) -> int: """CONT first: a STOPPED process queues TERM forever (the ghost-reaper lesson, ENG-196), so @@ -120,7 +142,9 @@ def arm_wedge_watchdog(ctx: object, tool_use_id: str, tool_name: str) -> None: session_id = getattr(ctx, "session_id", "") if not session_id: return - # ps + kill are blocking; keep them off the event loop. + # ps + kill are blocking; keep them off the event loop. The retry is armed on the LIVE + # session object the hook holds, so the agent redoes the step the frozen server swallowed. loop.run_in_executor(None, unwedge, session_id, tool_name, time.time() - started) + arm_retry(getattr(ctx, "session", None)) loop.call_later(WEDGE_SECONDS, p_check) diff --git a/backend/tests/test_unwedge_sidecar.py b/backend/tests/test_unwedge_sidecar.py index 4ae3d856..ced5590d 100644 --- a/backend/tests/test_unwedge_sidecar.py +++ b/backend/tests/test_unwedge_sidecar.py @@ -36,8 +36,48 @@ def test_non_core_tools_are_never_watched(): assert not is_quick_core_tool("mcp__github__create_issue") -def test_the_deadline_is_generous_not_twitchy(): - assert WEDGE_SECONDS >= 60, "a quick tool answers in ms; anything under a minute risks healthy kills" +def test_the_deadline_sits_between_twitchy_and_a_hang(): + # The quick class answers in milliseconds, so the floor is about not shooting a healthy-but-busy + # sidecar, and the ceiling is about the user not reading recovery as a hang. + assert 10 <= WEDGE_SECONDS <= 45, ( + f"{WEDGE_SECONDS}s is outside the band: under ~10s risks killing healthy work, " + "over ~45s and the user has already given up" + ) + + +def test_a_retry_is_armed_so_the_lost_step_is_redone(): + from backend.apps.agents.manager.streaming.unwedge_sidecar import RETRY_PROMPT, arm_retry + + class S: + pending_continuation = False + pending_continuation_prompt = "" + + s = S() + assert arm_retry(s) is True + assert s.pending_continuation is True + assert s.pending_continuation_prompt == RETRY_PROMPT + + +def test_a_retry_never_stacks_on_an_existing_continuation(): + from backend.apps.agents.manager.streaming.unwedge_sidecar import arm_retry + + class S: + pending_continuation = True + pending_continuation_prompt = "something else already queued" + + s = S() + assert arm_retry(s) is False + assert s.pending_continuation_prompt == "something else already queued" + + +def test_a_missing_session_is_survivable(): + from backend.apps.agents.manager.streaming.unwedge_sidecar import arm_retry + assert arm_retry(None) is False + + +def test_the_watchdog_arms_the_retry_on_the_live_session(): + src = inspect.getsource(unwedge_sidecar.arm_wedge_watchdog) + assert "arm_retry" in src, "killing the sidecar frees the turn but loses the in-flight call" # --------------------------------------------------------------------- the kill choreography