Repository navigation
[Bug]: Stop on a completion-wake run leaves tasks delegated during that run able to wake the parent #15325
Description
Before submitting
- I searched existing issues and did not find a duplicate.
- I included enough detail to reproduce or investigate the problem.
Area
apps/server
Steps to reproduce
- A parent thread delegates an async task (A). The parent's turn ends.
- A completes, and its completion delivery starts a wake run (W) on the parent.
- While W is running, the agent calls
delegate_taskagain, creating task B (B.runId === W.id). - The user presses Stop on the parent, which sends
run.interruptfor W.
Expected behavior
Stop disposes the delegated-completion deliveries owned by the stopped run, as it does for an ordinary run. B's delivery becomes disposed, and B's later completion does not start a new parent turn.
Actual behavior
Only the incoming cohort (A's origin run) is disposed. B's delivery is left untouched (completionDelivery stays undefined, not disposed).
The run.interrupt handler picks a single cohort (Orchestrator.ts ~L7932 on 429c625a85):
const completionCohortRunId = completionMessage?.delegatedCompletion?.parentRunId ?? run.id;For a wake run this resolves to the origin run, so disposeDelegatedCompletionCohort never looks at tasks whose runId is the wake run itself. For an ordinary run it resolves to run.id, so those tasks are disposed.
planDelegatedCompletionDelivery refuses only when the run's cohort exists and is not open. Reading the code, I expect B's completion to still be offered to the parent, which would start a turn the user tried to stop. I confirmed the undisposed state with a test, but I did not observe that later wake live.
A minimal fix disposes both cohorts when they differ, new Set([completionCohortRunId, run.id]). I checked that change with the same test.
Impact
Minor bug or occasional failure
Version or commit
main at 429c625a85 (2026-10-03). Code-identical on ce90eec1ff.
Environment
macOS 27, checked with the server test suite (deterministic adapters).
Logs or stack traces
The deterministic test below fails on main:
× Stop disposes tasks spawned during a completion-wake run as well as its incoming cohort
AssertionError: expected undefined to equal 'disposed'
Test (append to apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts)
it.effect(
"Stop disposes tasks spawned during a completion-wake run as well as its incoming cohort",
() =>
Effect.gen(function* () {
const orchestrator = yield* Orchestrator.OrchestratorV2;
const sink = yield* EventSink.EventSinkV2;
const now = yield* DateTime.now;
const threadId = ThreadId.make("stop-wake-parent");
const originRunId = RunId.make("stop-wake-origin");
const taskId = NodeId.make("stop-wake-incoming-task");
yield* seedParentWithTerminalTask({
threadId,
projectId: ProjectId.make("stop-wake-project"),
runId: originRunId,
rootNodeId: NodeId.make("stop-wake-origin-root"),
taskId,
deliveryState: "claimed",
deliveryTaskIds: [taskId],
completionWake: "always",
now,
});
const original = yield* orchestrator.getThreadProjection(threadId);
yield* sink.write({
events: [
{
id: EventId.make("stop-wake-origin-completed"),
type: "run.updated",
threadId,
runId: originRunId,
occurredAt: now,
payload: { ...original.runs[0]!, status: "completed", completedAt: now },
},
],
});
const delivery = original.runs[0]!.delegatedCompletion!.delivery!;
yield* orchestrator.dispatch({
type: "message.dispatch",
createdBy: "agent",
creationSource: "server",
commandId: CommandId.make("stop-wake-start"),
threadId,
messageId: delivery.messageId,
text: "Delegated task completed",
attachments: [],
modelSelection,
dispatchMode: { type: "queue_after_active" },
delegatedCompletion: {
parentRunId: originRunId,
generation: delivery.generation,
taskIds: [taskId],
},
});
yield* orchestrator.resumeQueuedRuns;
const wake = (yield* orchestrator.getThreadProjection(threadId)).runs.find(
(run) => run.userMessageId === delivery.messageId,
)!;
const requested = yield* orchestrator.dispatch({
type: "delegated_task.request",
createdBy: "agent",
creationSource: "mcp",
commandId: CommandId.make("stop-wake-new-task"),
parentThreadId: threadId,
parentRunId: wake.id,
parentNodeId: wake.rootNodeId!,
task: "Do the next round",
modelSelection,
runtimeMode: "full-access",
interactionMode: "default",
completionWake: "always",
});
const taskEvent = requested.storedEvents.find(
(stored) => stored.event.type === "subagent.updated",
);
if (taskEvent?.event.type !== "subagent.updated")
return yield* Effect.die("Missing new task");
const newTaskId = taskEvent.event.payload.id;
yield* orchestrator.dispatch({
type: "run.interrupt",
commandId: CommandId.make("stop-wake-stop"),
threadId,
runId: wake.id,
});
const stopped = yield* orchestrator.getThreadProjection(threadId);
assert.equal(
stopped.subagents.find((task) => task.id === newTaskId)?.completionDelivery?.state,
"disposed",
);
assert.equal(
stopped.runs.find((run) => run.id === wake.id)?.delegatedCompletion?.disposition,
"stopped",
);
assert.equal(
stopped.runs.find((run) => run.id === originRunId)?.delegatedCompletion?.disposition,
"stopped",
);
}).pipe(Effect.provide(TestLayer)),
);Workaround
None clean. Press Stop again on the extra turn when it starts.
Found while working on #13490.
Activity
juliusmarminge commentedon Oct 3, 2026
Note
Grok responding on behalf of Julius.
Triage
Thanks for the precise report and the failing test, @matsvarn! I can confirm this is a real bug on current
main(39efcd8558). It's separate from the open feature issue #13490 and from the interrupt items in the open hardening tracker #15013.run.interruptonly disposes one cohort:On an ordinary run, the user message has no
delegatedCompletion, so that id is the stopped run itself. On a completion-wake run, it's the origin run that owned the incoming delivery.disposeDelegatedCompletionCohortthen only updates app-owned tasks whoserunIdmatches that id. A task created withdelegated_task.requestduring the wake run is stored withrunIdequal to the wake run (dispatchDelegatedTaskRequest). Because of that, Stop never writescompletionDelivery: { state: "disposed" }for it, and it never setsdelegatedCompletion.disposition: "stopped"on the wake run itself. The origin cohort still gets stopped, which matches what you saw.That undisposed state is enough for a later wake.
planDelegatedCompletionDeliveryonly refuses a delivery when the parent run's cohort exists and isn'topen. An undefined cohort takes the offer path, andofferDelegatedCompletionDeliverywill queue a turn when the cohort isopenand the provider thread still exists. I didn't re-run your test here, but theundefinedvs"disposed"result follows directly from that filter. Stopping doesn't cancel the child thread, so what's missing is the same delivery disposal an ordinary run already gets.Likely fix area
new Set([completionCohortRunId, run.id])). The two cohorts belong to different runs, so both writes can use the interrupt-time projection, and an ordinary run stays a single dispose.A maintainer will decide on the fix direction.