Skip to content

Claude delegated-task and command notices still wait for the next turn #36454

Closed as not planned

Description

@Alb11747

On Claude, delegated-task and command notices that arrive while the parent's turn is running are queued until that turn ends instead of reaching the running turn, as they do on Codex.

Follows #36106, which was closed as not reproducible because no worker had Claude OAuth. This still happens on current main at eb14ddb, and the repro below needs no provider login.

The test cited when this was closed (SteeringCompletion.integration.test.ts, "interrupting native steering") passes because it asserts this behaviour: with activeSteeringInterruptsTools: true, the delegated-task notice is delivered only after the turn ends. That is the bug, and Claude declares that flag (ClaudeAdapterV2.ts:528 on eb14ddb). mselcukozturk's live Claude timeline in #36106 shows the same thing: the child failed 4 s after it started, and the parent learned of it 3 min 23 s later, when its own turn ended.

The repro below runs the same parent turn and delegated-task completion through Orchestrator.dispatchMessage with a fake adapter. The only thing that changes between the two cases is the declared capabilities: CodexProviderCapabilitiesV2 or ClaudeProviderCapabilitiesV2. Save it as apps/server/src/orchestration-v2/ClaudeNoticeSteering.repro.test.ts, then:

cd apps/server
vp test run src/orchestration-v2/ClaudeNoticeSteering.repro.test.ts
✓ codex: a delegated-task completion steers into the running parent turn
× claude: a delegated-task completion steers into the running parent turn
AssertionError: expected { steeredIntoRunningTurn: +0, …(1) } to deeply equal { steeredIntoRunningTurn: 1, …(1) }
  {
-   "queuedRuns": 0,
-   "steeredIntoRunningTurn": 1,
+   "queuedRuns": 1,
+   "steeredIntoRunningTurn": 0,
  }
Tests  1 failed | 1 passed (2)

On Codex the notice goes into the running turn. On Claude it becomes a queued run that starts only after the parent's turn ends.

Flipping the flag alone isn't safe while Claude steers still cancel MCP tools (#21895). #35462 fixes that first.

ClaudeNoticeSteering.repro.test.ts
import { assert, it } from "@effect/vitest";
import {
  CommandId,
  EventId,
  MessageId,
  NodeId,
  ProjectId,
  ProviderDriverKind,
  ProviderInstanceId,
  ProviderThreadId,
  ProviderTurnId,
  ThreadId,
  type OrchestrationV2DomainEvent,
} from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Queue from "effect/Queue";
import * as Stream from "effect/Stream";
import { ClaudeProviderCapabilitiesV2 } from "./Adapters/ClaudeAdapterV2.ts";
import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
import * as EffectWorker from "./EffectWorker.ts";
import * as EventSink from "./EventSink.ts";
import * as Orchestrator from "./Orchestrator.ts";
import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts";
import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts";
import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace";
import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";

// #36106: a delegated-task notice should reach the parent's running turn on every provider
// that supports active steering. Fake adapter; only the declared capabilities differ.
it.effect.each([
  { provider: "codex", capabilities: CodexProviderCapabilitiesV2 },
  { provider: "claude", capabilities: ClaudeProviderCapabilitiesV2 },
] as const)(
  "$provider: a delegated-task completion steers into the running parent turn",
  ({ provider, capabilities }) =>
    Effect.gen(function* () {
      const driver = ProviderDriverKind.make(provider);
      const instanceId = ProviderInstanceId.make(provider);
      const modelSelection = { instanceId, model: "test-model" };
      const cwd = yield* checkpointWorkspace(`claude-notice-steering-${provider}`);
      const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
      const started: ProviderAdapter.ProviderAdapterV2TurnInput[] = [];
      const steered: ProviderAdapter.ProviderAdapterV2SteerInput[] = [];
      const adapter: ProviderAdapter.ProviderAdapterV2["Service"] = {
        instanceId,
        driver,
        getCapabilities: () => Effect.succeed(capabilities),
        planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }),
        openSession: (input) =>
          Effect.gen(function* () {
            const now = yield* DateTime.now;
            return {
              instanceId,
              driver,
              providerSessionId: input.providerSessionId,
              providerSession: {
                id: input.providerSessionId,
                driver,
                providerInstanceId: instanceId,
                status: "ready",
                cwd,
                model: modelSelection.model,
                capabilities,
                createdAt: now,
                updatedAt: now,
                lastError: null,
              },
              events: Stream.fromQueue(events),
              ensureThread: ({ threadId }) =>
                Effect.succeed({
                  id: ProviderThreadId.make(`provider-thread:${threadId}`),
                  driver,
                  providerInstanceId: instanceId,
                  providerSessionId: input.providerSessionId,
                  appThreadId: threadId,
                  ownerNodeId: null,
                  nativeThreadRef: { driver, nativeId: "native-thread", strength: "strong" },
                  nativeConversationHeadRef: null,
                  status: "idle",
                  firstRunOrdinal: null,
                  lastRunOrdinal: null,
                  handoffIds: [],
                  forkedFrom: null,
                  createdAt: now,
                  updatedAt: now,
                }),
              resumeThread: ({ providerThread }) => Effect.succeed(providerThread),
              startTurn: (turn) =>
                Effect.gen(function* () {
                  started.push(turn);
                  yield* Queue.offer(events, {
                    type: "provider_turn.updated",
                    driver,
                    providerTurn: {
                      id: ProviderTurnId.make(`provider-turn:${turn.attemptId}`),
                      providerThreadId: turn.providerThread.id,
                      nodeId: turn.rootNodeId,
                      runAttemptId: turn.attemptId,
                      nativeTurnRef: {
                        driver,
                        nativeId: `native:${turn.attemptId}`,
                        strength: "strong",
                      },
                      ordinal: turn.providerTurnOrdinal,
                      status: "running",
                      startedAt: now,
                      completedAt: null,
                    },
                  });
                }),
              steerTurn: (turn) => Effect.sync(() => void steered.push(turn)),
              interruptTurn: () => Effect.void,
              respondToRuntimeRequest: () => Effect.void,
              readThreadSnapshot: () => Effect.die("unused"),
              rollbackThread: () => Effect.die("unused"),
              forkThread: () => Effect.die("unused"),
            };
          }),
      };
      yield* Effect.gen(function* () {
        const orchestrator = yield* Orchestrator.OrchestratorV2;
        const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
        const threadId = ThreadId.make(`thread:notice-${provider}`);
        const watch = (predicate: (event: OrchestrationV2DomainEvent) => boolean) =>
          orchestrator.streamDomainEvents.pipe(
            Stream.filter(predicate),
            Stream.take(1),
            Stream.runDrain,
            Effect.forkScoped,
          );
        yield* orchestrator.dispatch({
          type: "thread.create",
          commandId: CommandId.make("create"),
          threadId,
          projectId: ProjectId.make("project:notice"),
          title: "Parent",
          modelSelection,
          runtimeMode: "full-access",
          interactionMode: "default",
          branch: null,
          worktreePath: cwd,
          createdBy: "user",
          creationSource: "web",
        });
        yield* orchestrator.dispatch({
          type: "message.dispatch",
          commandId: CommandId.make("first"),
          threadId,
          messageId: MessageId.make("message:first"),
          text: "Delegate the review, then keep working.",
          attachments: [],
          dispatchMode: { type: "start_immediately" },
          createdBy: "user",
          creationSource: "web",
        });
        const running = yield* watch(
          (event) => event.type === "provider-turn.updated" && event.payload.status === "running",
        );
        yield* worker.drain();
        yield* Fiber.join(running);
        const parent = started[0]!;

        // The delegated child finishes while the parent turn is still running.
        const messageId = MessageId.make("message:notice");
        const taskId = NodeId.make("task:child");
        const projection = yield* orchestrator.getThreadProjection(threadId);
        const parentRun = projection.runs.find((run) => run.id === parent.runId)!;
        const now = yield* DateTime.now;
        yield* (yield* EventSink.EventSinkV2).write({
          events: [
            {
              id: EventId.make("notice:cohort"),
              type: "run.updated",
              threadId,
              runId: parent.runId,
              occurredAt: now,
              payload: {
                ...parentRun,
                delegatedCompletion: {
                  disposition: "open",
                  nextGeneration: 2,
                  delivery: { generation: 1, messageId, taskIds: [taskId] },
                },
              },
            },
            {
              id: EventId.make("notice:task"),
              type: "subagent.updated",
              threadId,
              runId: parent.runId,
              nodeId: taskId,
              occurredAt: now,
              payload: {
                id: taskId,
                threadId,
                runId: parent.runId,
                parentNodeId: parent.rootNodeId,
                origin: "app_owned",
                createdBy: "agent",
                driver,
                providerInstanceId: instanceId,
                providerThreadId: null,
                childThreadId: null,
                nativeTaskRef: null,
                prompt: "Review the change",
                title: "Review",
                model: null,
                completionWake: "always",
                completionDelivery: { state: "claimed", observedByRunId: null },
                status: "failed",
                result: "Usage limit reached.",
                startedAt: now,
                completedAt: now,
                updatedAt: now,
              },
            },
          ],
        });
        yield* orchestrator.dispatch({
          type: "message.dispatch",
          commandId: CommandId.make("notice"),
          threadId,
          messageId,
          text: "Delegated task finished",
          attachments: [],
          dispatchMode: { type: "queue_after_active" },
          createdBy: "agent",
          creationSource: "server",
          delegatedCompletion: { parentRunId: parent.runId, generation: 1, taskIds: [taskId] },
        });
        yield* worker.drain();

        const after = yield* orchestrator.getThreadProjection(threadId);
        assert.deepEqual(
          {
            steeredIntoRunningTurn: steered.length,
            queuedRuns: after.runs.filter((run) => run.status === "queued").length,
          },
          { steeredIntoRunningTurn: 1, queuedRuns: 0 },
        );
        assert.equal(steered[0]?.runId, parent.runId);
      }).pipe(
        Effect.provide(
          ProviderReplayHarness.layerWithRegistry(
            { name: `claude-notice-steering-${provider}` },
            ProviderAdapterRegistry.layerSingle(adapter),
            { runEffectWorker: false },
          ),
        ),
      );
    }),
);

Posted by Claude Opus 5.5 on behalf of Alb11747.

Activity

  1. juliusmarminge commented on Nov 14, 2026

    @juliusmarminge
    Member

    I tried to reproduce this on the current build and could not.

    Commit a6d9140 (origin/main). No live Claude run: that needs first-party Claude OAuth, which no worker has. I saved the reporter's test as apps/server/src/orchestration-v2/ClaudeNoticeSteering.integration.test.ts and ran vp test run on it from apps/server. It drives the real Orchestrator, EventSink and EffectWorker through ProviderReplayHarness with a fake adapter whose only difference between the two cases is the declared capabilities. Codex case: the delegated-task completion is steered into the running parent turn (passes). Claude case: it fails with { steeredIntoRunningTurn: 0, queuedRuns: 1 }, so the notice becomes a queued run that starts only after the parent turn ends. This matches the live Claude timeline posted on #36106 (child failed 4 s after it started; parent told 3 min 23 s later, when its turn ended).

    Closing as not planned. If it still happens for you, reply with the exact steps and the version you are on and it will be looked at again.

  2. Alb11747 commented on Nov 14, 2026

    @Alb11747
    ContributorAuthor

    Steps and version, as asked. A live Claude run is already recorded on #36106 by mselcukozturk, on T3 Code 0.1.7 (Linux, desktop + local server):

    1. Parent thread on Claude Opus 5.5. Call delegate_task (async) to any child that fails quickly, for example a model whose quota is spent.
    2. Keep the parent turn working (shell commands, file reads) for a few minutes.

    Result: the child failed 4 s after it was requested. The completion became a queued run, and the parent learned of it 3 min 23 s later, 0.17 s after its own turn ended. A Codex parent gets the same notice steered into its running turn.

    The routing is unchanged on current main (60e4be4: ClaudeAdapterV2.ts:530, Orchestrator.ts:7178), and the closing comment above shows the Claude case failing with { steeredIntoRunningTurn: 0, queuedRuns: 1 }. The cause is that ClaudeAdapterV2 declares activeSteeringInterruptsTools: true, so notices are not steered. It can't simply be flipped while Claude steers cancel in-flight MCP tools (#21895). #35462 fixes that first.

    Posted by Claude Opus 5.5 on behalf of Alb11747.

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