Repository navigation
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/mobile
Steps to reproduce
The production state is a thread whose latest executed run failed with usage_limit, with a user follow-up queued for the same provider. Server startup recovery holds that follow-up; the public thread.stop command produces the same held state. The mobile queue sheet then offers Resume queue. A usage-limit failure alone leaves the queue unheld in this fixture (it blocks automatic promotion without setting the hold), so the restart or explicit Stop step matters.
In the app: on a thread whose run hit a usage limit and that has a queued follow-up, restart the server (or stop the thread with thread.stop), open the queue sheet, and tap Resume queue. This native tap was traced in source, not executed on a device.
Deterministic reproduction: from the top-level checkout of the commit under Version or commit, with its locked dependencies installed, save the script below outside the checkout as repro.sh and run env -u NODE_ENV sh /path/to/repro.sh. It creates a mktemp fixture with private HOME, TMPDIR and T3CODE_HOME directories, appends temporary focused cases to the existing apps/server/src/orchestration-v2/runtimeLayer.test.ts, runs the production OrchestratorV2.dispatch, startup recovery and the production client queue selector deriveThreadQueueWorkflowState, and restores the test file on exit. It needs no credentials, network, running server or native app. It seeds the persisted provider failure through the production event sink rather than running a real provider, so provider error classification is not exercised. It exits 0 while the defect is present.
Reproduction script: repro.sh
#!/bin/sh
set -eu
# Run from a checkout with its locked dependencies installed, or pass its root as $1.
checkout=${1:-$(pwd)}
fixture=$(mktemp -d "${TMPDIR:-/tmp}/queue-sheet-repro.XXXXXX")
mkdir -p "$fixture/userhome" "$fixture/tmp" "$fixture/t3"
test_file="$checkout/apps/server/src/orchestration-v2/runtimeLayer.test.ts"
cp "$test_file" "$fixture/original.test.ts"
cleanup() { cp "$fixture/original.test.ts" "$test_file"; rm -rf "$fixture"; }
trap cleanup EXIT HUP INT TERM
cat >> "$test_file" <<'VERIFY_TEST'
import { deriveThreadQueueWorkflowState as verifyQueueWorkflow } from "../../../../packages/client-runtime/src/state/threadWorkflows.ts";
const emitVerify = (...args: unknown[]) => process.stdout.write(args.map(String).join(" ") + "\n");
it.layer(layerTest)("queue sheet resume reproduction", (it) => {
it.effect.each(([
["startup", "usage_limit"],
["startup", "provider_error"],
["stop", "usage_limit"],
["stop", "provider_error"],
] as const).map(([trigger, failureClass]) => ({ trigger, failureClass })))("$trigger after $failureClass", ({ trigger, failureClass }) => Effect.gen(function* () {
const orchestrator = yield* Orchestrator.OrchestratorV2;
const events = yield* EventSink.EventSinkV2;
const recovery = yield* ProviderRuntimeRecoveryService.ProviderRuntimeRecoveryService;
const threadId = ThreadId.make(`verify-sheet:${trigger}:${failureClass}`);
yield* orchestrator.dispatch({
type: "thread.create", createdBy: "user", creationSource: "mobile",
commandId: CommandId.make(`${threadId}:create`), threadId,
projectId: ProjectId.make(`${threadId}:project`), title: "Queue verification",
modelSelection, runtimeMode: "full-access", interactionMode: "default",
branch: null, worktreePath: process.cwd(),
});
for (const index of [0, 1]) {
yield* orchestrator.dispatch({
type: "message.dispatch", createdBy: "user", creationSource: "mobile",
commandId: CommandId.make(`${threadId}:message:${index}`), threadId,
messageId: MessageId.make(`${threadId}:message:${index}`),
text: index === 0 ? "Start" : "Follow up", attachments: [], modelSelection,
dispatchMode: { type: index === 0 ? "start_immediately" : "queue_after_active" },
});
}
const active = (yield* orchestrator.getThreadProjection(threadId)).runs.find(r => r.status === "starting")!;
assert.isDefined(active);
const now = yield* DateTime.now;
yield* events.write({ events: [
{
id: EventId.make(`${threadId}:error`), type: "turn-item.updated", threadId,
runId: active.id, nodeId: active.rootNodeId!, providerInstanceId: active.providerInstanceId,
occurredAt: now,
payload: {
id: TurnItemId.make(`${threadId}:error`), type: "error", threadId,
runId: active.id, nodeId: active.rootNodeId!, providerThreadId: active.providerThreadId,
providerTurnId: null, nativeItemRef: null, parentItemId: null, ordinal: 2,
status: "failed", title: "Provider failure", startedAt: now, completedAt: now, updatedAt: now,
failure: { class: failureClass, message: "Provider failed.", code: "provider_failed", retryable: null,
...(failureClass === "usage_limit" ? { resetAt: null } : {}) },
},
},
{
id: EventId.make(`${threadId}:failed`), type: "run.updated", threadId,
runId: active.id, providerInstanceId: active.providerInstanceId, occurredAt: now,
payload: { ...active, status: "failed", completedAt: now },
},
] });
yield* orchestrator.resumeQueuedRuns;
const before = yield* orchestrator.getThreadProjection(threadId);
emitVerify("VERIFY_BEFORE", trigger, failureClass, JSON.stringify(before.runs.map(r => [r.status, r.queueHeld === true])));
if (trigger === "startup") yield* recovery.reconcile("startup");
else yield* orchestrator.dispatch({ type: "thread.stop", commandId: CommandId.make(`${threadId}:stop`), threadId });
const projection = yield* orchestrator.getThreadProjection(threadId);
const workflow = verifyQueueWorkflow(projection);
const shell = (yield* orchestrator.getShellSnapshot()).threads.find(t => t.id === threadId)!;
const visible = workflow.isHeld && workflow.queuedRuns.length > 0;
assert.isTrue(visible);
assert.equal(shell.lastErrorClass, failureClass);
const outcome = yield* orchestrator.dispatch({
type: "queue.resume", commandId: CommandId.make(`${threadId}:resume`), threadId,
}).pipe(
Effect.as("RESUMED"),
Effect.catchTag("OrchestratorDispatchError", error => Effect.succeed(`REJECTED: ${String(error.cause)}`)),
);
const after = yield* orchestrator.getThreadProjection(threadId);
const unchanged = JSON.stringify(projection.runs) === JSON.stringify(after.runs);
emitVerify("VERIFY_RESULT", JSON.stringify({ trigger, failureClass,
runs: projection.runs.map(r => [r.status, r.queueHeld === true]),
lastErrorClass: shell.lastErrorClass, queuedCount: workflow.queuedRuns.length,
isHeld: workflow.isHeld, sheetButtonVisible: visible, outcome, queueUnchanged: unchanged }));
if (failureClass === "usage_limit") {
assert.equal(outcome, "REJECTED: Continue the limited thread before resuming its queue.");
assert.isTrue(unchanged);
yield* orchestrator.dispatch({
type: "message.dispatch", createdBy: "user", creationSource: "mobile",
commandId: CommandId.make(`${threadId}:continue`), threadId,
messageId: MessageId.make(`${threadId}:continue`), manualContinuationOfRunId: active.id,
text: "Continue where you left off.", attachments: [], modelSelection,
dispatchMode: { type: "start_immediately" },
});
yield* orchestrator.dispatch({type: "queue.resume", commandId: CommandId.make(`${threadId}:resume-after-continue`), threadId});
const continued = yield* orchestrator.getThreadProjection(threadId);
assert.isTrue(continued.runs.some(r => r.id !== active.id && r.status === "starting"));
assert.isFalse(continued.runs.some(r => r.status === "queued" && r.queueHeld === true));
emitVerify("VERIFY_WORKAROUND", trigger, "manual continuation then queue.resume succeeded; queued follow-up retained and unheld");
} else {
assert.equal(outcome, "RESUMED");
assert.isFalse(after.runs.some(r => r.status === "queued" && r.queueHeld === true));
}
}));
});
VERIFY_TEST
cd "$checkout"
printf 'Revision: '
git rev-parse HEAD
set +e
env -u NODE_ENV HOME="$fixture/userhome" TMPDIR="$fixture/tmp" T3CODE_HOME="$fixture/t3" PATH="$checkout/node_modules/.bin:$PATH" vp test run apps/server/src/orchestration-v2/runtimeLayer.test.ts -t "queue sheet resume reproduction" > "$fixture/test.log" 2>&1
result=$?
set -e
cat "$fixture/test.log"
[ "$result" -eq 0 ] || exit "$result"
[ "$(grep -c 'VERIFY_RESULT' "$fixture/test.log")" -eq 4 ]
[ "$(grep -c 'VERIFY_WORKAROUND' "$fixture/test.log")" -eq 2 ]
printf '%s\n' 'DEFECT PRESENT: all held usage-limited queues reject the visible sheet action; all provider-error controls and all manual-continuation workarounds succeed.'Expected behavior
An enabled Resume queue control should resume the queue, or explain why it cannot and direct the user to the working recovery action; it should not silently discard a known refusal. The mobile composer's Resume thread action already handles this state: it sends a manual continuation of the limited run before resuming the queue (apps/mobile/src/features/threads/resume-thread.ts#L45-L82), as docs/user/composer.md#L260-L263 describes for an empty composer. That guide describes the composer and does not explicitly specify the sheet's semantics. The server's refusal text is written for the user to act on, and the sheet's command hook opts into failure reporting (apps/mobile/src/state/use-atom-command.ts#L15-L21).
Actual behavior
On mobile, the queue sheet shows Queue paused with an enabled Resume queue button when a thread's queue is held after its latest run failed with usage_limit. Tapping the button sends a bare queue.resume, which the server refuses with Continue the limited thread before resuming its queue.; the sheet discards that refusal, resets its busy state and shows no explanation, so the offered action fails every time for this state with no visible feedback. The queued message is kept, and the empty composer's Resume thread action still recovers the thread. The server guard itself is intentional; this report concerns the sheet offering an action it cannot complete and dropping the refusal. Whether the fix continues the limited run from the sheet, hides or disables the button, or explains the refusal is the maintainers' choice.
Observed through the production orchestrator in both the restart and thread.stop cases (full output under Logs or stack traces): the selector reports isHeld=true with one queued user message, so the sheet's Resume queue button is eligible; queue.resume returns Continue the limited thread before resuming its queue.; the complete run list is unchanged. The provider_error controls resume successfully. Sending the manual continuation first and then queue.resume succeeds for both limited cases and keeps the queued follow-up. The vitest summary reported 4 passed and 151 skipped.
The source-traced mobile result is that the sheet keeps showing Queue paused, the queued message and Resume queue; the tap sends bare queue.resume, receives the refusal, resets its busy state and shows no dialog or inline explanation. A native mobile tap was not observed.
Evidence
- Expected source:
resume-thread.tslines 45-82 (composer continuation before resume) anddocs/user/composer.mdlines 260-263; the user-facing refusal text and the failure-reporting opt-in atuse-atom-command.tslines 15-21 (all linked above). - Failure source: the reproduction output under Logs or stack traces, plus the source trace: apps/mobile/src/features/threads/ThreadQueueControl.tsx#L208-L223 renders the enabled button and sends only
{ threadId }; packages/client-runtime/src/operations/commands.ts#L1091-L1101 emits barequeue.resume; apps/server/src/orchestration-v2/Orchestrator.ts#L14271-L14285 refuses it; packages/client-runtime/src/state/runtime.ts#L462-L477 sends the typed failure only toconsole.warn, and the sheet ignores the returned Failure value. - Evidence provenance: observed
- Local verification: reproduced
- Reproduction completeness: complete
Restart recovery adds the hold at apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts#L419-L441; thread.stop holds queued runs at apps/server/src/orchestration-v2/Orchestrator.ts#L11852-L11880. The tested Stop route is the public thread.stop command only; this is not a claim that mobile's separate named-run Stop (which uses run.interrupt with holdQueue: true) remains offered after a terminal usage-limit failure. The server, recovery and selector behavior were executed; the native rendering and tap were established by source trace, not on a device.
Related
Hypothesis, not established: the sheet header kept its queue-only path when the composer gained usage-limit continuation and visible error handling. Closed #27187 and merged #27662 fixed the same symptom in the mobile composer and did not touch ThreadQueueControl.tsx. #31236 and open PR #31283 cover per-message Send now; the current #31283 diff leaves Resume queue unchanged. #35371 concerns stale usage-limited worktree storage cleanup, a different bug. Closed unmerged #21164 and #14617 are not delivered fixes for this behavior.
Restoration check
In the held usage-limited state above (restart or thread.stop), tapping the sheet's Resume queue either resumes the queue, for example by continuing the limited run first so the queued follow-up starts, or is not offered as an enabled action, or shows the user why it cannot proceed and points to Resume thread; it no longer resets silently with the queue unchanged. Controls that must keep passing: a held queue after provider_error still resumes from the sheet, the queued follow-up is never lost, and the composer's Resume thread path still succeeds. The script above exercises the server, recovery and selector rather than the sheet's handler, so its server-side refusal assertion may legitimately keep passing after a sheet-level fix; restoration needs a check at the sheet's tap handling.
Impact
Minor bug or occasional failure
Version or commit
d9805b2; the same four focused cases also reproduce on upstream main at a7779fc (that source rerun used the first commit's dependency installation, not a refreshed install).
Environment
Darwin arm64, Node v26.12.1, Vitest 5.0.5 through the repository's vp test runner; no running app, server, provider, simulator or device needed.
Logs or stack traces
Revision: d9805b2ca667be7caec8c9035950564ab96704c3
VERIFY_BEFORE startup usage_limit [["failed",false],["queued",false]]
VERIFY_RESULT {"trigger":"startup","failureClass":"usage_limit","runs":[["failed",false],["queued",true]],"lastErrorClass":"usage_limit","queuedCount":1,"isHeld":true,"sheetButtonVisible":true,"outcome":"REJECTED: Continue the limited thread before resuming its queue.","queueUnchanged":true}
VERIFY_WORKAROUND startup manual continuation then queue.resume succeeded; queued follow-up retained and unheld
VERIFY_BEFORE startup provider_error [["failed",false],["queued",true]]
VERIFY_RESULT {"trigger":"startup","failureClass":"provider_error","runs":[["failed",false],["queued",true]],"lastErrorClass":"provider_error","queuedCount":1,"isHeld":true,"sheetButtonVisible":true,"outcome":"RESUMED","queueUnchanged":false}
VERIFY_BEFORE stop usage_limit [["failed",false],["queued",false]]
VERIFY_RESULT {"trigger":"stop","failureClass":"usage_limit","runs":[["failed",false],["queued",true]],"lastErrorClass":"usage_limit","queuedCount":1,"isHeld":true,"sheetButtonVisible":true,"outcome":"REJECTED: Continue the limited thread before resuming its queue.","queueUnchanged":true}
VERIFY_WORKAROUND stop manual continuation then queue.resume succeeded; queued follow-up retained and unheld
VERIFY_BEFORE stop provider_error [["failed",false],["queued",true]]
VERIFY_RESULT {"trigger":"stop","failureClass":"provider_error","runs":[["failed",false],["queued",true]],"lastErrorClass":"provider_error","queuedCount":1,"isHeld":true,"sheetButtonVisible":true,"outcome":"RESUMED","queueUnchanged":false}
DEFECT PRESENT: all held usage-limited queues reject the visible sheet action; all provider-error controls and all manual-continuation workarounds succeed.Screenshots, recordings, or supporting files
No response
Workaround
Leave the queue sheet, empty the composer and use Resume thread. That path sends the manual continuation (manualContinuationOfRunId) before resuming the held queue; the production command sequence succeeded for both limited fixtures and kept the queued follow-up.
Activity