Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
d518993
fix(agent-invocations): Route child resumes to the agent invocation f…
sentry-junior[bot] Nov 1, 2026
7a099f1
fix(agent-invocations): Never route a child wake to a root worker
sentry-junior[bot] Nov 1, 2026
4b966f6
refactor(agent-invocations): Use one child worker in the server and CLI
sentry-junior[bot] Nov 1, 2026
49babd3
ref(agent-invocations): Select the child worker from the stored parent
sentry-junior[bot] Nov 1, 2026
6afc5be
fix(task-execution): Mark the last resume attempt as final
sentry-junior[bot] Nov 1, 2026
0b2d66b
test(agent-invocations): Run child routing tests through production work
sentry-junior[bot] Nov 1, 2026
785bb29
ref(task-execution): Share the worker Turn lifecycle between root and…
sentry-junior[bot] Nov 1, 2026
56dc4e8
fix(task-execution): Retry a failed finish step after the Turn ends
sentry-junior[bot] Nov 1, 2026
8b66a13
ref(task-execution): Share core Turn steps between root and child work
sentry-junior[bot] Nov 1, 2026
31d57a3
test(task-execution): Cover the Turn lifecycle of every work owner
sentry-junior[bot] Nov 1, 2026
9f6ea77
ref(agent-dispatch): Run dispatch work on the shared worker lifecycle
sentry-junior[bot] Nov 1, 2026
0ccaed9
ref(task-execution): Let the Delivery owner post the turn limit notice
sentry-junior[bot] Nov 1, 2026
6bf2038
fix(agent-invocations): Do not count grandchild input toward a child'…
sentry-junior[bot] Nov 1, 2026
5af8d3d
ref(slack): Leave the resumed Turn limit count to the Slack slice
sentry-junior[bot] Nov 1, 2026
e0cc77a
fix(task-execution): Retry errors after the result save and close end…
sentry-junior[bot] Nov 1, 2026
2cf7679
ref(task-execution): Keep a setup failure without a Turn start and tr…
sentry-junior[bot] Nov 1, 2026
b770829
fix(task-execution): Do not post a second failure reply after a deliv…
sentry-junior[bot] Nov 1, 2026
b9cc6ef
fix(agent-dispatch): Close the Turn record when a dispatch ends witho…
sentry-junior[bot] Nov 1, 2026
4528c36
test(task-execution): Fail every Turn start write in the setup error row
sentry-junior[bot] Nov 1, 2026
ea77f89
fix(agent-dispatch): End the dispatch before its Turn writes on the l…
sentry-junior[bot] Nov 1, 2026
a4a2ef0
revert(task-execution): Drop the shared Turn layer and keep the child…
sentry-junior[bot] Nov 2, 2026
026e913
Merge main into fix/child-agent-resume-routing
sentry-junior[bot] Nov 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/junior/src/chat/agent-invocations/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ reads. Terms are in `TERMINOLOGY.md`.
no boundary is left, fail it now. Do not throw and requeue forever.
- The parent gets one report for the child's work. A finished invocation is
`replaced` while the child still has open work.
- Pick the child worker from the stored `parentConversationId`. Never pick it
from the Turn Source: a child watch-event Turn has an event Source. All
child work goes to the child worker and never falls through to a root
worker.

## Authority and privacy

Expand Down
42 changes: 40 additions & 2 deletions packages/junior/src/chat/agent-invocations/result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { StateAdapter } from "chat";
import type { Actor } from "@/chat/actor";
import { credentialContextForActor } from "@/chat/credentials/context";
import { SYSTEM_IDENTITY_ID } from "@/chat/identities/actor";
import type { AgentRunner } from "@/chat/runtime/agent-runner";
import {
EVENT_WAIT_MS,
isEventMailboxMetadata,
Expand Down Expand Up @@ -40,7 +41,11 @@ import type {
ChildAgent,
TerminalAgentInvocation,
} from "./types";
import { enqueueAgentInvocation } from "./work";
import {
createAgentInvocationWorker,
enqueueAgentInvocation,
resolveAgentInvocationId,
} from "./work";

const AGENT_INVOCATION_EVENT_NAMESPACE = "junior";
// TODO(dcramer): Add an `agent_invocation.needs_input` Event type when a child
Expand Down Expand Up @@ -360,7 +365,7 @@ async function getChildAgentInputInvocation(
* reports to the child's parent. Returns undefined for work that is not
* mailbox input for a child Conversation.
*/
export async function startAgentInvocationsForChildInput(
async function startAgentInvocationsForChildInput(
context: ConversationWorkerContext,
options: { queue: ConversationWorkQueue; state?: StateAdapter },
): Promise<ConversationWorkerResult | undefined> {
Expand Down Expand Up @@ -443,6 +448,39 @@ export async function startAgentInvocationsForChildInput(
return { status: "completed" };
}

/**
* Build the worker for all work of a child Conversation. The caller selects
* it from the stored parent of the Conversation, never from a Turn Source. It
* starts agent invocations for child input, then runs the agent invocation
* that owns the work. Work that no agent invocation owns is an error.
*/
export function createChildConversationWorker(
agentRunner: AgentRunner,
options: {
enqueueResult?: (invocation: TerminalAgentInvocation) => Promise<void>;
maxAgentDepth?: number;
queue: ConversationWorkQueue;
state?: StateAdapter;
},
) {
const invocationWorker = createAgentInvocationWorker(agentRunner, options);
return async (
context: ConversationWorkerContext,
): Promise<ConversationWorkerResult> => {
const started = await startAgentInvocationsForChildInput(context, options);
if (started) {
return started;
}
const invocationId = await resolveAgentInvocationId(context);
if (!invocationId) {
throw new Error(
`Child conversation ${context.conversationId} has no agent invocation for this work`,
);
}
return await invocationWorker(context, invocationId);
};
}

/**
* Run each agent invocation result or message as the Actor that triggered the
* child, user or system. This is on purpose: the result continues that
Expand Down
45 changes: 15 additions & 30 deletions packages/junior/src/chat/agent-invocations/work.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,12 +257,15 @@ export async function resolveAgentInvocationId(
if (active) {
return active.invocationId;
}
// A stop request can outlive the run that it stopped and wake the child
// again. The stopped agent invocation handles that wake.
// A child Conversation never runs on a root worker. A wake after its last
// agent invocation ended, such as a late stop request, goes to that agent
// invocation, which only repeats its idempotent finish. A paused Turn of an
// ended agent invocation can have an event Source, and a root worker would
// try to resume it.
const latest = await getLatestAgentInvocationForConversation(
context.conversationId,
);
return latest?.status === "stopped" ? latest.invocationId : undefined;
return latest?.invocationId;
}

/**
Expand Down Expand Up @@ -396,12 +399,15 @@ async function persistTerminalLifecycle(
terminal: TerminalAgentInvocation,
): Promise<void> {
const turnId = getAgentInvocationTurnId(terminal.invocationId);
if (terminal.status === "stopped") {
// A child stopped between slices has a paused Turn record. Nothing may
if (terminal.status !== "completed") {
// A child that ends between slices has a paused Turn record. Nothing may
// resume it.
await abandonTurnRecord({
conversationId: terminal.childConversationId,
errorMessage: "Turn cancelled",
errorMessage:
terminal.status === "stopped"
? "Turn cancelled"
: terminal.errorMessage,
turnId,
});
}
Expand Down Expand Up @@ -787,10 +793,9 @@ export function createAgentInvocationWorker(
}),
);
} catch (error) {
if (!(error instanceof AgentRunError)) {
throw error;
}
const runError = error.cause;
// A save error has no AgentRunError wrapper. On the final attempt it
// must also fail the agent invocation, or the child stays open.
const runError = error instanceof AgentRunError ? error.cause : error;
if (isInvocationInputCommitLost(runError)) {
return { status: "lost_lease" };
}
Expand Down Expand Up @@ -869,23 +874,3 @@ export function createAgentInvocationWorker(
return { status: "completed" };
};
}

/** Route invocation-backed work before falling through to existing consumers. */
export function routeAgentInvocationWork(options: {
fallbackWorker: (
context: ConversationWorkerContext,
) => Promise<ConversationWorkerResult>;
invocationWorker: (
context: ConversationWorkerContext,
invocationId: string,
) => Promise<ConversationWorkerResult>;
}) {
return async (
context: ConversationWorkerContext,
): Promise<ConversationWorkerResult> => {
const invocationId = await resolveAgentInvocationId(context);
return invocationId
? await options.invocationWorker(context, invocationId)
: await options.fallbackWorker(context);
};
}
32 changes: 12 additions & 20 deletions packages/junior/src/chat/app/conversation-work.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,8 @@ import {
resolveAgentDispatchId,
} from "@/chat/agent-dispatch/work";
import {
createAgentInvocationWorker,
resolveAgentInvocationId,
} from "@/chat/agent-invocations/work";
import {
createChildConversationWorker,
enqueueAgentInvocationResult,
startAgentInvocationsForChildInput,
withAgentInvocationResultActors,
} from "@/chat/agent-invocations/result";
import { botConfig } from "@/chat/config";
Expand Down Expand Up @@ -143,13 +139,15 @@ export function createConversationWork(
},
runTurn: runtime.runDispatchTurn,
});
const invocationWorker = createAgentInvocationWorker(options.agentRunner, {
const childWorker = createChildConversationWorker(options.agentRunner, {
enqueueResult: async (invocation) =>
await enqueueAgentInvocationResult(invocation, {
queue: options.queue,
state: options.state,
}),
maxAgentDepth: botConfig.maxAgentDepth,
queue: options.queue,
state: options.state,
});
const conversationTurnWorker = createConversationTurnWorker(
options.agentRunner,
Expand All @@ -158,8 +156,8 @@ export function createConversationWork(
state: options.state,
}),
);
// A Slack Destination uses the Slack worker. All other work must have been
// selected above this function.
// A Slack Destination uses the Slack worker. All other root work must have
// been selected above this function.
const destinationWorker = async (
context: ConversationWorkerContext,
): Promise<ConversationWorkerResult> => {
Expand All @@ -184,15 +182,13 @@ export function createConversationWork(
if (skipped) {
return skipped;
}
// Mailbox input for a child Conversation (a result, a message, or a watch
// event) starts an agent invocation on that child. The same input for a
// root Conversation runs as an Event Turn below.
const childInput = await startAgentInvocationsForChildInput(context, {
queue: options.queue,
state: options.state,
// The stored parent decides the kind of Conversation, so child work never
// reaches a root worker. A child Turn can have any Source.
const conversation = await options.conversationStore.get({
conversationId: context.conversationId,
});
if (childInput) {
return childInput;
if (conversation?.parentConversationId) {
return await childWorker(context);
}
const mailboxTurn = await resolveMailboxTurnWork(context);
if (mailboxTurn) {
Expand All @@ -201,10 +197,6 @@ export function createConversationWork(
await withAgentInvocationResultActors(mailboxTurn),
);
}
const invocationId = await resolveAgentInvocationId(context);
if (invocationId) {
return await invocationWorker(context, invocationId);
}
const dispatchId = await resolveAgentDispatchId(context);
if (dispatchId) {
return await dispatchWorker(context, dispatchId);
Expand Down
3 changes: 3 additions & 0 deletions packages/junior/src/chat/task-execution/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ checkpoint, paused Turns, heartbeat recovery, and the shared worker loop in
new queue wake.
- A Turn that loses its worker resumes once from its last save. A second loss
fails the Turn and keeps committed history.
- The last attempt before the worker gives up is final, also for a resume
wake with no messages. On that attempt each worker records its own
failure, so no work stops silently.
- Lease expiry permits recovery. It must not erase mailbox work or agent
history.
- Heartbeats repair missing wakes and abandoned leases. They are not a second
Expand Down
25 changes: 23 additions & 2 deletions packages/junior/src/chat/task-execution/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ export interface InboxAttempt {
handle: (messages: InboundMessage[]) => Promise<readonly string[] | void>,
options?: { deferred?: boolean },
): Promise<InboundMessage[]>;
/**
* True when a failure of this attempt can end the work: the worker gives up
* and drops the mailbox. The adapter must record its own failure first.
* Resume attempts, which have no messages, can also be final.
*/
isFinalAttempt: boolean;
messages: InboundMessage[];
}
Expand Down Expand Up @@ -226,6 +231,21 @@ function isTerminalFailure(failure: AttemptFailure): boolean {
);
}

/**
* Return whether a failure of this attempt can end the conversation work. The
* retry limit applies to every attempt, also one with no messages, such as a
* resume. A message also ends it at its own delivery limit.
*/
function isLastConversationAttempt(
work: ConversationWorkState,
messages: readonly InboundMessage[],
): boolean {
return (
(work.execution.retryCount ?? 0) + 1 >= CONVERSATION_WORK_MAX_RETRIES ||
messages.some((message) => isFinalAttempt(message))
);
}

function logRetryExhausted(args: {
error: unknown;
retryCount: number;
Expand Down Expand Up @@ -636,8 +656,9 @@ async function processConversationWorkInContext(
conversationId,
destination,
drain,
isFinalAttempt: attemptMessages.some((message) =>
isFinalAttempt(message),
isFinalAttempt: isLastConversationAttempt(
leasedWork,
attemptMessages,
),
messages: attemptMessages,
},
Expand Down
27 changes: 7 additions & 20 deletions packages/junior/src/cli/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,9 +246,7 @@ async function prepareLocalChatRun(
await import("@/chat/local/sandbox-egress-signals");
const { bindSpawnAgent } = await import("@/chat/agent-invocations/spawn");
const { bindMessageAgent } = await import("@/chat/agent-invocations/message");
const { createAgentInvocationWorker, routeAgentInvocationWork } =
await import("@/chat/agent-invocations/work");
const { startAgentInvocationsForChildInput } =
const { createChildConversationWorker } =
await import("@/chat/agent-invocations/result");
const { skipInputBeforeConversationStop } =
await import("@/chat/conversations/session-stop");
Expand All @@ -261,26 +259,15 @@ async function prepareLocalChatRun(
if (!agentRunner) {
throw new Error("Local agent runner is not ready");
}
const run = routeAgentInvocationWork({
// The local queue carries only child Conversation work.
fallbackWorker: async (context) => {
const skipped = await skipInputBeforeConversationStop(context);
if (skipped) {
return skipped;
}
const started = await startAgentInvocationsForChildInput(context, {
queue: localConversationWork.queue,
});
if (!started) {
throw new Error("Local child queue received non-invocation work");
}
return started;
},
invocationWorker: createAgentInvocationWorker(agentRunner),
const childWorker = createChildConversationWorker(agentRunner, {
queue: localConversationWork.queue,
});
await processConversationWork(message, {
queue: localConversationWork.queue,
run,
// The local queue carries only child Conversation work.
run: async (context) =>
(await skipInputBeforeConversationStop(context)) ??
(await childWorker(context)),
});
});
agentRunner = createAgentRunner(executeAgentRun, {
Expand Down
53 changes: 52 additions & 1 deletion packages/junior/tests/component/agent-invocation-worker.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { PiMessage } from "@/chat/pi/messages";
import { buildSteeringPiMessage } from "@/chat/agent/prompt";
import type { AgentSteeringMessage } from "@/chat/agent/types";
import type { AgentRun, AgentSteeringMessage } from "@/chat/agent/types";
import { agentInstructionProvenance } from "@/chat/conversations/provenance";
import {
createAgentInvocation,
Expand Down Expand Up @@ -523,6 +523,57 @@ describe("agent invocation worker", () => {
}
});

it("lets child agents spawn only down to maxAgentDepth", async () => {
const fixture = await prepareParentConversation();
try {
const child = await createAgentInvocation({
...INVOCATION_INPUT,
idempotencyKey: "depth-child",
});
const helper = await createAgentInvocation({
...INVOCATION_INPUT,
idempotencyKey: "depth-helper",
parentConversationId: child.childConversationId,
});
const runs: AgentRun[] = [];
const worker = createAgentInvocationWorker(
{
run: async (run) => {
runs.push(run);
throw new Error("stop after reading the run");
},
},
{ maxAgentDepth: 1 },
);
// With maxAgentDepth 1, depth 1 can spawn and depth 2 cannot.
for (const invocation of [child, helper]) {
await expect(
worker(
{
attempt: {
ack: vi.fn(),
conversationId: invocation.childConversationId,
drain: vi.fn(),
isFinalAttempt: false,
messages: [buildAgentInvocationInboundMessage(invocation)],
},
checkIn: vi.fn(),
conversationId: invocation.childConversationId,
shouldYield: () => false,
},
invocation.invocationId,
),
).rejects.toThrow("stop after reading the run");
}
expect(runs.map((run) => run.disabledFeatures)).toEqual([
["handoff", "interactive-auth"],
["handoff", "interactive-auth", "subagents"],
]);
} finally {
await fixture.close();
}
});

it("keeps unexpected runner failures retryable until the final attempt", async () => {
const fixture = await prepareParentConversation();
try {
Expand Down
Loading
Loading