fix(acp): cap how long a pending tool call defers the idle watchdog - #1066
Conversation
| snapshot = tuple(sorted(session.pending_tool_call_ids())) | ||
| if snapshot != pending_snapshot: | ||
| pending_snapshot = snapshot | ||
| pending_since = now |
There was a problem hiding this comment.
🟡 Delayed pending-call grace window
When a tool call appears between polls, cur_count > last_count postpones snapshot tracking until the next poll. The watchdog grants up to two extra poll intervals.
Prompt for agents
In src/benchflow/acp/runtime.py, _prompt_with_idle_watchdog only observes the pending-ID snapshot in the elif branch after activity-count processing. A newly appended tool call increases _activity_count, so the first poll that observes it skips snapshot tracking; pending_since is reset on the following poll instead. Track pending-set transitions on every poll, independently of whether other activity was also detected, while preserving the rule that a pending call only refreshes last_progress during its grace window. Add a regression test where the prompt coroutine creates the pending call after the watchdog starts, rather than pre-populating the session.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Good catch, fixed: the pending set is now observed every poll, so the grace clock starts on the same poll the call appears.
|
The root-cause analysis here is excellent — the four-link chain in the description (lost terminal update, permanent PENDING, unconditional deferral, event-driven heartbeat) matches the code, and bounding the deferral is the right link to attack rather than trusting the transport. I verified the regression test is red on main (the miniature stall: the outer wait_for trips a plain TimeoutError at 20s) and green here (~5s), 97/97 test_acp.py passes, and the expiry path is genuinely clean: bounded 0.25s cancel-drain, then the existing retryable IdleTimeoutError — not the same stall moved later. I did probe the grace semantics adversarially and found one real false-fire mode plus a diagnostics gap that I think should be fixed before this lands; both keep your approach intact.
Minor, fold into 1 if you take it: since the pending branch is an elif, a poll where the activity count grows skips the snapshot update, so the grace start can lag by one poll interval — benign (it only extends the grace). Ordering vs #1046 (feat/ablate-cli): I trial-merged the branches — clean auto-merge, no textual conflict (our runtime.py changes end at _configure_acp_session; yours start at _prompt_with_idle_watchdog), and the merged tree passes 97/97 test_acp.py. Semantically independent as well: our ACPRequestGlobalError classification covers session-configuration RPCs that run before execute_prompts, and your IdleTimeoutError keeps flowing through the existing IDLE_TIMEOUT retry category. Happy to rebase whichever lands second. If it helps, I'm happy to send a small patch PR against your branch implementing the grace-reset-on-progress + truthful expiry diagnostics — your call; the review stands either way. |
|
As offered above — the patch for both review items is ready to merge into this PR's branch: Benjamin-eecs#1 (keeps the bounded-grace design and the 3x constant; grace resets on in-progress |
…ruthful expiry diagnostics A single long tool call that streamed in-progress tool_call_update notifications past the grace boundary was killed as idle: those updates mutate ToolCallRecord in place, invisible to both the pending-set snapshot and _activity_count. The watchdog now observes a monotonic ACPSession.tool_call_update_count each poll, and any change restarts the pending grace clock alongside pending-set changes — a call that keeps talking defers as long as it talks, while one silent for the full grace still trips. On grace expiry, the raised message and IdleTimeoutDiagnostic now report the truth via additive fields: which pending calls exceeded the grace, when the pending set last changed, when the last update was observed, and how many updates were seen. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DZggU3vNYzr3hfDZJUE7jX
|
Thanks @JeremyJC67, both findings were real. Merged your patch into this branch as-is. The update-count reset is the right signal: a streaming call proves the transport is alive, a half-open one stays flat. Named the multiplier (PENDING_GRACE_MULTIPLIER) and the description now states the silent-cap behavior change with the breaking-change box ticked. 99/99 test_acp locally after the merge. |
bingran-you
left a comment
There was a problem hiding this comment.
Validated at exact head 054f91f. Pending grace is now tracked per tool call, so duplicate terminal traffic and another streaming call cannot hide a lost completion, while relevant streaming progress survives. The watchdog policy is extracted from the ACP runtime (runtime: 858 lines; prompt loop: 44 lines). Exact-head ACP tests pass 100/100; the full local suite passes 5936 tests with 48 skipped and 7 deselected; Ruff and ty pass. Real Gemini 3.1 Pro Docker and Daytona runs both earned reward 1, and both artifacts validate healthy and training-ready. All GitHub checks are green after rerunning one transient, unrelated heartbeat timing failure. Ready to squash merge.
Description
Cap the silent deferral of each pending ACP tool call independently. A call gets
PENDING_GRACE_MULTIPLIER (3) x idle_timeoutsince its own creation or last nonterminal update; streaming progress for that call restarts its grace, while duplicate terminal updates or progress from another call cannot conceal a lost completion.The grace policy and diagnostics now live in a focused
acp/watchdog.pystate machine. The async runtime loop is 44 lines andruntime.pyis back to 858 lines instead of crossing 1,000.Motivation and Context
The #1061 stall chain leaves a tool record pending forever when its terminal update is lost, while stderr traffic prevents the transport timeout. Main previously treated any pending call as progress until the multi-hour wall budget. This PR bounds that deferral without killing demonstrably streaming tools.
Expiry raises the existing retryable
IdleTimeoutErrorwith additive diagnostics naming all pending calls, the specifically expired calls, the grace, relevant update counts, and update ages.Behavior change: a call that stays pending and fully silent beyond 3x the idle budget now fails instead of deferring to the wall clock. With the default 600-second idle timeout this is approximately a 30-minute silent cap (plus at most one watchdog poll); legitimate longer silent tools should raise
--agent-idle-timeout.Closes #1061.
Types of changes
Implemented Tasks
Validation
tests/test_acp.py: 100 passedtests/: 5936 passed, 48 skipped, 7 deselectedty check src/gemini-3.1-pro-previewDocker and Daytona E2E at the final working tree: reward 1.0 on both backends