From 049244af8483a4384716bee90d96165d8647974b Mon Sep 17 00:00:00 2001 From: AI091 Date: Fri, 23 Oct 2026 12:01:34 +0300 Subject: [PATCH] fix(server): background wakes wait out a usage limit instead of failing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a provider hits its usage limit, the thread's run fails. Background work that finishes afterwards (subagents, monitors, watched commands) wakes the thread with a queued message. With no run active, the orchestrator started that message immediately, so every wake failed against the same limit and its result reached a turn that never ran. A queued message now waits behind the limited run, which is the rule startNextQueuedRun already applies to queued runs on a limited provider. Continuing the thread drains it as before. Model: Claude Opus 5.5 (claude-opus-5-5) ยท Harness: Claude Code in T3 Code --- .../src/orchestration-v2/Orchestrator.ts | 8 +- .../src/orchestration-v2/runtimeLayer.test.ts | 101 ++++++++++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 8c8620088fa94..85c55942fad80 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -5237,7 +5237,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio projection.thread.worktreePath === null ? latestRun : undefined; - const queueBehindRun = activeRun ?? worktreeMissingRun; + const usageLimitedRun = + activeRun === undefined && + dispatchMode.type === "queue_after_active" && + usageLimitHoldsQueue(projection, modelSelection.instanceId) + ? (latestRun ?? undefined) + : undefined; + const queueBehindRun = activeRun ?? worktreeMissingRun ?? usageLimitedRun; const pendingMergeBackTransfers = pendingMergeBackTransfersForThread(projection); const startsOrQueuesRun = dispatchMode.type === "defer_start" || diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 9db0564cced01..e0cc356428113 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -4202,6 +4202,107 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect("queues a background wake behind a usage limit instead of starting it", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const threadId = ThreadId.make("runtime-layer-limited-wake"); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`${threadId}:create`), + threadId, + createdBy: "user", + creationSource: "web", + projectId: ProjectId.make(`${threadId}:project`), + title: "Limited wake", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: process.cwd(), + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${threadId}:prompt`), + threadId, + messageId: MessageId.make(`${threadId}:prompt`), + text: "Prompt", + attachments: [], + createdBy: "user", + creationSource: "web", + dispatchMode: { type: "start_immediately" }, + }); + const [limited] = (yield* orchestrator.getThreadProjection(threadId)).runs; + assert.isDefined(limited); + const now = yield* DateTime.now; + yield* eventSink.write({ + events: [ + { + id: EventId.make(`${threadId}:error`), + type: "turn-item.updated", + threadId, + runId: limited.id, + nodeId: limited.rootNodeId!, + providerInstanceId: limited.providerInstanceId, + occurredAt: now, + payload: { + id: TurnItemId.make(`${threadId}:error`), + type: "error", + threadId, + runId: limited.id, + nodeId: limited.rootNodeId!, + providerThreadId: limited.providerThreadId, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 2, + status: "failed", + title: "Usage limit reached", + startedAt: now, + completedAt: now, + updatedAt: now, + failure: { + class: "usage_limit", + message: "Usage limit reached", + code: "usage_limit", + retryable: null, + }, + }, + }, + { + id: EventId.make(`${threadId}:failed`), + type: "run.updated", + threadId, + runId: limited.id, + providerInstanceId: limited.providerInstanceId, + occurredAt: now, + payload: { ...limited, status: "failed", completedAt: now }, + }, + ], + }); + + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${threadId}:wake`), + threadId, + messageId: MessageId.make(`${threadId}:wake`), + text: "Subagent finished", + attachments: [], + createdBy: "agent", + creationSource: "provider", + notification: { + source: { kind: "background_task" }, + outcome: "completed", + summary: "Subagent finished", + }, + dispatchMode: { type: "queue_after_active" }, + }); + const { runs } = yield* orchestrator.getThreadProjection(threadId); + const wake = runs.find((run) => run.userMessageId === `${threadId}:wake`); + assert.equal(wake?.status, "queued"); + }), + ); + it.effect.each([ ["server", modelSelection], ["provider", modelSelection],