Conversation
The 30s AI request timeout was armed as a wall-clock deadline over the whole generateText roundtrip, tool execution included. A browser action slower than the deadline — a fillField that took 22.5s on a busy form — was read as an unresponsive model: the turn was aborted mid-tool and withRetry replayed it up to three times while the original commands were still executing. All three attempts pushed commands into the one global CodeceptJS recorder. The first attempt's commands ran and created the record; the second attempt's fillField then ran against the already-navigated page and threw, and because the recorder and its step logger are process-wide, the first attempt's await rejected on the second's error. The form tool returned attempts [OK, OK, OK, FAILED] alongside a pageDiff carrying POST /runs 200 and the new URL — success and failure in one payload. Tester believed creation had failed and Pilot voted fail on "Creation timed out". abortAfterIdle already exempted executionController.isAwaitingInput(), the same fix applied narrowly to one blocking tool. Generalize it: count in-flight tool executions and treat any of them as not-idle, so the timer only fires while the model itself is what we are waiting on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DenysKuchma
requested changes
Sep 16, 2026
| return { ...tools, commentary: createHarmonyChannelFallbackTool() }; | ||
| } | ||
|
|
||
| let toolsRunning = 0; |
Collaborator
There was a problem hiding this comment.
This counter is process-global, so a tool running in one request disables the idle timeout for other concurrent requests too. It should be scoped to the individual generateWithTools call.
…pts-running-tools # Conflicts: # CHANGELOG.md
The counter was module-global, so a browser tool running under one request suppressed the idle timeout for every other in-flight model call. maxParallelRequests defaults to 4, and nested calls bypass the slot limiter entirely (modelSlotContext short-circuits withModelRequestSlot) — so an agent invoked from inside a Tester tool had its own idle timeout held open by the tool that called it, for as long as that tool ran. Give each generateWithTools call its own counter and thread it through raceWithIdleTimeout to abortAfterIdle. A call now only exempts itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Private module-level helpers belong with the file's other private helpers at the bottom, not above the class they serve — function declarations hoist, so position was never a constraint. Record the rule in CLAUDE.md alongside the existing one for private methods. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Session
UsualNegativeGray57(trace4892e780…) failed the scenario "Create a mixed manual and automated run and verify it appears in the runs list." — but the run was created:POST /api/…/runs 200, navigation to/runs/55c2d528. Everyform()call still reportedsuccess: false, and Pilot votedfailon "Creation timed out".I.fillField('Title', …)on the mixed-run form took 22.5s (step meta1789530314391 → 1789530336908). That pushed the roundtrip past the 30s deadline armed inraceWithIdleTimeout,abortAfterIdlefired mid-tool, andwithRetry(maxAttempts: 3) replayed the turn. Threeinvoke_agentspans overlap to the millisecond:This operation was abortedThis operation was abortedAll three issued a creation
form()into the one global CodeceptJS recorder. Attempt 1's commands ran (fillFieldOK →click All testsOK →click SaveOK → run created); attempt 2'sfillFieldthen ran against the already-navigated detail page and threw. Because the recorder andattachStepLogger's event dispatcher are process-global, attempt 1'sawait recorder.promise()rejected on attempt 2's error — soaction.lastErrorwas set for a tool whose own commands had all succeeded.The result was one payload containing both outcomes:
Every
I.stepspan in that window is duplicated in pairs — two step loggers attached at once. Pilot's notes carryExecution error: AI request timeoutverbatim.Fix
abortAfterIdlemeasured wall-clock elapsed over the entire roundtrip, tool execution included, and so read a slow tool as an idle model. Its one existing exemption —executionController.isAwaitingInput()— is this same bug already patched narrowly for a single blocking tool. This generalizes it to any tool:withIdleExemption()wraps each tool'sexecuteto increment/decrement atoolsRunningcounterabortAfterIdlere-arms whiletoolsRunning > 0, so the timer only fires when the model itself is what we are waiting ongenerateWithToolsparameter, so both thewithHarmonyChannelFallbackpath and the required-tool-choice relax path inherit it27 lines, one file.
Considered and rejected
'AI request timeout'fromretryCondition. It is dead — the genericerror.message.includes('timeout')above it already matches — and removing the generic match too would still leave the abort firing mid-fillField, with the zombieformcolliding with whatever the next tester iteration sends into the same recorder. The retry amplifies; it is not the cause.onStepEnd. Steps bracket model+tools together, so the timer would still have fired at 03:45:48 inside a tool that ended at 03:46:03.setActivitystate. Activity is also set for the model call itself, so it cannot tell model-idle from tool-busy.askApiwould indeed have proven the run existed, but it is registered on Pilot's supervision conversation while the verdict call has no tools — and the tester's belief was already false two minutes upstream.Trade-off
A genuinely hung browser tool is no longer bounded by the AI idle timeout, only by Playwright's own
setDefaultTimeout(config.action?.timeout ?? DEFAULT_ACTION_TIMEOUT)inAction.executeOnce. That is the right owner for a browser deadline, but a tool that hangs below Playwright's timeout now stalls the turn instead of being killed at 30s.Verification
bun test tests/unit/— 1423 pass, 0 failbun test tests/integration/— 148 pass, 1 fail (Prima attaches to a live playwright-cli session, pre-existing and environmental; fails identically onmain)bun run check:fix— cleantsc -p tsconfig.json— same 6 pre-existingprovider.tserrors asmain, none newReplaying the scenario should show one
invoke_agentspan per tester iteration with no overlap, zeroAI request timeoutnotes, the firstform()returningsuccess: truewithPOST /runs 200+urlChanged, and noI.stepspans emitted in duplicate pairs. This one needs a regression run to confirm end to end — flagging rather than labelling.🤖 Generated with Claude Code