Repository navigation
fix(agent-runner): cancel a stopped turn's goose run instead of abandoning it - #655
Conversation
…oning it A stop (or the turn timeout, or the tool-call cap) only ended Prompt's wait; the session/prompt already sent to goose kept running, and goose rejects the session's next session/prompt with -32602 "session already has active run ...; use _goose/unstable/session/steer" until that run ends. A Stop followed by a new message in the same chat therefore ended the conversation failed with the user's message unprocessed. Send session/cancel (the notification form goose's on_cancel handles, same as apps/acp-bridge's Runner.Interrupt) on every non-terminal exit, and retry the prompt once after cancelling when it was rejected by a run still unwinding.
There was a problem hiding this comment.
ℹ️ No critical issues — one rough edge worth a look.
Reviewed changes
Promptcancels on non-terminal exits — a newansweredflag plus adefersendssession/cancelwhenever the turn's terminal response wasn't received (ctx cancel, turn timeout,maxToolCalls, dead stream), so a stopped run is no longer left active to reject the session's next prompt with-32602.- One cancel-and-retry on the run-active rejection —
isRunActiveErrormatches goose's-32602+"session already has active run", andPromptcancels, waitsrunActiveRetryDelay(2s), then re-posts the prompt once. The former inline frame loop is factored intoawaitTurn(behaviour unchanged). - New
notifysibling + wire types —post/notifyshare onesend;rpcNotification(id-less) andcancelParamsare added, matching the notification formapps/acp-bridge'sacpclient.Cancelalready uses.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
… retry Review catch: the delay comment claimed a cancelled run clears in under two seconds, while the measurement was 1.8-3.8s (the probe polled every 2s). One retry at 2s would still fail a message for a run on the slow side of that range. Allow up to two retries, and pin the budget with a test that a run which never clears still fails the turn with goose's own error.
kayahk
left a comment
There was a problem hiding this comment.
Fair catch — the two numbers disagreed and the retry was too tight for the slower half of the range.
What I actually measured (goose 1.50.1, sleep 60 tool call in flight, probe polling every 2 s) is: session/cancel → next prompt accepted 1.8–3.8 s later. The poll interval is the resolution, not an upper bound, so "under two seconds" in the comment was wrong.
Fixed in 42bf724:
- retry budget is now up to two attempts (
maxRunActiveRetries = 2), one cancel each, so 2 s + 2 s covers the measured window; - the comment states the 1.8–3.8 s measurement and that it comes from a 2 s poll, instead of claiming a bound I did not measure;
TestPrompt_GivesUpAfterTheRetryBudgetpins the budget: with goose rejecting every prompt, the turn makes exactly 3 attempts and then fails with goose's own-32602, andsession/cancelis sent on giving up too — a turn never leaves its run active. A rejected prompt costs nothing (no model call), so the only budget being spent is waiting.
go build ./..., go vet ./..., go test -race (whole module) pass; the retry tests were re-sabotaged to confirm they fail without the fix.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- Retry budget widened from one attempt to a bounded three — the run-active rejection now retries up to
maxRunActiveRetries = 2(2s + 2s waits), which covers the measured 1.8–3.8ssession/cancelunwind window instead of the single 2s wait that lost the message on the slow side of that range. - Retry-delay comment reconciled with the measurement — it now states the observed 1.8–3.8s range and that the 2s probe interval is the resolution, not an upper bound, rather than claiming an unmeasured "under two seconds".
- New
TestPrompt_GivesUpAfterTheRetryBudget— pins the budget (exactlymaxRunActiveRetries+1prompts), the cancel before each retry, and the final cancel on giving up, so a turn that never clears still fails with goose's own-32602without leaving its run active.
The prior review's single concern (the 2s delay versus the ~2–4s reproduction) is addressed by 42bf724; the resulting two-wait budget matches the measurement, and the give-up path preserves the invariant that a turn never exits with its run still active.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
pikann
left a comment
There was a problem hiding this comment.
LGTM! Thank you for the contribution! 🚀

Fixes #654
A stopped turn left its goose run running, so the next message in the same chat was rejected:
and the conversation ended
failedwith the user's message unprocessed.Promptonly stopped waiting on cancellation — its doc comment even said the transport had nosession/cancel, which is no longer true of goose 1.50.1. Reproduced against a livepaca-env-…pod: a secondsession/promptwhile asleep 60tool call was in flight returns exactly that-32602, andsession/cancelclears the run again in ~2–4 s.Changes
services/agent-runner/internal/acp:Promptcancels on every exit that did not receive the turn's terminal response (user Stop,timeout_minutesbudget,maxToolCallscap, dead stream) via a newcancelRun→notify("session/cancel", …). The notification form is what goose'son_cancelhandles, and it is the same callapps/acp-bridge'sRunner.Interrupt/acpclient.Cancelmakes.session/promptis rejected because a previous run is still unwinding: a message typed a second after pressing Stop lands in that window, and failing it loses what the user wrote. Budget ismaxRunActiveRetries = 2waits of 2 s each, sized from the measurement below; a rejected prompt costs no model call. A run that never clears still fails the turn, and is cancelled on the way out.postgrew anotifysibling (no id) sharing onesend;rpcNotification/cancelParamsintypes.go.Prompt's frame loop moved toawaitTurnso the retry doesn't duplicate it. Its behaviour is unchanged.No api or web change: the stop already reaches the runner, it just stopped there.
Tests
services/agent-runner/internal/acp/client_test.go:TestPrompt_CancelledSendsSessionCancel— a cancelled turn sends exactly onesession/cancel, without an id, carrying the session's id. Sabotage (cancel stubbed out):session/cancel sent 0 times, want exactly 1 (an abandoned run left active rejects the session's next prompt).TestPrompt_RetriesAfterActiveRunRejection— goose's verbatim-32602on the first prompt, success on the retry, exactly one cancel in between. Sabotage (retry removed):Prompt: Invalid params: session already has active runrun_58072af2-…; use _goose/unstable/session/steer.TestPrompt_GivesUpAfterTheRetryBudget— every prompt rejected: exactlymaxRunActiveRetries+1attempts, then goose's own error, with a cancel on giving up too. Sabotage:session/prompt sent 1 times, want 3 (initial + 2 retries).TestPrompt_SuccessSendsNoSessionCancel— a normal turn sends none, since a chat conversation reuses its session.Gates
In
services/agent-runner:go build ./...,go vet ./...,go test -race $(go list ./... | grep -v '/test/e2e')— all pass.golangci-lintis not installed locally, so that gate is CI's.Behaviour is verified against goose 1.50.1 by probe, not by a rebuilt image: this repo's
paca-agent-runnerimage still needs a build and pin bump before the deployment stops hitting this.