feat: post-review backlog hardening (cancel/parser/stall/history/9502)

Five independent items from the post-review backlog. F1: Stop on an external
agent task now aborts the running child via a per-task AbortController registry
reachable from the cancel route, and finalizes the assistant message as
cancelled (fixing two latent bugs — catch blocks left the message streaming,
and warm success-paths wrote complete on an aborted turn); warm pools/worktrees
are preserved and the native path is unchanged. F2/F3: prune the tool-call
parser to its two load-bearing exports (unexport eight zero-caller symbols, add
a gate test for the <invoke>-as-text fallback) and route placeholder-rejection
logging through pino. F6: a 90s per-chunk stall-timeout wraps native inference's
fullStream via AbortSignal.any so a hung stream finalizes the message instead of
hanging — no retry (a pure classifyStreamError helper is added). F7: a read-only
view_session_history MCP tool (newest-N, chronological). F9: retire the unused
apps/coder/web :9502 fallback SPA, keeping every API/WS/health/MCP route.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 02:23:11 +00:00
parent 9a139633b8
commit f32fd928b3
48 changed files with 1014 additions and 2254 deletions

View File

@@ -0,0 +1,18 @@
// Pure classifier for errors thrown from the fullStream loop. Establishes the
// retry seam for when llama-swap gains restart-in-place-with-clear-partial
// semantics. No retry is performed today (partial-stream re-emit is
// non-idempotent at single-local-instance scale).
export type StreamErrorKind = 'stall' | 'transient' | 'non-retryable';
export function classifyStreamError(err: unknown): StreamErrorKind {
if (err instanceof Error && err.name === 'AbortError') {
return 'stall';
}
if (err != null && typeof err === 'object') {
const status = (err as Record<string, unknown>).status;
if (typeof status === 'number' && status >= 500 && status < 600) {
return 'transient';
}
}
return 'non-retryable';
}

View File

@@ -11,6 +11,7 @@ import type { Agent, ToolCall } from '../../types/api.js';
import type { ToolJsonSchema } from '../tools.js';
import type { OpenAiMessage } from './payload.js';
import { extractToolCallBlocks } from './tool-call-parser.js';
import { classifyStreamError } from './stream-error-classifier.js';
import type { StreamResult } from './types.js';
import { upstreamModel } from './provider.js';
import {
@@ -193,6 +194,10 @@ function buildAiTools(schemas: ToolJsonSchema[]): Record<string, ReturnType<type
return out;
}
// F6: per-chunk stall deadline. Exported so tests can advance fake timers by
// exactly this value without hardcoding a magic number.
export const STALL_TIMEOUT_MS = 90_000;
// v1.10.5 Qwen-coder XML fallback. Some local models (notably qwen3-coder via
// llama-swap) emit tool calls as inline XML inside delta.content rather than
// the structured tool_calls field. We extract them out of the streamed text
@@ -267,6 +272,22 @@ export async function streamCompletion(
// before this. They now go through the same extraBody path as the new params.
const samplerBody = buildSamplerProviderOptions(opts);
// F6: per-chunk stall deadline. If the model stops emitting chunks for
// STALL_TIMEOUT_MS the stallAc fires through AbortSignal.any; the post-loop
// abort check below then throws AbortError → handleAbortOrError writes
// 'cancelled'. Timer is bumped on every chunk and cleared in the finally.
// NO retry: partial-stream re-emit is non-idempotent at single-local-instance
// scale; see stream-error-classifier.ts for the future retry seam.
const stallAc = new AbortController();
let stallTimer: ReturnType<typeof setTimeout> | null = null;
const bumpStallTimer = () => {
if (stallTimer !== null) clearTimeout(stallTimer);
stallTimer = setTimeout(() => stallAc.abort(), STALL_TIMEOUT_MS);
};
const effectiveSignal = signal
? AbortSignal.any([signal, stallAc.signal])
: stallAc.signal;
const result = streamText({
model: upstreamModel(ctx.config, model, agent ?? null),
messages: aiMessages,
@@ -277,7 +298,7 @@ export async function streamCompletion(
...(typeof opts.top_p === 'number' ? { topP: opts.top_p } : {}),
...(typeof opts.presence_penalty === 'number' ? { presencePenalty: opts.presence_penalty } : {}),
...(samplerBody ? { providerOptions: { openaiCompatible: samplerBody } } : {}),
abortSignal: signal,
abortSignal: effectiveSignal,
});
let content = '';
@@ -289,7 +310,11 @@ export async function streamCompletion(
// same flat list and keep the v1.10.5 synthetic id convention.
const toolCalls: ToolCall[] = [];
bumpStallTimer();
try {
for await (const part of result.fullStream) {
bumpStallTimer();
switch (part.type) {
case 'text-delta': {
pendingBuffer += part.text;
@@ -297,7 +322,7 @@ export async function streamCompletion(
// complete <tool_call> or <invoke> block, flushes prose between/around
// them, holds any partial opener for the next chunk, and silently
// drops blocks that fail to parse (matches pre-v1.13.16 behavior).
const extracted = extractToolCallBlocks(pendingBuffer);
const extracted = extractToolCallBlocks(pendingBuffer, ctx.log);
if (extracted.flushed.length > 0) {
content += extracted.flushed;
onDelta(extracted.flushed);
@@ -339,7 +364,9 @@ export async function streamCompletion(
}
case 'error': {
const err = part.error;
throw err instanceof Error ? err : new Error(String(err));
const actualErr = err instanceof Error ? err : new Error(String(err));
ctx.log.warn({ kind: classifyStreamError(actualErr) }, 'stream error part');
throw actualErr;
}
// Intentional no-op: start, start-step, text-start, text-end,
// reasoning-start, reasoning-end, source, file, tool-input-start,
@@ -365,7 +392,8 @@ export async function streamCompletion(
// Without this throw the row would land as status='complete' with partial
// content instead of going through handleAbortOrError → status='cancelled'.
// Smoke D caught this in v1.13.1-A — don't refactor it away.
if (signal?.aborted) {
// F6: also catch the stall timeout arm (stallAc.signal.aborted).
if (signal?.aborted || stallAc.signal.aborted) {
const abortErr = new Error('aborted');
abortErr.name = 'AbortError';
throw abortErr;
@@ -402,4 +430,12 @@ export async function streamCompletion(
completionTokens,
reasoning: reasoningAccumulated,
};
} finally {
// Clear the stall timer whether the stream completes normally, throws, or
// is aborted — prevents a dangling timer from firing after the turn ends.
if (stallTimer !== null) {
clearTimeout(stallTimer);
stallTimer = null;
}
}
}

View File

@@ -5,10 +5,10 @@
// ── Constants ────────────────────────────────────────────────────────────
export const XML_TOOL_OPEN = '<tool_call>';
export const XML_TOOL_CLOSE = '</tool_call>';
export const INVOKE_TOOL_OPEN = '<invoke';
export const INVOKE_TOOL_CLOSE = '</invoke>';
const XML_TOOL_OPEN = '<tool_call>';
const XML_TOOL_CLOSE = '</tool_call>';
const INVOKE_TOOL_OPEN = '<invoke';
const INVOKE_TOOL_CLOSE = '</invoke>';
// ── Strip patterns ───────────────────────────────────────────────────────
@@ -45,7 +45,7 @@ export interface ParsedCall {
const PLACEHOLDER_LITERALS = new Set(['...', 'placeholder', '<path>', '<file>']);
const ANGLE_BRACKET_SENTINEL_RE = /^<[^>]+>$/;
export function isPlaceholderArgValue(value: unknown): boolean {
function isPlaceholderArgValue(value: unknown): boolean {
if (typeof value !== 'string') return false;
const trimmed = value.trim();
if (trimmed === '') return true;
@@ -61,17 +61,21 @@ function hasPlaceholderArgs(args: Record<string, unknown>): boolean {
return false;
}
function logRejectedPlaceholder(parsed: ParsedCall): void {
console.debug(
{ toolName: parsed.name, args: parsed.args },
'rejected placeholder tool call at parse time',
);
type MinLogger = { debug(obj: object, msg: string): void };
function logRejectedPlaceholder(parsed: ParsedCall, log?: MinLogger): void {
if (log) {
log.debug(
{ toolName: parsed.name, args: parsed.args },
'rejected placeholder tool call at parse time',
);
}
}
const QWEN_FUNCTION_RE = /<function\s*=\s*([^>\s]+)\s*>/;
const QWEN_PARAM_RE = /<parameter\s*=\s*([^>\s]+)\s*>([\s\S]*?)<\/parameter>/g;
export function parseXmlToolCall(block: string): ParsedCall | null {
function parseXmlToolCall(block: string): ParsedCall | null {
const nameMatch = block.match(QWEN_FUNCTION_RE);
if (!nameMatch || !nameMatch[1]) return null;
const name = nameMatch[1].trim();
@@ -95,7 +99,7 @@ const INVOKE_NAME_RE =
const INVOKE_PARAM_RE =
/<parameter\s+name\s*=\s*("([^"]*)"|'([^']*)')\s*>([\s\S]*?)<\/parameter>/g;
export function parseInvokeToolCall(block: string): ParsedCall | null {
function parseInvokeToolCall(block: string): ParsedCall | null {
const nameMatch = block.match(INVOKE_NAME_RE);
if (!nameMatch) return null;
const name = (nameMatch[2] ?? nameMatch[3] ?? '').trim();
@@ -116,7 +120,7 @@ export function parseInvokeToolCall(block: string): ParsedCall | null {
const ALL_OPENERS = [XML_TOOL_OPEN, INVOKE_TOOL_OPEN] as const;
export function partialXmlOpenerStart(s: string): number {
function partialXmlOpenerStart(s: string): number {
let earliest = -1;
for (const op of ALL_OPENERS) {
const idx = s.indexOf(op);
@@ -150,7 +154,7 @@ const OPENER_SPECS: ReadonlyArray<OpenerSpec> = [
{ open: INVOKE_TOOL_OPEN, close: INVOKE_TOOL_CLOSE, parse: parseInvokeToolCall },
];
export function extractToolCallBlocks(buffer: string): ToolCallExtraction {
export function extractToolCallBlocks(buffer: string, log?: MinLogger): ToolCallExtraction {
let flushed = '';
const calls: ParsedCall[] = [];
let pos = 0;
@@ -176,7 +180,7 @@ export function extractToolCallBlocks(buffer: string): ToolCallExtraction {
const parsed = next.spec.parse(block);
if (parsed) {
if (hasPlaceholderArgs(parsed.args)) {
logRejectedPlaceholder(parsed);
logRejectedPlaceholder(parsed, log);
flushed += block;
} else {
calls.push(parsed);