Skip to content

fix(agent-invocations): Route subagent resumes to subagent code - #2499

Merged
dcramer merged 22 commits into
mainfrom
fix/child-agent-resume-routing
Nov 2, 2026
Merged

dcramer merged 22 commits into
mainfrom
fix/child-agent-resume-routing

Conversation

@sentry-junior

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

Copy link
Copy Markdown
Contributor

What broke

A subagent was working on a GitHub event ("PR checks failed"). After 12 minutes it paused, as long jobs do. When it picked the job back up, Junior looked at the job's "came from an event" label and sent it to the code for top-level chats. That code needs a Slack thread to reply in. A subagent has none, so it crashed (Invalid local conversation id, Sentry JUNIOR-C7). It retried 4 times, gave up, and sat "paused" with nobody told.

What this PR changes

  • Subagent work always goes to the subagent code. Junior checks the saved conversation: if it has a parent, it is a subagent. The label no longer picks the code path. The server and the CLI now share one function for this, so they can't drift apart again.
  • A pick-up can be the last try. Before, only new messages counted toward the retry limit, so a pick-up never got a "last try". Now it does. On the last try a subagent is marked failed and its parent is told, and a top-level chat gets its normal error reply.
  • A failed subagent closes its paused job, so nothing can pick it up again.

Tests

The subagent tests now run through the same routing code that production runs. Before, they built their own copy of it, which is why they missed this bug. The pick-up test covers: a normal task, a GitHub-event job (the prod case), a job that ended before the pick-up, a stopped subagent, and a pick-up that fails every time.

What I took out

An earlier version of this PR also added a shared "Turn lifecycle" layer. It made the code bigger and harder to follow, not simpler: two new files, a seven-callback interface, and the three Slack copies of the same logic were still there. Most review bugs on this PR came from it. It is removed. The old commits are on ref/shared-turn-lifecycle-attempt for reference.

Still open

The real cleanup is to have fewer copies of the Turn logic and fewer places that store "is this job running, paused, or failed", not a new layer on top. That needs its own design first.

via @dcramer.

--

View Session [Sentry]

@vercel

vercel Bot commented Nov 1, 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 2:05am UTC

Request Review

@github-actions github-actions Bot added the risk: medium PR risk score: medium label Nov 1, 2026
@sentry-junior sentry-junior Bot changed the title fix(agent-invocations): Route child resumes to the agent invocation first fix(agent-invocations): Never route child work to a root worker Nov 1, 2026
@sentry-junior sentry-junior Bot changed the title fix(agent-invocations): Never route child work to a root worker fix(agent-invocations): Route child work by stored parent and fail it on the last try Nov 1, 2026
@github-actions github-actions Bot added risk: high PR risk score: high and removed risk: medium PR risk score: medium labels Nov 1, 2026
@dcramer

dcramer commented Nov 1, 2026

Copy link
Copy Markdown
Member

Why aren't patent and child events handled identically? They're roughly the same systems?

@sentry-junior sentry-junior Bot changed the title fix(agent-invocations): Route child work by stored parent and fail it on the last try fix(agent-invocations): Route child work by stored parent and share the worker Turn lifecycle Nov 1, 2026

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

Stale Bugbot comment from a previous run.

sentry-junior Bot added a commit that referenced this pull request Nov 1, 2026
After executeTurn records the terminal Turn, an error in the owner's
complete step ran the stop step when a stop was pending, then acked.
For a child, that skipped promoting a replaced result, so the parent
never got it. Run complete outside the stop and failure handling, so
an error there retries the attempt like it did before the shared
lifecycle.

Reported by Cursor Bugbot on #2499.
@github-actions

github-actions Bot commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

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

Review screenshots in Frameshift

Conversation Attachment · Desktop
Conversation Attachment · Desktop
Changed
Conversation Event Log · Mobile
Conversation Event Log · Mobile
Changed

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

Stale Bugbot comment from a previous run.

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

Stale Bugbot comment from a previous run.

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

Stale Bugbot comment from a previous run.

@sentry-junior sentry-junior Bot changed the title fix(agent-invocations): Route child work by stored parent and share the worker Turn lifecycle fix(task-execution): Route child work by stored parent and run root, child, and dispatch work on one Turn lifecycle Nov 1, 2026

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

Stale Bugbot comment from a previous run.

sentry-junior Bot and others added 14 commits November 1, 2026 23:05
isFinalAttempt came only from message delivery counts, so a resume
attempt with no messages was never final. When a resumed Turn kept
failing, the worker gave up without telling the adapter. A child agent
then stayed paused, the parent was never told, and later results of
that child were held back as replaced.

The retry count now also marks the last attempt as final. On that
attempt the agent invocation worker fails the invocation for any error,
also a save error without an AgentRunError wrapper, and reports it to
the parent. Root Turns and dispatches get the same final attempt.

Co-Authored-By: David Cramer <[email protected]>
The message, nested, and stop tests built worker contexts by hand and
called startAgentInvocationsForChildInput, createAgentInvocationWorker,
and createChildConversationWorker directly. They did not run the
production conversation work that selects the child worker.

They now process queue wakes through createConversationWorkHarness, which
moves to fixtures/conversation-work and wraps processConversationQueueMessage.
Stops use the real stop request poll and stopConversationTurn instead of a
local AbortController. Runners only read or abort runs, because tests in
packages/junior/tests must not run the agent.

The nested report case in the spawn test duplicated the nested test, so it
is removed. The maxAgentDepth check moves to the component worker test,
which owns that option. The stopped empty resume wake is now a case of the
yielded child wake test. startAgentInvocationsForChildInput has no callers
outside result.ts, so it is no longer exported.

Co-Authored-By: David Cramer <[email protected]>
… child work

Mailbox work and agent invocation work each had their own copy of the Turn
lifecycle: input ack, stop checks, AgentRunError unwrap, lost-lease
detection, and the retry-or-fail decision. The child-resume bugs came from
drift between these copies.

runWorkerTurn in task-execution/worker-turn.ts now owns that lifecycle. Each
work owner supplies its Run, its result save, and its finish steps.

Behavior changes from aligning the two copies:
- An execution limit now fails a root Turn at once, as it already did for a
  child. A retry cannot pass the limit.
- A root setup error on the last attempt now fails the Turn, as it did for
  a child, instead of leaving it open.
- A child setup error while a stop is pending now ends the child as stopped.
- A lost lease while a child records a stop returns lost_lease.

Co-Authored-By: David Cramer <[email protected]>
After executeTurn records the terminal Turn, an error in the owner's
complete step ran the stop step when a stop was pending, then acked.
For a child, that skipped promoting a replaced result, so the parent
never got it. Run complete outside the stop and failure handling, so
an error there retries the attempt like it did before the shared
lifecycle.

Reported by Cursor Bugbot on #2499.

Co-Authored-By: David Cramer <[email protected]>
A child agent invocation is a core Turn with no provider Delivery. Move
the steps that root mailbox work and child work both ran into
task-execution/core-turn.ts:

- createTurnSandbox: read the saved sandbox ref and save each change.
- createSteeringDrain: drain pending mailbox input, skip messages that
  an earlier drain already saved in the Turn, and accept the rest.
- saveFinishedTurn: save the sandbox ref, the completed checkpoint, and
  the automated-turn limit.
- recordAutomatedTurnLimit: count an automated Turn, or reset the count
  for input from the Conversation owner (a person, or the parent agent
  of a child).

Child work also reads history with loadProjection, as root work does.

Align three drifts:

- A grandchild result now counts toward the automated-turn limit of a
  child, as a child result counts on a root.
- A resumed Slack Turn now records the automated-turn limit, as a Slack
  Turn that finishes in one slice does.
- A completed child checkpoint with no earlier record starts at slice 1,
  as on a root, instead of failing.

The web and event worker now selects Delivery from the input owner
(event mailbox input or a saved event Message), not from Source.

Co-Authored-By: David Cramer <[email protected]>
Add one table to the durable queue tests. It runs each work owner through
production routing with the real agent: a root web Turn, a root event Turn
at a Slack Location, a root Slack mention, a dispatch, a child from a parent
task, and a child from a watch event. Each owner gets new input, a yield and
empty resume wake, a stop mid-Turn, an attempt that throws every time, and a
resume that throws every time.

The table found these bugs, which this commit fixes:

- A stop that aborted a child Run saved the Run instead of ending the Turn
  as stopped. The stop check now runs in the shared worker lifecycle for
  root and child work. A Run that finished before the stop keeps its result.
- A root event Turn that failed on its last attempt posted no error reply
  at its Slack Location. It now posts the standard failure reply.
- A dispatch resume failed, because the wake and the resume read a Slack
  session Source that a dispatch Conversation does not have. They now use
  the saved Source of the dispatch.

The table moves the durable queue helper onto the shared conversation work
harness, so the file runs the agent from one place. The owner and signal
rows are in tests/fixtures/turn-lifecycle.ts to keep the test file under
the length limit.

Dispatch stop and failure rows are not in the table yet. Dispatch work
still runs its own lifecycle. The next commit moves it onto the shared one.

Co-Authored-By: David Cramer <[email protected]>
Dispatch work had its own copy of the ack, lost lease, stop, and last
attempt logic. It now runs through runWorkerTurn, like root and child
work. Dispatch keeps its own steps: it reads and projects the dispatch
record, and its Slack runtime runs the Turn.

runWorkerTurn accepts a prepared Turn that its owner runs. The owner says
whether the Turn completed or paused, and acks when the Turn saved its
input.

This fixes two dispatch rows of the Turn lifecycle table:

- A stop mid-Turn now reaches the dispatch Run and ends its Turn as
  stopped. Before, the Run ignored the stop and the Turn failed.
- When every attempt throws, the last attempt now records the failed Turn.
  Before, only the dispatch record failed and the Turn stayed running.

Co-Authored-By: David Cramer <[email protected]>
core-turn.ts imported the Slack pause notice of the automated-turn limit.
Core now takes the notice as a step from the owner of the Delivery. The
Slack Turn and resume runners and the root mailbox worker pass the Slack
notice. Child work has no Delivery, so it passes none, and core clears the
notice claim as the Slack notice did for a Conversation without a Slack
Location.

Co-Authored-By: David Cramer <[email protected]>
…s turn limit

A child Turn whose input is a result or message from its own child agent
is work the child started. Restore the earlier rule: it neither counts
toward nor resets the automated-turn limit. The child owner supplies
this as a third TurnLimitInput state, so core has no child check.

Root behavior is unchanged.

Co-Authored-By: David Cramer <[email protected]>
The resumed Slack Turn started to count toward the automated-turn limit
in this PR, with no test. Restore the main behavior. The change moves
with the Slack runner conversion, where its test row belongs.

Co-Authored-By: David Cramer <[email protected]>
…ed child Turns

An error after the result save, such as a failed turn_completed write,
went to the stop or fail step when a stop was pending. A child result
that waited for an open grandchild then ended as skipped, and the parent
never heard. runWorkerTurn now records the save, and any later error
retries the attempt. The child report applies the stop-after-save rule,
so the retry reports a replaced result and stops the grandchildren.

Also:
- A failed root or child Turn writes its idempotent start before its
  failure, so a setup error before the start leaves no lone turn_failed.
- A failed or blocked child abandons its paused Turn record, as a
  stopped child did, so nothing can resume it.
- The agent-invocations README names the latest invocation of any status
  as the owner of a late wake.

The Turn lifecycle table gets a depth-2 child owner and rows for a stop
before and after the Run, an error after the save with a pending stop,
an execution limit, a setup error on the last attempt, and a child auth
pause. Rows where the Slack runner differs are listed per owner as
slackRunnerGaps.

Co-Authored-By: David Cramer <[email protected]>
…im the lifecycle table

A failed setup can end a Turn before its start write. Readers key turn_failed
by turn id and do not need the start, so the extra start write is removed.
The table rows now arrange their mocks in the row.

Co-Authored-By: David Cramer <[email protected]>
…ered reply

A model failure reply reaches the Location before the save. When the save
then fails on the last attempt, the fail step posted another error message.
The fail step now skips the Location message when the Location already has
a reply. A provider post also counts as delivered when its commit fails.

Co-Authored-By: David Cramer <[email protected]>
…ut a Run result

A dispatch that failed on its last attempt or was blocked wrote a failed
summary but kept a running or paused Turn record. A stop with no saved
result only failed the dispatch. The fail step now fails the record, the
block step abandons it, and the stop step abandons it and writes the
cancelled Turn end, as root and child stops do.

Co-Authored-By: David Cramer <[email protected]>

@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 b9cc6ef. Configure here.

The row let the start write pass again after the retries, so a start write in
a fail step was not covered. Each start write now fails, and the row checks
that the failure still reaches the owner.
…ast attempt

The fail and block steps wrote the Turn summary, the Turn record, and the
Turn end before they marked the dispatch. When one of those writes failed,
the dispatch stayed open with no retries left. The step now marks the
dispatch first.

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

if you look at this in the context of the whole its still confusing... core-turn? conversation-turn? worker-turn? tf is the difference, and thats just one component...

… fix

The shared Turn layer did not make the code simpler. It added
worker-turn.ts and core-turn.ts with a seven-step callback interface,
but the three Slack Turn copies still run their own lifecycle. The
source grew by about 390 lines, and most review bugs on this PR came
from that layer.

This commit puts the branch back to the child fix (0b2d66b):
- Child work goes to the child worker, picked from the stored parent.
- The last resume attempt counts as final, so a failing child tells its
  parent.
- Child routing tests run through production work.

It also keeps one small fix: a failed or blocked child closes its paused
Turn record, as a stopped child did. The yielded-child test checks this.

The removed work is kept on ref/shared-turn-lifecycle-attempt.

Co-Authored-By: David Cramer <[email protected]>
@sentry-junior sentry-junior Bot changed the title fix(task-execution): Route child work by stored parent and run root, child, and dispatch work on one Turn lifecycle fix(agent-invocations): Route subagent resumes to subagent code Nov 2, 2026
Main replaced the module READMEs with AGENTS.md files. The two routing
rules from this PR move there as one bullet each.

Main also turned the old yielded-child test into an approve plugin tool
test. Its checks now run in the yielded-child table, which already goes
through production routing.

Co-Authored-By: David Cramer <[email protected]>
@dcramer
dcramer merged commit 325894e into main Nov 2, 2026
44 checks passed
@dcramer
dcramer deleted the fix/child-agent-resume-routing branch November 2, 2026 02:11

This branch was successfully deployed

1 active deployment
Preview – junior-docs — 026e9137 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: high PR risk score: high

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant