Skip to content

Commit 415c5f1

Browse files
nathanschramclaude
andauthored
fix: approval-aware stall threshold (#99) (#101)
* fix: suppress stall warnings while waiting for user approval (#99) Use 30-minute threshold instead of 5-minute when the most recent action is a pending approval (has inline_keyboard buttons). Prevents false stall warnings when user is away from Telegram while a permission request waits. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: update changelog and test counts for approval-aware stall threshold Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2b32934 commit 415c5f1

4 files changed

Lines changed: 142 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
- early PID threading: `last_pid` set at subprocess spawn, polled by `run_runner_with_cancel` before `StartedEvent`
1010
- standalone `/cancel` fallback: cancels single active run without requiring reply; prompts when multiple runs active
1111
- `queued_for_chat()` method on `ThreadScheduler` for standalone cancel of queued jobs
12+
- approval-aware stall threshold: 30 min when waiting for user approval (inline keyboard detected), 5 min otherwise
1213

1314
## v0.34.1 (2026-03-07)
1415

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,11 +138,11 @@ Rules in `.claude/rules/` auto-load when editing matching files:
138138

139139
## Tests
140140

141-
1470 tests, 80% coverage threshold. Key test files:
141+
1472 tests, 80% coverage threshold. Key test files:
142142

143143
- `test_claude_control.py` — 56 tests: control requests, response routing, registry lifecycle, auto-approve/auto-deny, tool auto-approve, custom deny messages, discuss action, early toast, progressive cooldown, auto permission mode
144144
- `test_callback_dispatch.py` — 28 tests: callback parsing, dispatch toast/ephemeral behaviour, early answering
145-
- `test_exec_bridge.py`77 tests: ephemeral notification cleanup, approval push notifications, progressive stall warnings, stall diagnostics, stall auto-cancel, session summary, PID/stream threading
145+
- `test_exec_bridge.py`80 tests: ephemeral notification cleanup, approval push notifications, progressive stall warnings, stall diagnostics, stall auto-cancel, approval-aware stall threshold, session summary, PID/stream threading
146146
- `test_ask_user_question.py` — 27 tests: AskUserQuestion control request handling, question extraction, pending request registry, answer routing, option button rendering, multi-question flows, structured answer responses, ask mode toggle auto-deny
147147
- `test_diff_preview.py` — 10 tests: Edit diff display, Write content preview, Bash command display, line/char truncation
148148
- `test_cost_tracker.py` — 56 tests: cost accumulation, per-run/daily budget thresholds, warning levels, daily reset, auto-cancel flag

src/untether/runner_bridge.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -574,7 +574,13 @@ async def _stall_monitor(self) -> None:
574574
elapsed = self.clock() - self._last_event_at
575575
self._peak_idle = max(self._peak_idle, elapsed)
576576

577-
if elapsed < self._STALL_THRESHOLD_SECONDS:
577+
# Use longer threshold when waiting for user approval
578+
threshold = (
579+
self._STALL_THRESHOLD_APPROVAL
580+
if self._has_pending_approval()
581+
else self._STALL_THRESHOLD_SECONDS
582+
)
583+
if elapsed < threshold:
578584
continue
579585
now = self.clock()
580586
if (
@@ -687,6 +693,14 @@ async def _stall_monitor(self) -> None:
687693
exc_info=True,
688694
)
689695

696+
def _has_pending_approval(self) -> bool:
697+
"""Check if the most recent non-completed action is waiting for user approval."""
698+
for action_state in reversed(list(self.tracker._actions.values())):
699+
if not action_state.completed:
700+
return bool(action_state.action.detail.get("inline_keyboard"))
701+
break # only check the most recent
702+
return False
703+
690704
def _last_action_summary(self) -> str | None:
691705
"""Return a short description of the most recent action."""
692706
for action_state in reversed(list(self.tracker._actions.values())):
@@ -799,6 +813,7 @@ async def _delete_notify(ref: MessageRef) -> None:
799813
self.rendered_seq = seq_at_render
800814

801815
_STALL_THRESHOLD_SECONDS: float = 300.0 # 5 minutes
816+
_STALL_THRESHOLD_APPROVAL: float = 1800.0 # 30 minutes when waiting for approval
802817
_STALL_MAX_WARNINGS: int = 10 # absolute cap
803818
_STALL_MAX_WARNINGS_NO_PID: int = 3 # aggressive cap when pid=None + no events
804819

tests/test_exec_bridge.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2106,3 +2106,126 @@ async def drive() -> None:
21062106
c for c in transport.send_calls if "Auto-cancelled" in c["message"].text
21072107
]
21082108
assert len(auto_cancel_msgs) == 1
2109+
2110+
2111+
@pytest.mark.anyio
2112+
async def test_stall_suppressed_while_waiting_for_approval() -> None:
2113+
"""Stall monitor uses longer threshold when pending approval action exists."""
2114+
transport = FakeTransport()
2115+
presenter = _KeyboardPresenter()
2116+
clock = _FakeClock(start=100.0)
2117+
edits = _make_edits(transport, presenter, clock=clock)
2118+
edits._stall_check_interval = 0.01
2119+
edits._STALL_THRESHOLD_SECONDS = 0.05
2120+
edits._STALL_THRESHOLD_APPROVAL = 0.5 # 500ms for test
2121+
2122+
# Simulate a pending approval action (has inline_keyboard in detail)
2123+
from untether.model import Action, ActionEvent
2124+
2125+
evt = ActionEvent(
2126+
engine="codex",
2127+
action=Action(
2128+
id="ctrl.1",
2129+
kind="warning",
2130+
title="Permission Request [CanUseTool] - tool: ExitPlanMode",
2131+
detail={"inline_keyboard": {"buttons": [[{"text": "Approve"}]]}},
2132+
),
2133+
phase="started",
2134+
)
2135+
await edits.on_event(evt)
2136+
clock.set(100.0) # reset after on_event
2137+
2138+
async with anyio.create_task_group() as tg:
2139+
2140+
async def drive() -> None:
2141+
# Advance past normal threshold (0.05) but NOT past approval threshold (0.5)
2142+
clock.set(100.2)
2143+
await anyio.sleep(0.05)
2144+
# Should NOT have warned yet
2145+
edits.signal_send.close()
2146+
2147+
tg.start_soon(edits.run)
2148+
tg.start_soon(drive)
2149+
2150+
# No stall warning should have fired
2151+
assert edits._stall_warn_count == 0
2152+
stall_msgs = [c for c in transport.send_calls if "No progress" in c["message"].text]
2153+
assert len(stall_msgs) == 0
2154+
2155+
2156+
@pytest.mark.anyio
2157+
async def test_stall_fires_after_approval_threshold() -> None:
2158+
"""Stall monitor fires after the longer approval threshold is exceeded."""
2159+
transport = FakeTransport()
2160+
presenter = _KeyboardPresenter()
2161+
clock = _FakeClock(start=100.0)
2162+
edits = _make_edits(transport, presenter, clock=clock)
2163+
edits._stall_check_interval = 0.01
2164+
edits._STALL_THRESHOLD_SECONDS = 0.05
2165+
edits._STALL_THRESHOLD_APPROVAL = 0.1 # short for test
2166+
2167+
from untether.model import Action, ActionEvent
2168+
2169+
evt = ActionEvent(
2170+
engine="codex",
2171+
action=Action(
2172+
id="ctrl.1",
2173+
kind="warning",
2174+
title="Permission Request [CanUseTool] - tool: Bash",
2175+
detail={"inline_keyboard": {"buttons": [[{"text": "Approve"}]]}},
2176+
),
2177+
phase="started",
2178+
)
2179+
await edits.on_event(evt)
2180+
clock.set(100.0)
2181+
2182+
async with anyio.create_task_group() as tg:
2183+
2184+
async def drive() -> None:
2185+
# Advance past the approval threshold (0.1)
2186+
clock.set(100.2)
2187+
await anyio.sleep(0.05)
2188+
edits.signal_send.close()
2189+
2190+
tg.start_soon(edits.run)
2191+
tg.start_soon(drive)
2192+
2193+
assert edits._stall_warn_count >= 1
2194+
stall_msgs = [c for c in transport.send_calls if "No progress" in c["message"].text]
2195+
assert len(stall_msgs) >= 1
2196+
2197+
2198+
@pytest.mark.anyio
2199+
async def test_stall_normal_threshold_without_approval() -> None:
2200+
"""Stall monitor uses normal threshold when no pending approval."""
2201+
transport = FakeTransport()
2202+
presenter = _KeyboardPresenter()
2203+
clock = _FakeClock(start=100.0)
2204+
edits = _make_edits(transport, presenter, clock=clock)
2205+
edits._stall_check_interval = 0.01
2206+
edits._STALL_THRESHOLD_SECONDS = 0.05
2207+
edits._STALL_THRESHOLD_APPROVAL = 10.0 # very long, shouldn't matter
2208+
2209+
# No pending approval — normal action without inline_keyboard
2210+
from untether.model import Action, ActionEvent
2211+
2212+
evt = ActionEvent(
2213+
engine="codex",
2214+
action=Action(id="a1", kind="tool", title="Bash"),
2215+
phase="started",
2216+
)
2217+
await edits.on_event(evt)
2218+
clock.set(100.0)
2219+
2220+
async with anyio.create_task_group() as tg:
2221+
2222+
async def drive() -> None:
2223+
clock.set(100.1) # past normal threshold (0.05)
2224+
await anyio.sleep(0.05)
2225+
edits.signal_send.close()
2226+
2227+
tg.start_soon(edits.run)
2228+
tg.start_soon(drive)
2229+
2230+
# Should have warned using the normal threshold
2231+
assert edits._stall_warn_count >= 1

0 commit comments

Comments
 (0)