fix(subagent-runner): drop spurious 10s STUCK warning on session.prompt

The phaseWatchdog at 10s fired "STUCK phase=session.prompt" on every
healthy LLM call longer than 10 seconds. Verified via strace on the
running dogfood sf: bytes were actively flowing on the TLS socket
(fd 29) to the LLM provider while STUCK was being logged — the
session.prompt was never actually stuck, the watchdog was just
diagnostic-only and oblivious to stream activity.

The noOutputTimeoutMs watchdog (set to 60s for triage in commit
d80060fec) is the actual kill mechanism. It is already event-aware:
every meaningful subagent event resets the timer via armNoOutputTimer
+ isMeaningfulSubagentOutputEvent. The 10s STUCK warning was added
in commit 67e5ac9db as investigation infrastructure for the
sf-mp8e02m1-zpk903 family of bugs, but now it is just noise that
makes legitimate 30-200s LLM responses look broken.

Keeps the 10s STUCK watchdog for the three setup phases
(resourceLoader.reload, createAgentSession, bindExtensions) where
10s of silence is a real hang signal — those phases normally run in
sub-second.

Also includes:
- biome.json: bump $schema URL from 2.4.14 to 2.4.15 to match the
  current biome CLI (clears the deserialize warning)
- scripts/check-test-imports.{,test.}mjs: format + drop a useless
  regex escape that biome flagged in landed code

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-05-16 23:49:43 +02:00
parent c09cad1cf0
commit f55d490e1d
4 changed files with 265 additions and 76 deletions

View file

@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",

View file

@ -327,7 +327,14 @@ export async function runSubagent(
`[subagent:${name}] phase=session.prompt-entered taskLen=${promptTask.length} timeoutMs=${timeoutMs} noOutputMs=${noOutputTimeoutMs}\n`,
);
const promptEnteredAt = phaseStartedAt();
const promptWatchdog = phaseWatchdog("session.prompt", promptEnteredAt);
// Note: no phaseWatchdog for session.prompt. The 10s STUCK warning fired
// spuriously on healthy 3060s LLM responses (verified by strace: bytes
// were flowing on the TLS socket the whole time). The noOutputTimeoutMs
// watchdog below is the event-aware replacement — it resets on every
// meaningful subagent event (see armNoOutputTimer / isMeaningfulSubagent-
// OutputEvent) and aborts only after a true silence window. Keep the
// STUCK watchdog for setup phases (resourceLoader.reload, createAgent-
// Session, bindExtensions) where 10s of silence really is a hang signal.
try {
if (timeoutMs === 0 && noOutputTimeoutMs === 0 && !options?.signal) {
@ -336,7 +343,6 @@ export async function runSubagent(
runExtensionHooks: false,
});
await promptPromise;
clearTimeout(promptWatchdog);
logPhase("session.prompt-returned", promptEnteredAt);
cleanup();
return { ok: true, output: extractFinalOutput(), exitCode: 0 };
@ -416,7 +422,6 @@ export async function runSubagent(
});
competitors.unshift(
promptPromise.then(() => {
clearTimeout(promptWatchdog);
logPhase("session.prompt-returned", promptEnteredAt);
return {} as RaceResult;
}),
@ -425,7 +430,6 @@ export async function runSubagent(
const result = await Promise.race(competitors);
cleanup();
clearTimeout(promptWatchdog);
if (result.timedOut) {
return {

View file

@ -30,54 +30,207 @@ const root = resolve(__dirname, "..");
// ── Helpers ───────────────────────────────────────────────────────────────────
const NODE_BUILTINS = new Set([
"process", "require", "module", "exports", "console", "global", "Buffer",
"setTimeout", "setInterval", "clearTimeout", "clearInterval", "queueMicrotask",
"Array", "Object", "String", "Number", "Boolean", "Symbol", "BigInt",
"Map", "Set", "WeakMap", "WeakSet", "Promise", "Math", "JSON", "Date",
"RegExp", "Error", "EvalError", "RangeError", "ReferenceError", "SyntaxError",
"TypeError", "URIError", "AggregateError", "AbortController", "AbortSignal",
"URL", "URLSearchParams", "TextEncoder", "TextDecoder", "TransformStream",
"ReadableStream", "WritableStream", "Blob", "File", "FormData", "Headers",
"Request", "Response", "fetch", "WebSocket", "EventSource",
"setImmediate", "clearImmediate", "structuredClone", "atob", "btoa",
"navigator", "window", "document", "location", "history", "gc",
"finalizationRegistry", "Intl", "Reflect", "Proxy",
"process",
"require",
"module",
"exports",
"console",
"global",
"Buffer",
"setTimeout",
"setInterval",
"clearTimeout",
"clearInterval",
"queueMicrotask",
"Array",
"Object",
"String",
"Number",
"Boolean",
"Symbol",
"BigInt",
"Map",
"Set",
"WeakMap",
"WeakSet",
"Promise",
"Math",
"JSON",
"Date",
"RegExp",
"Error",
"EvalError",
"RangeError",
"ReferenceError",
"SyntaxError",
"TypeError",
"URIError",
"AggregateError",
"AbortController",
"AbortSignal",
"URL",
"URLSearchParams",
"TextEncoder",
"TextDecoder",
"TransformStream",
"ReadableStream",
"WritableStream",
"Blob",
"File",
"FormData",
"Headers",
"Request",
"Response",
"fetch",
"WebSocket",
"EventSource",
"setImmediate",
"clearImmediate",
"structuredClone",
"atob",
"btoa",
"navigator",
"window",
"document",
"location",
"history",
"gc",
"finalizationRegistry",
"Intl",
"Reflect",
"Proxy",
// Node.js globals (not ESM builtins but always available)
"__dirname", "__filename", "import", "imports",
"__dirname",
"__filename",
"import",
"imports",
]);
// Node.js module exports commonly used in tests without explicit import
const NODE_MODULE_GLOBALS = new Set([
"assert", "assertEq", "deepEqual", "strictEqual",
"path", "fs", "os", "url", "querystring", "crypto", "buffer",
"stream", "util", "events", "querystring", "stringDecoder",
"readline", "tty", "domain", "constants", " timers", "async_hooks",
"assert",
"assertEq",
"deepEqual",
"strictEqual",
"path",
"fs",
"os",
"url",
"querystring",
"crypto",
"buffer",
"stream",
"util",
"events",
"querystring",
"stringDecoder",
"readline",
"tty",
"domain",
"constants",
" timers",
"async_hooks",
]);
// JavaScript reserved words and keywords that should never be flagged as undeclared
const JS_KEYWORDS = new Set([
// Reserved words
"break", "case", "catch", "continue", "debugger", "default", "delete",
"do", "else", "finally", "for", "function", "if", "in", "instanceof",
"new", "return", "switch", "this", "throw", "try", "typeof", "var",
"void", "while", "with", "class", "const", "enum", "export", "extends",
"import", "super", "implements", "interface", "let", "package", "private",
"protected", "public", "static", "yield", "async", "await", "of",
"get", "set", "target", "from", "as",
"break",
"case",
"catch",
"continue",
"debugger",
"default",
"delete",
"do",
"else",
"finally",
"for",
"function",
"if",
"in",
"instanceof",
"new",
"return",
"switch",
"this",
"throw",
"try",
"typeof",
"var",
"void",
"while",
"with",
"class",
"const",
"enum",
"export",
"extends",
"import",
"super",
"implements",
"interface",
"let",
"package",
"private",
"protected",
"public",
"static",
"yield",
"async",
"await",
"of",
"get",
"set",
"target",
"from",
"as",
]);
// Common test framework globals that are always available in vitest/jest
const TEST_GLOBALS = new Set([
"describe", "it", "test", "expect", "beforeEach", "afterEach",
"beforeAll", "afterAll", "suite", "spec", "context", "given",
"when", "then", "and", "describe.skip", "it.skip", "test.skip",
"describe.only", "it.only", "test.only", "describe.each", "it.each",
"test.each", "describe.concurrent", "it.concurrent", "test.concurrent",
"vi", "vitest", "spyOn", "jest",
"describe",
"it",
"test",
"expect",
"beforeEach",
"afterEach",
"beforeAll",
"afterAll",
"suite",
"spec",
"context",
"given",
"when",
"then",
"and",
"describe.skip",
"it.skip",
"test.skip",
"describe.only",
"it.only",
"test.only",
"describe.each",
"it.each",
"test.each",
"describe.concurrent",
"it.concurrent",
"test.concurrent",
"vi",
"vitest",
"spyOn",
"jest",
]);
// Boolean and literal values that always exist
const JS_LITERALS = new Set(["true", "false", "null", "undefined", "NaN", "Infinity"]);
const JS_LITERALS = new Set([
"true",
"false",
"null",
"undefined",
"NaN",
"Infinity",
]);
/**
* Parse the statically-imported identifiers from the top of a file.
@ -92,7 +245,7 @@ function parseImports(source) {
const stripped = source
.replace(/'[^']*'/g, "'_'")
.replace(/"[^"]*"/g, '"_"')
.replace(/`[^`]*`/g, '``_`')
.replace(/`[^`]*`/g, "``_`")
.replace(/\/\/[^\n]*/g, "")
.replace(/\/\*[\s\S]*?\*\//g, "");
@ -107,16 +260,19 @@ function parseImports(source) {
const namedRe = /import\s+\{([^}]+)\}\s+from\s+["'][^"']+["']/g;
for (const m of stripped.matchAll(namedRe)) {
const content = m[1];
const items = content.split(",").map((s) => {
s = s.trim();
if (s.includes(" as ")) {
s = s.split(" as ").pop().trim();
}
if (s.startsWith("type ")) {
s = s.replace("type ", "").trim();
}
return s.replace(/\s+/g, "");
}).filter(Boolean);
const items = content
.split(",")
.map((s) => {
s = s.trim();
if (s.includes(" as ")) {
s = s.split(" as ").pop().trim();
}
if (s.startsWith("type ")) {
s = s.replace("type ", "").trim();
}
return s.replace(/\s+/g, "");
})
.filter(Boolean);
items.forEach((id) => named.add(id));
}
@ -143,7 +299,7 @@ function collectReferences(source) {
const stripped = source
.replace(/'[^']*'/g, "'_'")
.replace(/"[^"]*"/g, '"_"')
.replace(/`[^`]*`/g, '``_`')
.replace(/`[^`]*`/g, "``_`")
.replace(/\/\/[^\n]*/g, "")
.replace(/\/\*[\s\S]*?\*\//g, "");
@ -154,7 +310,7 @@ function collectReferences(source) {
// Replace member expressions and calls with placeholders to isolate identifiers
const cleaned = stripped
.replace(/\b[a-zA-Z_$][\w$]*\.[a-zA-Z_$][\w$]*(?=\s*[\[.\(])/g, "_")
.replace(/\b[a-zA-Z_$][\w$]*\.[a-zA-Z_$][\w$]*(?=\s*[[.(])/g, "_")
.replace(/\[[^\]]+\]/g, "[_]");
// Now collect bare identifiers (word boundaries)
@ -208,13 +364,22 @@ function checkFile(filePath, source) {
// - Variable names assigned in try/catch or before import
// - Playwright/MCP test helpers
const COMMON_TEST_LOCALS = new Set([
"canLaunchChromium", "isAvailable", "hasSupport", "isSupported",
"describeOrSkip", "itOrSkip", "skipIf", "runIf",
"jiti", "interopDefault", "debug",
"canLaunchChromium",
"isAvailable",
"hasSupport",
"isSupported",
"describeOrSkip",
"itOrSkip",
"skipIf",
"runIf",
"jiti",
"interopDefault",
"debug",
]);
const filteredUndeclared = [...undeclared].filter(
(id) => !COMMON_TEST_LOCALS.has(id) &&
(id) =>
!COMMON_TEST_LOCALS.has(id) &&
!id.match(/^(can|has|is|should|will)[A-Z]/) &&
!id.match(/^(testBrowser|chromiumInstance|pageInstance)$/),
);
@ -251,9 +416,12 @@ function findTestFiles(dir) {
}
results.push(...findTestFiles(full));
} else if (
(entry.name.endsWith(".test.js") || entry.name.endsWith(".test.mjs") ||
entry.name.endsWith(".test.ts") || entry.name.endsWith(".spec.js") ||
entry.name.endsWith(".spec.mjs") || entry.name.endsWith(".spec.ts")) &&
(entry.name.endsWith(".test.js") ||
entry.name.endsWith(".test.mjs") ||
entry.name.endsWith(".test.ts") ||
entry.name.endsWith(".spec.js") ||
entry.name.endsWith(".spec.mjs") ||
entry.name.endsWith(".spec.ts")) &&
!entry.name.includes("node_modules")
) {
results.push(full);
@ -271,9 +439,7 @@ const verbose = args.includes("--verbose");
// If specific paths are provided, use those instead of scanning
// Filter out only flag args, keep everything else as a file path
const explicitPaths = args.filter(
(a) => !a.startsWith("--"),
);
const explicitPaths = args.filter((a) => !a.startsWith("--"));
let allFiles = [];
if (explicitPaths.length > 0) {
@ -293,7 +459,9 @@ if (explicitPaths.length > 0) {
process.exit(1);
}
if (verbose) {
console.error(`[check-test-imports] explicit mode: ${allFiles.length} file(s)`);
console.error(
`[check-test-imports] explicit mode: ${allFiles.length} file(s)`,
);
}
} else {
const scanDirs = [
@ -341,4 +509,4 @@ if (jsonOut) {
}
}
process.exit(issues.length > 0 ? 1 : 0);
process.exit(issues.length > 0 ? 1 : 0);

View file

@ -42,14 +42,22 @@ function run(args) {
/** Create a temp test file, return absolute path. */
function makeTempFile(id, content) {
try { mkdirSync(tmpDir, { recursive: true }); } catch { /* */ }
try {
mkdirSync(tmpDir, { recursive: true });
} catch {
/* */
}
const file = resolve(tmpDir, `regression-${id}.test.mjs`);
writeFileSync(file, content);
return file;
}
function cleanupTempDir() {
try { rmdirSync(tmpDir); } catch { /* ignore */ }
try {
rmdirSync(tmpDir);
} catch {
/* ignore */
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
@ -89,7 +97,9 @@ if (!existsSync(realFile)) {
// ── Test 2: Detects undeclared identifier in anti-pattern file ───────────────
console.log("\n[2] Detects undeclared identifier in anti-pattern file");
const file2 = makeTempFile("regression-02", `
const file2 = makeTempFile(
"regression-02",
`
// Regression test — generated by check-test-imports.test.mjs
// DO NOT COMMIT
import { describe, it, expect } from "vitest";
@ -100,7 +110,8 @@ describe("test", () => {
undeclaredFn();
});
});
`);
`,
);
const result2 = run([file2, "--json"]);
try {
const parsed = JSON.parse(result2.stdout);
@ -118,7 +129,9 @@ try {
// ── Test 3: Clean file with namespace import passes ─────────────────────────
console.log("\n[3] Clean file with namespace import + local vars passes");
const file3 = makeTempFile("regression-03", `
const file3 = makeTempFile(
"regression-03",
`
// Clean: namespace import + only local variables
// DO NOT COMMIT
import { describe, it, expect } from "vitest";
@ -131,21 +144,21 @@ describe("test", () => {
expect(myLocalVar).toBeDefined();
});
});
`);
`,
);
const result3 = run([file3, "--json"]);
try {
const parsed = JSON.parse(result3.stdout);
assert(
parsed.count === 0,
`Clean file: count ${parsed.count}`,
);
assert(parsed.count === 0, `Clean file: count ${parsed.count}`);
} catch {
assert(false, `Script should not throw on clean file`);
}
// ── Test 4: Local const/let declarations not flagged ───────────────────────
console.log("\n[4] Local const/let declarations not flagged");
const file4 = makeTempFile("regression-04", `
const file4 = makeTempFile(
"regression-04",
`
// Local variable declarations should not be flagged
// DO NOT COMMIT
import { describe, it, expect } from "vitest";
@ -159,7 +172,8 @@ describe("test", () => {
expect(myLocalVar).toBeDefined();
});
});
`);
`,
);
const result4 = run([file4, "--json"]);
try {
const parsed = JSON.parse(result4.stdout);
@ -173,7 +187,9 @@ try {
// ── Test 5: Short lowercase variables not flagged ───────────────────────────
console.log("\n[5] Short lowercase variables not flagged");
const file5 = makeTempFile("regression-05", `
const file5 = makeTempFile(
"regression-05",
`
// Short lowercase vars like i, fn are common test locals
// DO NOT COMMIT
import { describe, it, expect } from "vitest";
@ -186,7 +202,8 @@ describe("test", () => {
expect(j).toBeGreaterThan(0);
});
});
`);
`,
);
const result5 = run([file5, "--json"]);
try {
const parsed = JSON.parse(result5.stdout);
@ -203,4 +220,4 @@ try {
console.log(`\n${"-".repeat(50)}`);
console.log(`Results: ${passed} passed, ${failed} failed`);
cleanupTempDir();
process.exit(failed > 0 ? 1 : 0);
process.exit(failed > 0 ? 1 : 0);