Skip to content

[Bug]: Stop on a completion-wake run leaves tasks delegated during that run able to wake the parent #15325

Closed

Description

@matsvarn

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

  1. A parent thread delegates an async task (A). The parent's turn ends.
  2. A completes, and its completion delivery starts a wake run (W) on the parent.
  3. While W is running, the agent calls delegate_task again, creating task B (B.runId === W.id).
  4. The user presses Stop on the parent, which sends run.interrupt for 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

  1. juliusmarminge commented on Oct 3, 2026

    @juliusmarminge
    Member

    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.interrupt only disposes one cohort:

    // apps/server/src/orchestration-v2/Orchestrator.ts ~L7932
    const completionCohortRunId = completionMessage?.delegatedCompletion?.parentRunId ?? run.id;

    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. disposeDelegatedCompletionCohort then only updates app-owned tasks whose runId matches that id. A task created with delegated_task.request during the wake run is stored with runId equal to the wake run (dispatchDelegatedTaskRequest). Because of that, Stop never writes completionDelivery: { state: "disposed" } for it, and it never sets delegatedCompletion.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. planDelegatedCompletionDelivery only refuses a delivery when the parent run's cohort exists and isn't open. An undefined cohort takes the offer path, and offerDelegatedCompletionDelivery will queue a turn when the cohort is open and the provider thread still exists. I didn't re-run your test here, but the undefined vs "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

    • One option is the one you suggested: dispose both ids when they differ (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.

  2. added
    bugSomething is broken or behaving incorrectly.
    via-triageFiled through npx t3 triage
    on Oct 3, 2026
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

    bugSomething is broken or behaving incorrectly.via-triageFiled through npx t3 triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions