Skip to content

Cancelling one queued completion message disables future results from still-running sibling tasks #32806

Closed

Description

@Dropje97

What happened

A coordinator agent ran two delegated tasks, A and B, started in the same run. When A finished while the coordinator was busy, its completion message was queued, and the coordinator cancelled that queued message (t3_queue action: "cancel") because it had already read A's result. B, still running, finished about 17 minutes later and never woke the coordinator: its result was saved, but no completion message was delivered. The coordinator found it only through a periodic status check.

The cancellation disposes the completion cohort of the run that spawned both tasks. It also sets B's delivery to disposed while B remains running. B subsequently completes normally, with its result saved and observedByRunId: null, but no completion wake is offered.

Expected: cancelling A's queued message removes that delivery. B, whose result was not in the message, keeps its promised completion wake.

t3_queue describes its scope as “Manage a thread's queued messages.” (tool definition) The running-task result says: “Completion wakes this thread, so end your turn. Do not wait on it or poll it.” (contract) Cancelling a message does not advertise disabling another task's future delivery.

Impact: major degradation or frequent failure

A coordinator can silently miss a finished review after deleting another queued result. The recorded case was found by a later periodic check. Frequency was not measured.

Diagnosis

At the installed build's upstream commit 47f2c8d29e981c7723467efe137ee19e91df924e:

  1. dispatchQueuedRunCancel sees a queued message's delegatedCompletion and calls disposeDelegatedCompletionCohort with its parentRunId, without restricting the disposal to the message's task IDs (Orchestrator.ts:9678).
  2. That helper selects every app-owned task attached to the owning run (Orchestrator.ts:2617), disposes the cohort, and marks every task whose delivery is not already acknowledged, delivered or disposed as disposed (Orchestrator.ts:2642). It does not require the task to have finished or to appear in the cancelled message.
  3. When B finishes, planDelegatedCompletionDelivery returns offer: false for its preserved disposed state (Orchestrator.ts:12131).

Still present on main 71c2d3a302092b94511eb7e6ab4e6844a66dbf22. The queued-cancel branch is unchanged at Orchestrator.ts:10074; the planner is unchanged at :12530. Main's helper at :2784 can preserve siblings through interruptedWakeTaskIds, but the queued-cancel caller does not supply it. Its default still disposes all outstanding tasks on the owning run.

Steps to reproduce

These steps follow from the source and the production event sequence below. No clean reproduction or tests were run for this investigation.

  1. In one coordinator run, start two async delegated tasks, A and B.
  2. Let A finish while the coordinator is busy, so A's completion message is queued. Keep B running.
  3. Cancel A's queued message with t3_queue { "action": "cancel", "queuedRunId": "<A's queued wake run>" }.
  4. End the coordinator turn and let B finish. B's result is saved, but it does not wake the coordinator.

Version

Server 0.1.8-nightly.20261106.4275, upstream 47f2c8d29e981c7723467efe137ee19e91df924e.

Environment

Debian 13, Linux x64 LXC, systemd user service; Claude coordinator, Codex gpt-6.1-sol reviewer. Local patches: thread-session-boundary and reconnect-fairness. Neither changes the queue handler, cohort disposal or delivery planner. They concern process/session boundaries and SQLite/storage scheduling respectively.

Evidence

Read-only orchestration_events evidence, UTC on 2026-11-07. P is mcp:0e63ae8e…; B is the Codex reviewer. Task titles, result text and unrelated repository identifiers are omitted.

sequence  time          stream  event
6107922   20:16:42.160  P       reviewer B started, owning parent run 4293
6110563   20:29:55.687  P       completion wake run 4324 created queued
6110567   20:29:55.687  P       wake message: parentRunId=4293, generation=2
                                taskIds contains only a different completed task, A
6110810   20:30:43.946  P       t3_queue(action=cancel, queuedRunId=4324)
6110851   20:30:52.246  P       run 4293: cohort disposition=disposed, delivery=null
6110852   20:30:52.246  P       B still running; completionDelivery=disposed, observer=null
6110854   20:30:52.246  P       queued wake run 4324 cancelled
6110859   20:30:52.327  P       t3_queue success: sequence=6110856
6114660   20:47:14.535  B       final assistant message saved
6114663   20:47:14.579  B       provider turn completed
6114672   20:47:14.625  B       run 1 completed
6114674   20:47:15.290  P       B completed with result saved; delivery still disposed, observer=null

The disposal and cancellation events share command mcp:e585b9a5…; the queue tool's returned sequence identifies that batch. This establishes the cause before B's completion, rather than inferring it from a missing wake alone.

The scoped parent queries returned 3 events for B/owning run in 20:15–20:48, 2 for queued run 4324 and its message in 20:20–20:31, and 13 tool/message/run events in 20:30:20–20:31:10. The reviewer query returned 8 message/provider-turn/run events in 20:46:30–20:47:30. Every query used a specific thread and time window, with SQLite opened using mode=ro.

Related issues

  • #28175, second problem: interrupting or steering an executing completion wake disposes siblings. This case uses t3_queue cancel before the wake runs. Main's preservation option is used by the interrupt path, but not this path.
  • #23871: explicit Stop deliberately suppresses outstanding wakes. This incident is a single queued-message cancellation, without Stop.

Duplicate searches included dispatchQueuedRunCancel, disposeDelegatedCompletionCohort, queued-run.cancel, and combinations of t3_queue, completionDelivery, disposed, sibling and cohort.

Fix applied or workaround

Avoid cancelling queued delegated-completion messages while sibling tasks from the same owning run remain outstanding. If a cancellation already happened, read the saved child result explicitly; the coordinator did recover this review that way. Reading it does not restore future notification policy.

Possible fix: dispose only the task IDs carried by the cancelled message and keep unrelated pending tasks eligible for a later wake. Preserve the separate, deliberate Stop behavior.

Filed by

Claude (Opus 5.5) in T3 Code, on the owner's instruction.

Activity

  1. changed the title [-][Bug]: Cancelling one queued completion message disables future results from still-running sibling tasks[/-] [+]Cancelling one queued completion message disables future results from still-running sibling tasks[/+] on Nov 8, 2026
  2. aron-intframe commented on Nov 8, 2026

    @aron-intframe

    Reproduced on main at a1af1841 with a server test. Passing interruptedWakeTaskIds from the queued-cancel caller is not enough on its own, and the same cancel also drops tasks the coordinator starts after it.

    Cause. dispatchQueuedRunCancel (Orchestrator.ts:10076) calls disposeDelegatedCompletionCohort without interruptedWakeTaskIds, so keptTaskIds is empty (:2814). Every outstanding app-owned task on the owning run is marked disposed, and the cohort itself becomes disposed (:2831). planDelegatedCompletionDelivery then returns offer: false for B because of its row, and for any later task because the cohort is no longer open (:12548).

    How I checked. I copied the "interrupted completion wakes" case (DelegatedCompletionDelivery.test.ts:1383) and changed three things: the coordinator run stays running so A's wake is queued, not started; run.interrupt becomes queued-run.cancel; B is a delegated_task.request sibling that completes after the cancel. A second case starts the task only after the cancel, with no sibling outstanding.

    vp test run apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts -t "queued completion wake" --silent=false
    

    On main:

    [repro] after queued-run.cancel: {"wakeStatus":"cancelled","incoming":"disposed","siblingStatus":"running","siblingDelivery":"disposed","cohort":"disposed"}
    AssertionError: a still-running sibling that was not in the cancelled message must keep its wake: expected 'disposed' to not equal 'disposed'
    [repro] after queued-run.cancel: {"wakeStatus":"cancelled","incoming":"disposed","cohort":"disposed"}
    [repro] late task after it completes: {"lateTaskDelivery":"disposed","cohort":"disposed"}
    Tests  2 failed | 39 skipped (41)
    

    Why the one-line fix falls short. With only interruptedWakeTaskIds: queuedMessage.delegatedCompletion.taskIds, B is kept, but emitQueuedRunCancellation is gated on keptTaskIds.size === 0 (:2876), so the queued message is never removed:

    [repro] after queued-run.cancel: {"wakeStatus":"queued","incoming":"disposed","siblingStatus":"running","siblingDelivery":null,"cohort":"open"}
    AssertionError: expected 'queued' to equal 'cancelled'
    

    Adding cancelQueuedDelivery: false and an explicit emitQueuedRunCancellation fixes the sibling case, but the late-task case still ends lateTaskDelivery: "disposed", because with nothing kept the cohort is still disposed.

    Fix that passes both cases. Removing a queued wake disposes only the tasks that message carried, cancels the run, and leaves the cohort open. The terminal-run listener already handles a cancelled delivery run (:13156): it clears cohort.delivery, or reserves a successor for pending results, so the next result gets a new generation.

    diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts
    index 5d3c4391..d832e930 100644
    --- a/apps/server/src/orchestration-v2/Orchestrator.ts
    +++ b/apps/server/src/orchestration-v2/Orchestrator.ts
    @@ -10072,15 +10072,28 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
           (candidate) => candidate.id === queuedRun.userMessageId,
         );
         if (queuedMessage?.delegatedCompletion !== undefined) {
    +      // Removing a wake drops only the results it carried; the cohort stays open for later ones.
           const now = yield* DateTime.now;
    -      yield* disposeDelegatedCompletionCohort({
    -        command,
    -        events,
    -        projection,
    -        parentRunId: queuedMessage.delegatedCompletion.parentRunId,
    -        disposition: "disposed",
    -        now,
    -      });
    +      const carried = queuedMessage.delegatedCompletion.taskIds;
    +      const emitEvent = emit(events, command);
    +      for (const task of projection.subagents) {
    +        if (!carried.includes(task.id) || task.completionDelivery?.state !== "claimed") continue;
    +        yield* emitEvent({
    +          type: "subagent.updated",
    +          threadId: queuedRun.threadId,
    +          ...(task.runId === null ? {} : { runId: task.runId }),
    +          nodeId: task.id,
    +          driver: task.driver,
    +          providerInstanceId: task.providerInstanceId,
    +          occurredAt: now,
    +          payload: {
    +            ...task,
    +            completionDelivery: { state: "disposed", observedByRunId: null },
    +            updatedAt: now,
    +          },
    +        });
    +      }
    +      yield* emitQueuedRunCancellation({ command, events, projection, run: queuedRun, now });
           return;
         }
         const queuedRootNode =

    With it:

    [repro] after queued-run.cancel: {"wakeStatus":"cancelled","incoming":"disposed","siblingStatus":"running","siblingDelivery":null,"cohort":"open"}
    [repro] after sibling completes: {"successor":{"generation":2,"messageId":"message:thread:sibling-queued-cancel-parent:ordinal:2:…","taskIds":["node:delegated-task:sibling-queued-cancel-sibling"]}}
    [repro] late task after it completes: {"lateTaskDelivery":"claimed","cohort":"open"}
    Tests  2 passed | 39 skipped (41)
    

    DelegatedCompletionDelivery, ThreadStop, runtimeLayer, GuestShare, ThreadDeletion, SubagentProjection and ThreadManagementService tests: 245 passed, 0 failed. effect-tsgo tsrs --noEmit in apps/server reports no errors in Orchestrator.ts (only the existing brand suggestions at :200-:332, which are outside the patch).

    One call for you: thread.settle also goes through dispatchQueuedRunCancel for automatic queued runs (:3529). With this patch, settling no longer disposes those cohorts (the settle tests still pass). If settle should keep dropping later results, keep the old disposeDelegatedCompletionCohort call on that path only. thread.archive is not affected; it disposes all cohorts separately (:4317).

    Test body after the shared seeding (same as :1383, minus the write that completes the origin run)
    yield* orchestrator.dispatch({
      type: "message.dispatch", createdBy: "agent", creationSource: "server",
      commandId: CommandId.make(`${key}-wake`), threadId, messageId: delivery.messageId,
      text: "Delegated task completed", attachments: [], modelSelection,
      dispatchMode: { type: "queue_after_active" },
      delegatedCompletion: { parentRunId: originRunId, generation: delivery.generation, taskIds: delivery.taskIds },
    });
    yield* orchestrator.resumeQueuedRuns;
    const wake = (yield* orchestrator.getThreadProjection(threadId)).runs.find(
      (run) => run.userMessageId === delivery.messageId,
    )!;
    assert.equal(wake.status, "queued"); // origin is still running
    
    const cancelled = yield* orchestrator.dispatch({
      type: "queued-run.cancel", commandId: CommandId.make(`${key}-cancel`), threadId, runId: wake.id,
    });
    const cancelDelivery = new Map(cancelled.storedEvents.flatMap(({ event }) =>
      event.type === "subagent.updated" ? [[event.payload.id, event.payload.completionDelivery?.state] as const] : []));
    const afterCancel = yield* orchestrator.getThreadProjection(threadId);
    assert.equal(afterCancel.runs.find((run) => run.id === wake.id)?.status, "cancelled");
    assert.equal(cancelDelivery.get(incomingTaskId), "disposed");
    assert.notEqual(
      afterCancel.subagents.find((task) => task.id === sibling.id)?.completionDelivery?.state,
      "disposed",
      "a still-running sibling that was not in the cancelled message must keep its wake",
    );
    // then complete B's child run (as completeSibling does at :1490), wait for B's
    // subagent.updated with completionDelivery "claimed", and assert the cohort's
    // delivery is a new message whose taskIds are [sibling.id].

    Not run: a real provider turn. This is the event-level test only.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions