Skip to content

fix(agent-invocations): Keep one active agent invocation per child - #2558

Merged
dcramer merged 5 commits into
mainfrom
fix/agent-invocation-single-active
Nov 2, 2026
Merged

dcramer merged 5 commits into
mainfrom
fix/agent-invocation-single-active

Conversation

@sentry-junior

@sentry-junior sentry-junior Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

A child agent can have at most one agent invocation that is running or paused. getActiveAgentInvocationForConversation already 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:

  • If a finished Turn is saved, projectTerminalTurn gives that result.
  • If not, the invocation fails and its paused Turn is abandoned. The error says that the task was not resumed because newer work started.

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.

listActiveAgentInvocationsForConversation is the single query for active invocations. getActiveAgentInvocationForConversation reads 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 running or paused.

bindings.ts

The named-child binding queries moved unchanged from store.ts to bindings.ts. The move only keeps store.ts under the 1,000-line limit.

Tests

  • New case in agent-invocation-work.test.ts, through production routing: a child that still has a running or paused invocation 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.
  • These pass: the 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]

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>
@vercel

vercel Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
junior-docs Ready Ready Preview Nov 2, 2026 8:30pm UTC

Request Review

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>
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

3 screenshot changes — 3 changed · 0 added · 0 removed

Review screenshots in Frameshift

Conversation Child Agent Drawer · Desktop
Conversation Child Agent Drawer · Desktop
Changed
Conversation Child Agent Drawer · Mobile
Conversation Child Agent Drawer · Mobile
Changed
Settings Mcp · Desktop
Settings Mcp · Desktop
Changed

…cation

Co-Authored-By: David Cramer <23610+dcramer@users.noreply.github.com>

@dcramer dcramer left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@sentry-junior

sentry-junior Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Is this just trying to prevent a subagent from having more than one subagent, or is it a bug in our turn handling?

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 running or paused after nothing can resume its Turn. For example, its worker died mid-run, or it paused and nothing resumed it. When the parent then sends that same child new work with messageAgent, the worker starts a second invocation. The child now has two active invocations, and getActiveAgentInvocationForConversation throws.

The fix: before the worker marks the new invocation running, it ends every other active invocation of that child. A saved finished Turn gives its result. Otherwise the invocation fails. Each ended invocation sends its own result to the parent.

@dcramer
dcramer marked this pull request as ready for review November 2, 2026 20:22
@github-actions github-actions Bot added the risk: medium PR risk score: medium label Nov 2, 2026

@dcramer dcramer left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gonna go with this but we gotta clean all this up its way too many fucked up branches in design

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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.
@dcramer
dcramer merged commit 05108d9 into main Nov 2, 2026
46 checks passed
@dcramer
dcramer deleted the fix/agent-invocation-single-active branch November 2, 2026 20:37
dcramer pushed a commit that referenced this pull request Nov 2, 2026
## 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>

This branch was successfully deployed

1 active deployment
Preview – junior-docs — cbcb1aa1 Deployed Nov 2, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk: medium PR risk score: medium

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant