Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
6f00ee9
fix(core): recognize OpenAI SDK APIUserAbortError as an abort
harjothkhara Aug 2, 2026
2fad2fd
test(core): pin the abort match against sibling SDK errors
harjothkhara Aug 3, 2026
ad700bf
fix(core): suppress the api_error telemetry event on user cancels
harjothkhara Aug 3, 2026
1868550
test(core): pin the cancel gate's truth table and its stream call site
harjothkhara Aug 4, 2026
6fe680f
Merge branch 'main' into oss-find/qwen-code-2026-08-02
wenshao Aug 4, 2026
546e192
test(core): pin the setup and DOMException cancel paths, correct a ra…
harjothkhara Aug 4, 2026
a433efd
Merge branch 'main' into oss-find/qwen-code-2026-08-02
wenshao Aug 5, 2026
63237d2
fix(core): keep reporting timed-out side queries, not just user cancels
harjothkhara Aug 6, 2026
ebf497e
fix(core): signal internal deadlines as TimeoutError so they stay rep…
harjothkhara Aug 6, 2026
ea1cf10
Merge branch 'main' into oss-find/qwen-code-2026-08-02
wenshao Aug 7, 2026
ebb9c25
fix(core): share the timeout-abort contract and convert its fifth pro…
harjothkhara Aug 7, 2026
577c499
fix(cli): signal the voice-refine deadline as TimeoutError
harjothkhara Aug 8, 2026
fcd299c
Merge remote-tracking branch 'origin/main' into oss-find/qwen-code-20…
harjothkhara Aug 8, 2026
77d2d44
fix(core): signal the goal checkpoint verifier deadline as TimeoutError
harjothkhara Aug 8, 2026
262bfa5
docs(core): design note for the user-cancel vs internal-deadline cont…
harjothkhara Aug 8, 2026
1d8072b
docs(core): record the daemon prompt deadline as an open boundary case
harjothkhara Aug 9, 2026
aa6eca2
refactor(core): reduce #8399 to the #8398 fix, split out the invariant
harjothkhara Aug 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions packages/cli/src/ui/voice/voice-refine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,9 +158,11 @@ describe('refineVoiceTranscript', () => {
afterEach(() => vi.useRealTimers());

it('falls back to raw when refinement exceeds the timeout', async () => {
let captured: AbortSignal | undefined;
mockRunSideQuery.mockImplementation(
(_config, opts: { abortSignal: AbortSignal }) =>
new Promise((_resolve, reject) => {
captured = opts.abortSignal;
opts.abortSignal.addEventListener('abort', () =>
reject(new Error('aborted')),
);
Expand All @@ -173,6 +175,33 @@ describe('refineVoiceTranscript', () => {
);
await vi.advanceTimersByTimeAsync(2500);
await expect(pending).resolves.toBe('raw words');
// The deadline must signal TimeoutError: this signal reaches a model
// request, and a bare abort reads downstream as a user cancel — its
// api_error would be suppressed for what is really a hung fast model.
expect((captured?.reason as Error).name).toBe('TimeoutError');
});

it('forwards a user cancel as a bare abort, distinct from the deadline', async () => {
// The external-abort path is a genuine user cancel and must NOT carry
// the TimeoutError shape, or downstream would report the user's own
// Esc as a failed request.
let captured: AbortSignal | undefined;
mockRunSideQuery.mockImplementation(
(_config, opts: { abortSignal: AbortSignal }) =>
new Promise((_resolve, reject) => {
captured = opts.abortSignal;
opts.abortSignal.addEventListener('abort', () =>
reject(new Error('aborted')),
);
}),
);
const user = new AbortController();
const pending = refineVoiceTranscript(config, 'raw words', user.signal);
user.abort();
await vi.advanceTimersByTimeAsync(0);
await expect(pending).resolves.toBe('raw words');
expect(captured?.aborted).toBe(true);
expect((captured?.reason as Error).name).toBe('AbortError');
});
});
});
19 changes: 17 additions & 2 deletions packages/cli/src/ui/voice/voice-refine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { createDebugLogger, runSideQuery } from '@qwen-code/qwen-code-core';
import {
createDebugLogger,
runSideQuery,
timeoutAbortReason,
} from '@qwen-code/qwen-code-core';
import type { Config } from '@qwen-code/qwen-code-core';

const debugLogger = createDebugLogger('VOICE_REFINE');
Expand Down Expand Up @@ -53,7 +57,18 @@ export async function refineVoiceTranscript(
const controller = new AbortController();
const onExternalAbort = () => controller.abort();
signal.addEventListener('abort', onExternalAbort, { once: true });
const timer = setTimeout(() => controller.abort(), REFINE_TIMEOUT_MS);
// The external abort above stays bare — it forwards a genuine user cancel.
// The deadline signals TimeoutError so a hung fast model is reported as a
// failure downstream instead of being suppressed as a user cancel.
const timer = setTimeout(
() =>
controller.abort(
timeoutAbortReason(
`Voice transcript refinement timed out after ${REFINE_TIMEOUT_MS}ms`,
),
),
REFINE_TIMEOUT_MS,
);

try {
const { text } = await runSideQuery(config, {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/agents/runtime/workflow-orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,7 @@ export function createProductionDispatch(
// retry loop. The wrapper owns the per-attempt AbortController +
// AgentEventEmitter; it chains the caller's `signal` into the
// per-attempt controller and hands both into `runSingleDispatch`. A
// stall fires `controller.abort('stalled')`, the attempt returns
// stall fires a TimeoutError-shaped abort (see workflow-stall.ts), the attempt returns
// CANCELLED, runSingleDispatch throws its "did not complete" terminal,
// and the wrapper retries (up to 3) when `watchdog.stalled()` is set
// and the parent signal is NOT aborted.
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/agents/runtime/workflow-sandbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -940,6 +940,10 @@ describe('createWorkflowSandbox security', () => {
/exceeded 100 ms of active time/,
);
expect(abortOnTimeout.signal.aborted).toBe(true);
// The reason must be TimeoutError-shaped: this signal reaches dispatched
// subagents' model requests, and a bare abort reads downstream as a user
// cancel — suppressing the api_error for a wall-clock kill.
expect((abortOnTimeout.signal.reason as Error).name).toBe('TimeoutError');
});

// T40 sibling: a normal completion must NOT abort the controller — the
Expand Down
12 changes: 10 additions & 2 deletions packages/core/src/agents/runtime/workflow-sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@ function isRegexContext(source: string, i: number): boolean {

import * as vm from 'node:vm';
import { createDebugLogger } from '../../utils/debugLogger.js';
import { timeoutAbortReason } from '../../utils/errors.js';
import type { WorkflowDispatchScheduler } from './workflow-dispatch-scheduler.js';

// Shared with workflow-orchestrator (avoids a duplicate createDebugLogger
Expand Down Expand Up @@ -1760,8 +1761,15 @@ export function createWorkflowSandbox(opts: SandboxOptions): WorkflowSandbox {
// T40 (PR #4732 R4): abort linked controller BEFORE rejecting so
// in-flight subagents see the cancellation and stop. Order
// matters: rejecting first then aborting would race the
// caller's finally block.
opts.abortOnTimeout?.abort();
// caller's finally block. TimeoutError-shaped reason: this signal
// reaches dispatched subagents' model requests, and a bare abort
// reads downstream as a user cancel, suppressing the api_error
// for a wall-clock kill. User cancels (handle.abort()) stay bare.
opts.abortOnTimeout?.abort(
timeoutAbortReason(
`Workflow execution exceeded ${maxWallClockMs} ms of active time`,
),
);
reject(
new Error(
`Workflow execution exceeded ${maxWallClockMs} ms of active time (paused time is not counted). ` +
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/agents/runtime/workflow-stall.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
DEFAULT_STALL_MS,
MAX_STALL_ATTEMPTS,
MAX_WORKFLOW_STALL_MS_ENV,
STALL_ABORT_REASON,
} from './workflow-stall.js';

describe('resolveStallMs', () => {
Expand Down Expand Up @@ -71,7 +72,12 @@ describe('attachStallWatchdog', () => {
vi.advanceTimersByTime(2);
expect(wd.stalled()).toBe(true);
expect(controller.signal.aborted).toBe(true);
expect(controller.signal.reason).toBe('stalled');
// TimeoutError-shaped, carrying the same 'stalled' text. The attempt
// signal reaches model requests, and a reason that is not TimeoutError
// reads downstream as a user cancel and loses the api_error event.
expect(controller.signal.reason).toBeInstanceOf(DOMException);
expect(controller.signal.reason.name).toBe('TimeoutError');
expect(controller.signal.reason.message).toBe(STALL_ABORT_REASON);
wd.dispose();
});

Expand Down
21 changes: 17 additions & 4 deletions packages/core/src/agents/runtime/workflow-stall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
* `AbortController` and `AgentEventEmitter`. It chains the caller's parent
* signal into the per-attempt controller (so parent cancellation still
* propagates) and passes BOTH the per-attempt signal and emitter into the
* single-attempt dispatch. A stall fires `controller.abort('stalled')`,
* single-attempt dispatch. A stall fires `controller.abort(timeoutAbortReason(STALL_ABORT_REASON))` — a TimeoutError-shaped
* reason, so downstream telemetry does not read it as a user cancel —
* which makes the subagent return `CANCELLED`; the single-attempt dispatch
* then throws its "did not complete" terminal, which the wrapper catches.
* The wrapper distinguishes a stall-abort (retry) from a parent-abort
Expand All @@ -38,8 +39,16 @@

import { AgentEventEmitter, AgentEventType } from './agent-events.js';
import { createDebugLogger } from '../../utils/debugLogger.js';
import { timeoutAbortReason } from '../../utils/errors.js';
import { parsePositiveIntegerEnv } from '../../utils/env.js';

/**
* Message carried by the watchdog's abort reason. The reason is a
* `TimeoutError`-named DOMException rather than this bare string so downstream
* consumers can tell a watchdog abort from a user cancel.
*/
export const STALL_ABORT_REASON = 'stalled';

/** Default stall timeout: 60s of no progress (with no tool in flight). */
export const DEFAULT_STALL_MS = 60_000;

Expand Down Expand Up @@ -77,7 +86,7 @@ export function resolveStallMs(
const debugLogger = createDebugLogger('WORKFLOW_STALL');

export interface StallWatchdogHandle {
/** True once the watchdog has fired `controller.abort('stalled')`. */
/** True once the watchdog has fired its TimeoutError-shaped abort. */
stalled(): boolean;
/** Clear the timer + detach listeners. Idempotent; call in a `finally`. */
dispose(): void;
Expand All @@ -87,7 +96,7 @@ export interface StallWatchdogHandle {
* Attach a stall watchdog to a subagent's event emitter. The watchdog arms
* a `stallMs` timer that any progress event resets; while a tool is in
* flight the timer is held (a long tool call is not a stall). When the
* timer elapses with no in-flight tool, it fires `controller.abort('stalled')`.
* timer elapses with no in-flight tool, it fires `controller.abort(timeoutAbortReason(STALL_ABORT_REASON))`.
*
* The watchdog arms on the FIRST progress event, not at attach time. The
* time-to-first-response window — connection setup, server-side queueing, and
Expand Down Expand Up @@ -133,7 +142,11 @@ export function attachStallWatchdog(
`[Workflow] agent dispatch stalled — no progress for ${stallMs}ms; aborting.`,
);
try {
controller.abort('stalled');
// Reason keeps the 'stalled' text but is TimeoutError-shaped: the
// attempt signal reaches model requests, and a bare string reason
// reads downstream as a user cancel, suppressing the api_error for
// what is actually a watchdog abort.
controller.abort(timeoutAbortReason(STALL_ABORT_REASON));
} catch (e) {
debugLogger.warn('stall watchdog abort threw:', e);
}
Expand Down
Loading
Loading