fix: reclaim the AgentInstance task slot after an interrupted turn - #2490
Open
QuentinBisson wants to merge 5 commits into
Open
fix: reclaim the AgentInstance task slot after an interrupted turn#2490QuentinBisson wants to merge 5 commits into
QuentinBisson wants to merge 5 commits into
Conversation
An AgentInstance holds one non-terminal task at a time, enforced by a partial unique index. The gateway terminates a task on the error paths it can observe, but a process that stops mid-turn writes no terminal state, so the row stays non-terminal and every later send fails with ErrAgentInstanceTaskConflict. Cancel cannot clear it either: it dials the runtime, which has no record of a task it never loaded. Reserving the slot now first terminates an active task that has not reported progress within a configurable bound, in the same transaction, so the takeover cannot race a task that is still running. The interruption is appended to the task history as well as written to status, so readers show it without interpreting the terminal state themselves. AGENT_INSTANCE_TASK_STALE_AFTER configures the bound and defaults to 1h, which clears a non-streaming turn that persists nothing between submit and return. Zero disables the takeover. Signed-off-by: QuentinBisson <quentin@giantswarm.io>
QuentinBisson
force-pushed
the
fix/2469-agent-instance-stale-task
branch
from
August 19, 2026 00:13
07fa225 to
a434a13
Compare
…tance-stale-task Signed-off-by: Eitan Yarmush <eitan.yarmush@solo.io>
Contributor
There was a problem hiding this comment.
Pull request overview
This PR makes AgentInstance task-slot reservation self-healing by allowing CreateAgentInstanceTask to reclaim the “one active task” slot when the incumbent task has been silent past a configurable bound, preventing permanently stuck AgentInstances after interrupted turns.
Changes:
- Add a staleness-bound takeover to
CreateAgentInstanceTaskthat terminates a non-terminal task whoseupdated_atis older thanAGENT_INSTANCE_TASK_STALE_AFTER(default1h,0disables). - Introduce a
FOR UPDATEquery to lock the active task row inside the reservation transaction to avoid races during takeover. - Update gateway/controller wiring and tests to thread the new
taskStaleAfterconfiguration through call sites.
Reviewed changes
Copilot reviewed 7 out of 9 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| go/core/v2/a2agateway/gateway.go | Threads taskStaleAfter into task creation via the store interface and adds gateway config field. |
| go/core/v2/a2agateway/gateway_test.go | Updates test store + gateway construction to account for the new taskStaleAfter parameter. |
| go/core/internal/database/queries/agent_instance_tasks.sql | Adds LockActiveAgentInstanceTask query to lock the non-terminal task row for takeover safety. |
| go/core/internal/database/gen/querier.go | Regenerates sqlc interface to include LockActiveAgentInstanceTask. |
| go/core/internal/database/gen/agent_instance_tasks.sql.go | Regenerates sqlc implementation for LockActiveAgentInstanceTask. |
| go/core/internal/database/client_postgres.go | Implements stale-task termination + event recording before reserving the active-task slot. |
| go/core/internal/database/client_agent_instance_test.go | Adds coverage for stale-task termination reclaiming the slot; updates existing calls for new signature. |
| go/core/cmd/controller-v2/main.go | Adds AGENT_INSTANCE_TASK_STALE_AFTER parsing and wires it into gateway construction. |
| go/api/database/client.go | Updates the public DB client interface/docs to accept the staleness bound parameter. |
Files not reviewed (2)
- go/core/internal/database/gen/agent_instance_tasks.sql.go: Generated file
- go/core/internal/database/gen/querier.go: Generated file
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+794
to
+796
| if row.UpdatedAt.After(time.Now().Add(-staleAfter)) { | ||
| return nil | ||
| } |
Signed-off-by: Eitan Yarmush <eitan.yarmush@solo.io>
Signed-off-by: Eitan Yarmush <eitan.yarmush@solo.io>
Signed-off-by: Eitan Yarmush <eitan.yarmush@solo.io>
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.
Fixes #2469.
The problem
agent_instance_one_active_task_idxreserves one non-terminal task per AgentInstance. The gateway terminates a task on every error path it can observe (failTaskon dial failure, send error, stream error, store error), but a turn that ends without one of those paths running writes no terminal state at all.The row then stays non-terminal, and because the index reserves the slot, every later send to that AgentInstance fails with
ErrAgentInstanceTaskConflict, "AgentInstance already has an active task".Two ways in, and the second is the ordinary one:
SendStreamingMessagethe persistence call and thefailTaskthat would record the failure both run on the request context, so the cancellation takes out the recovery along with the write.if !yield(event, nil) { return }leaves without a terminal write too. This is the mechanism [BUG] A2A client disconnect can cancel terminal task persistence and leave tasks working #2447 reports for the v1 store; the index is what turns it from a stuck task into an unusable instance.Nothing recovers it.
CancelTaskdials the runtime first, and a restarted process has no record of a task it never loaded, so the cancel fails and the slot is never cleared. There is no reaper, lease or takeover ingo/core/v2. The only escape is deleting the row by hand.The change
Reserving the slot now first terminates an active task that has not reported progress within a configurable bound. It happens inside the transaction that reserves the slot, holding the incumbent with
FOR UPDATE, so the takeover cannot race a task that is still running.The interruption is appended to the task
historyas well as written tostatus, so a client renders it as part of the conversation without needing its own handling for the terminal state.Staleness is measured on
updated_atrather thanstatus.timestamp: it is a server clock, it is always set, and every persisted event refreshes it.AGENT_INSTANCE_TASK_STALE_AFTERconfigures the bound and defaults to1h. The bound has to clear the longest quiet period of a live turn, and the constraining case is a non-streamingSendMessage, which persists nothing between submit and return. Setting it to0disables the takeover and restores the previous behaviour.Scope
This makes the state recoverable. It does not stop the orphan being created, and for the disconnect trigger the prevention is to decouple persistence and the recovery write from the client's context, which is what #2447 asks for on the v1 store. The two are complementary, and that one is deliberately not in this PR.
Notes for review
taskForEventalready does for event folding. That would make silence a trigger rather than a verdict and allow a much tighter bound. It is easier to argue once the slot can be reclaimed at all.CreateAgentInstanceTaskgains the bound as an explicit parameter rather than reading hidden client state, which is what moves the call sites in the tests.stateandupdated_atare already columns onagent_instance_task, and the lookup rides the existing partial unique index.