Repository navigation
fix(agent-invocations): Keep one active agent invocation per child - #2558
Conversation
A child agent has at most one running or paused agent invocation. Before the worker marks an agent invocation running, it now ends every other active invocation of the same child. A saved finished Turn gives its result; otherwise the invocation fails and its paused Turn is abandoned. The started invocation reports for both. Move the named-child binding queries to bindings.ts so store.ts stays under the file-length limit. Co-Authored-By: David Cramer <23610+dcramer@users.noreply.github.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Newer work of a child does not continue an earlier task, so the parent must get the earlier agent invocation's result on its own. Send it through the failed-result path instead of marking it replaced. Co-Authored-By: David Cramer <23610+dcramer@users.noreply.github.com>
|
3 screenshot changes — 3 changed · 0 added · 0 removed Review screenshots in Frameshift
|
…cation Co-Authored-By: David Cramer <23610+dcramer@users.noreply.github.com>
dcramer
left a comment
There was a problem hiding this comment.
is this just trying to prevent a subagent from having more than one subagent?
or is this a bug in our turn handling?
The worker comment and JSDoc already state it.
It's a bug in agent invocation handling. It does not limit how many subagents an agent can start. One child conversation can keep an earlier agent invocation in The fix: before the worker marks the new invocation |
dcramer
left a comment
There was a problem hiding this comment.
gonna go with this but we gotta clean all this up its way too many fucked up branches in design
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit b35f67c. Configure here.
…one write Ending another active invocation set its result `replaced` and then flipped it to `pending` in a second write. A crash between the writes left it `replaced`, which recovery does not send. `completeAgentInvocation` now takes `reportNow`, so the terminal write marks it `pending` directly.
## What failed JUNIOR-9M: `getActiveMailboxTurnId` throws `Conversation … has multiple active mailbox Turns` (for example `local:web:67232d03…` and `slack:C0B595QDZLL:1793638594.088319`). Every resume attempt of the Conversation throws, retries run out (`conversation.work.retry.exhausted`), and the Conversation stays stuck until a person posts again. ## Root cause Generic conversation work can stop a Turn without closing its Turn cursor. Examples: the heartbeat clears an expired lease after a worker is lost (deploy rollout, freeze), lost-lease recovery runs out of retries, or a final-attempt Run failure in `conversation-turn.ts` that marks the Turn failed in the Conversation but leaves the checkpoint `running`/`paused`. The next web or event message starts a new Turn next to it. New input only superseded auth-paused Turns, so the Conversation now has two open mailbox Turns. When the new Turn yields, its resume fails. ## Fix When new mailbox input starts a Turn, abandon every other open web or event Turn, not only auth-paused ones. The OAuth prompt cleanup still runs only when an auth-paused Turn is superseded. Slack Turns are not mailbox Turns and stay as before. ## Why a Turn that waits to resume is not superseded New mailbox input starts a Turn only when no yielded Turn waits: - Every path that leaves a Turn to resume sets execution `paused`: yield (`requestAnotherSlice`), expired-lease recovery (`clearExpiredConversationLease`), a counted retry (`recordConversationRetry`), and lost-lease recovery in the worker. - `selectAttemptMessages` returns no web or event input while execution is `paused`. A steered web message is not an attempt interrupt while paused. Deferred input appended while paused keeps `paused`. - The lost-worker `retry` pause from #2298 runs only in `runNextPausedTurn` (Slack and dispatch workers). Mailbox Turns do not use it. - A Slack mention can interrupt a paused Conversation. It does not reach this path: `resolveMailboxTurnWork` returns no work for Slack input, and the Slack worker resumes the paused Turn and steers the mention into it. So a different open mailbox Turn at this point is stale. `conversation-turn-work.test.ts` › "resumes a paused dashboard Turn before a deferred event" protects the selection rule: it fails when the `paused` check in `selectAttemptMessages` is removed. ## Review evidence An end-to-end run through `processConversationQueueMessage`, the real conversation-turn worker and `recoverConversationWork` reproduces JUNIOR-9M: a web Turn yields, five workers take the lease and are lost, and the heartbeat sets the Conversation to `failed` with the cursor still `paused`. On `main`, the next message yields and every resume fails with "multiple active mailbox Turns". On this branch, the stale Turn is `abandoned` and the next message yields, resumes and replies. That test is not in this PR: `scripts/check-test-architecture.mjs` does not allow tests in `packages/junior/tests` to run the agent or compose the worker. Its home is `evals/integration/conversation/lifecycle.eval.ts`, with a fixture step that loses workers. That needs new model recordings. Other checks: - A late save from a lost worker of the abandoned Turn does not reopen it after the new Turn commits history: the save fails with `AgentHistoryBranchError` and the cursor stays `abandoned`. - No terminal lifecycle event is written for the superseded Turn. Do not add one here: the new Turn already wrote `turn_started`, and the dashboard reads the latest lifecycle event to show that Junior is responding. ## Remaining risk - A stale Turn is closed only when new input arrives. A Conversation that is already stuck still needs a new message, but that message now unsticks it. - The abandoned Turn sends no reply. The person who sent it is not told that it was dropped. This is the "nothing finishes the Turn" gap in #2567. - Between the abandon and the new Turn's first history commit, a late save from a lost worker could still reopen the old cursor. Turn saves do not check the Conversation lease (#2567, item 8). - JUNIOR-NP (`multiple active agent invocations` for child Conversations) is the same class of bug. #2558 fixed it: the worker ends every other active agent invocation of the child before it starts a new one. <!-- junior-request-attribution:start --> via @dcramer. <!-- junior-request-attribution:end --> <!-- junior-session-footer:start --> <!-- junior-conversation-id:slack%3AC0AHB7N2JCR%3A1793655819.784179 --> -- [View Session](https://junior-prod.sentry.dev/conversations/slack%3AC0AHB7N2JCR%3A1793655819.784179) [[Sentry]](https://sentry.sentry.io/explore/conversations/slack%3AC0AHB7N2JCR%3A1793655819.784179/?project=4510944073809921) <!-- junior-session-footer:end --> Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> Co-authored-by: BYK <126780+BYK@users.noreply.github.com>

A child agent can have at most one agent invocation that is
runningorpaused.getActiveAgentInvocationForConversationalready asserts this. Until now, nothing enforced it when an invocation starts.Change
The worker owns execution under the Conversation lease. Before
markAgentInvocationRunning, it ends every other active invocation of the child:projectTerminalTurngives that result.The newer work does not continue the earlier task, so each ended invocation sends its own result to the parent through the normal result path. It is not marked
replaced. The parent gets one report per invocation.listActiveAgentInvocationsForConversationis the single query for active invocations.getActiveAgentInvocationForConversationreads it and keeps its assertion.Why not a DB constraint
A partial unique index would only make the start throw a different error. The worker would still have to end the other invocation. The Conversation lease already serializes the transition, and only the worker sets
runningorpaused.bindings.tsThe named-child binding queries moved unchanged from
store.tstobindings.ts. The move only keepsstore.tsunder the 1,000-line limit.Tests
agent-invocation-work.test.ts, through production routing: a child that still has arunningorpausedinvocation from earlier work gets new mailbox input. The run sees only the new invocation as active. The earlier Turn is no longer active. The parent mailbox gets exactly one failed result for the earlier invocation, then one result for the new invocation after it finishes.agent-invocation-*integration and component tests,tsc,pnpm --filter @sentry/junior lint, the file-length check, and the test-architecture check.via @dcramer.
--
View Session [Sentry]