From d8be026a2b83bba3e7ec19c3ba512dc693606759 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Tue, 11 Aug 2026 12:12:15 -0400 Subject: [PATCH] fix: todos scan task ballooned to 2.5MB and analysis produced no output The todos pre-scan walked .output/ (Nitro) and .vercel/ (Vercel) build dirs, flagging 119 of 124 candidates inside minified bundles (single lines up to 162KB). buildTodosScanTask embedded full candidate lines in the task prompt, producing a 2.5MB prompt on freno-dev; the analysis agent settled with ok:true + empty text + no findings.md, verify failed, and resume re-ran the same oversized prompt and failed identically. - scope: exclude .output/.vercel/.netlify (shared by all checks) - todos: truncate candidate code at 160 chars in the prompt + fallback - agent-runner: a session settling with no text and no observed message/ tool events now fails the run loudly instead of reporting ok:true - agent prompts: add the three dirs to each skip list - tests: excluded-dir scan, prompt truncation, emptySessionError cases --- agents/deep-modules.md | 5 ++-- agents/defensive-guards.md | 5 ++-- agents/scanner.md | 5 ++-- agents/todos.md | 5 ++-- src/agent-runner.ts | 51 +++++++++++++++++++++++++++++------- src/checks/scope.ts | 7 +++++ src/checks/todos.ts | 19 ++++++++++++-- tests/agent-runner.test.ts | 53 +++++++++++++++++++++++++++++++++++++- tests/todos.test.ts | 45 ++++++++++++++++++++++++++++++++ 9 files changed, 174 insertions(+), 21 deletions(-) diff --git a/agents/deep-modules.md b/agents/deep-modules.md index cbe18fd..664361b 100644 --- a/agents/deep-modules.md +++ b/agents/deep-modules.md @@ -71,8 +71,9 @@ noise the user cannot act on. ## Skip (directory names — never descend into) -`.cache`, `.git`, `.hg`, `.idea`, `.next`, `.nuxt`, `.pygienium`, `.ralpi`, -`.svelte-kit`, `.svn`, `.turbo`, `.vscode`, `__pycache__`, `build`, `coverage`, +`.cache`, `.git`, `.hg`, `.idea`, `.netlify`, `.next`, `.nuxt`, `.output`, +`.pygienium`, `.ralpi`, `.svelte-kit`, `.svn`, `.turbo`, `.vercel`, `.vscode`, +`__pycache__`, `build`, `coverage`, `dist`, `node_modules`, `out`, `vendor`, `venv` (and `.venv`) ## Skip (file patterns) diff --git a/agents/defensive-guards.md b/agents/defensive-guards.md index 8e5bca8..34c9454 100644 --- a/agents/defensive-guards.md +++ b/agents/defensive-guards.md @@ -87,8 +87,9 @@ noise the user cannot act on. ## Skip (directory names — never descend into) -`.cache`, `.git`, `.hg`, `.idea`, `.next`, `.nuxt`, `.pygienium`, `.ralpi`, -`.svelte-kit`, `.svn`, `.turbo`, `.vscode`, `__pycache__`, `build`, `coverage`, +`.cache`, `.git`, `.hg`, `.idea`, `.netlify`, `.next`, `.nuxt`, `.output`, +`.pygienium`, `.ralpi`, `.svelte-kit`, `.svn`, `.turbo`, `.vercel`, `.vscode`, +`__pycache__`, `build`, `coverage`, `dist`, `node_modules`, `out`, `vendor`, `venv` (and `.venv`) ## Skip (file patterns) diff --git a/agents/scanner.md b/agents/scanner.md index 5d443f3..2ff31a2 100644 --- a/agents/scanner.md +++ b/agents/scanner.md @@ -36,8 +36,9 @@ noise the user cannot act on. ## Skip (directory names — never descend into) -`.cache`, `.git`, `.hg`, `.idea`, `.next`, `.nuxt`, `.pygienium`, `.ralpi`, -`.svelte-kit`, `.svn`, `.turbo`, `.vscode`, `__pycache__`, `build`, `coverage`, +`.cache`, `.git`, `.hg`, `.idea`, `.netlify`, `.next`, `.nuxt`, `.output`, +`.pygienium`, `.ralpi`, `.svelte-kit`, `.svn`, `.turbo`, `.vercel`, `.vscode`, +`__pycache__`, `build`, `coverage`, `dist`, `node_modules`, `out`, `vendor`, `venv` (and `.venv`) ## Skip (file patterns) diff --git a/agents/todos.md b/agents/todos.md index fa57e1a..2e8e389 100644 --- a/agents/todos.md +++ b/agents/todos.md @@ -100,8 +100,9 @@ noise the user cannot act on. ## Skip (directory names — never descend into) -`.cache`, `.git`, `.hg`, `.idea`, `.next`, `.nuxt`, `.pygienium`, `.ralpi`, -`.svelte-kit`, `.svn`, `.turbo`, `.vscode`, `__pycache__`, `build`, `coverage`, +`.cache`, `.git`, `.hg`, `.idea`, `.netlify`, `.next`, `.nuxt`, `.output`, +`.pygienium`, `.ralpi`, `.svelte-kit`, `.svn`, `.turbo`, `.vercel`, `.vscode`, +`__pycache__`, `build`, `coverage`, `dist`, `node_modules`, `out`, `vendor`, `venv` (and `.venv`) ## Skip (file patterns) diff --git a/src/agent-runner.ts b/src/agent-runner.ts index 2a182ae..c20eb88 100644 --- a/src/agent-runner.ts +++ b/src/agent-runner.ts @@ -256,16 +256,15 @@ async function runSessionToCompletion( unsubscribe(); // Surface session errors that didn't throw but left no useful output. // A session ending with stopReason "error" and no text means the model - // call failed silently — treat that as a failed run, not ok:true. - if (acc.errorMessage) { - return { ok: false, text: acc.text, error: acc.errorMessage }; - } - if (!acc.text.trim() && acc.stopReason === "error") { - return { - ok: false, - text: acc.text, - error: "sub-agent session ended in error with no output.", - }; + // call failed silently — treat that as a failed run, not ok:true. A + // session that settled with NO text and NO observed events means the + // model never produced anything at all (dead provider stream, failed + // start) — reporting that as a successful analysis would let an empty + // scan masquerade as a clean one, and the check's verify hook would + // fail only later with a confusing "artifact missing" error. + const sessionError = emptySessionError(acc); + if (sessionError) { + return { ok: false, text: acc.text, error: sessionError }; } return { ok: true, text: acc.text }; } catch (err) { @@ -287,12 +286,34 @@ async function runSessionToCompletion( export interface SessionEventAccumulator { /** Joined assistant text seen so far (text_delta stream). */ text: string; + /** Whether any assistant-message or tool event was observed at all. */ + sawMessage: boolean; /** stopReason of the final assistant message, when reported. */ stopReason?: string; /** errorMessage of the final assistant message, when reported. */ errorMessage?: string; } +/** + * Classify a settled session's capture: `undefined` when the result is a + * legitimate (possibly empty-text) outcome, else the error that should fail + * the run. Kept pure so the decision is unit-testable without a session. + */ +export function emptySessionError( + acc: SessionEventAccumulator, +): string | undefined { + if (acc.errorMessage) return acc.errorMessage; + if (!acc.text.trim()) { + if (acc.stopReason === "error") { + return "sub-agent session ended in error with no output."; + } + if (!acc.sawMessage) { + return "sub-agent session settled with no output — no assistant message or tool activity was observed. Check model/provider connectivity, then resume."; + } + } + return undefined; +} + /** * Interpret one session event into the running accumulator and forward the * stream-driving events to the chat. @@ -313,6 +334,16 @@ export function applySessionEvent( ): void { if (!event) return; try { + // Any message or tool event means the session actually ran — a settled + // capture with none of these is a dead session, not an empty scan. + if ( + event.type === "message_update" || + event.type === "message_end" || + event.type === "tool_execution_start" || + event.type === "tool_execution_end" + ) { + acc.sawMessage = true; + } if ( event.type === "message_update" && event.assistantMessageEvent?.type === "text_delta" diff --git a/src/checks/scope.ts b/src/checks/scope.ts index e81d9d8..5a3ef5a 100644 --- a/src/checks/scope.ts +++ b/src/checks/scope.ts @@ -56,6 +56,13 @@ export const SCOPE_EXCLUDE_DIRS: ReadonlySet = new Set([ ".nuxt", ".turbo", ".svelte-kit", + // Framework/deploy build output: Nitro, Vercel, Netlify artifact dirs. + // Generated bundles dominate a naive marker/stub scan (freno-dev alone + // flagged 119 of 124 candidates inside `.output/`/`.vercel/` minified + // bundles) and must never feed the pre-scan or the agent's file walk. + ".output", + ".vercel", + ".netlify", "__pycache__", ".venv", "venv", diff --git a/src/checks/todos.ts b/src/checks/todos.ts index 6cbe949..97e66bd 100644 --- a/src/checks/todos.ts +++ b/src/checks/todos.ts @@ -124,6 +124,21 @@ const MAX_CANDIDATES = 500; const FALLBACK_CAP = 16; /** Candidate list embedded in the live prompt is truncated at this many. */ const PROMPT_CAP = 40; +/** + * Max characters of a candidate's code line embedded in the task prompt. + * Generated/bundled single lines can be hundreds of KB (e.g. minified assets + * sneaking past scope); embedding them wholesale balloons the task to + * megabytes and chokes the sub-agent. `path:line` plus a truncated prefix is + * enough to classify — the agent can read the file for full context. + */ +const CODE_DISPLAY_CAP = 160; + +/** Truncate a candidate's code line for prompt embedding. */ +function displayCode(code: string): string { + return code.length > CODE_DISPLAY_CAP + ? `${code.slice(0, CODE_DISPLAY_CAP)}…` + : code; +} /** Extract the declared function name from a header line, when present. */ function headerName(line: string): string | undefined { @@ -369,7 +384,7 @@ function renderFindings( parts.push(`## ${title}`); list.slice(0, FALLBACK_CAP).forEach((c, i) => { parts.push( - `### ${i + 1}. ${relative(cwd, c.path)}:${c.line} — ${c.code} | snippet: ${c.snippet} | context: ${c.context || relative(cwd, c.path)}`, + `### ${i + 1}. ${relative(cwd, c.path)}:${c.line} — ${displayCode(c.code)} | snippet: ${c.snippet} | context: ${c.context || relative(cwd, c.path)}`, ); }); if (list.length > FALLBACK_CAP) { @@ -434,7 +449,7 @@ export async function buildTodosScanTask( const candidateList = candidates .slice(0, PROMPT_CAP) - .map((c) => ` - ${relative(cwd, c.path)}:${c.line} [${c.kind}] ${c.code}`); + .map((c) => ` - ${relative(cwd, c.path)}:${c.line} [${c.kind}] ${displayCode(c.code)}`); if (candidates.length > PROMPT_CAP) { candidateList.push( ` - … and ${candidates.length - PROMPT_CAP} more (truncated for brevity)`, diff --git a/tests/agent-runner.test.ts b/tests/agent-runner.test.ts index 45251b3..4343552 100644 --- a/tests/agent-runner.test.ts +++ b/tests/agent-runner.test.ts @@ -10,12 +10,13 @@ import { describe, expect, it } from "bun:test"; import { applySessionEvent, + emptySessionError, type SessionEventAccumulator, } from "../src/agent-runner.js"; import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent"; function fresh(): SessionEventAccumulator { - return { text: "" }; + return { text: "", sawMessage: false }; } /** Build a typed-as-unknown event so malformed shapes compile in tests. */ @@ -134,4 +135,54 @@ describe("applySessionEvent", () => { expect(forwarded).toEqual([]); expect(acc.text).toBe("x"); }); + + it("marks sawMessage when any message or tool event is observed", () => { + const fromUpdate = fresh(); + applySessionEvent(fromUpdate, event({ type: "message_update" })); + expect(fromUpdate.sawMessage).toBe(true); + + const fromEnd = fresh(); + applySessionEvent(fromEnd, event({ type: "message_end", message: null })); + expect(fromEnd.sawMessage).toBe(true); + + const fromTool = fresh(); + applySessionEvent(fromTool, event({ type: "tool_execution_start" })); + expect(fromTool.sawMessage).toBe(true); + }); +}); + +describe("emptySessionError", () => { + it("fails a session that settled with no text and no observed events", () => { + expect(emptySessionError({ text: "", sawMessage: false })).toContain( + "no output", + ); + }); + + it("fails an empty session whose final message reported stopReason error", () => { + expect( + emptySessionError({ text: "", sawMessage: true, stopReason: "error" }), + ).toBe("sub-agent session ended in error with no output."); + }); + + it("surfaces a recorded errorMessage regardless of text", () => { + expect( + emptySessionError({ + text: "partial output", + sawMessage: true, + errorMessage: "upstream 529", + }), + ).toBe("upstream 529"); + }); + + it("accepts an empty-text session that demonstrably ran (tool activity)", () => { + expect( + emptySessionError({ text: "", sawMessage: true, stopReason: "end_turn" }), + ).toBeUndefined(); + }); + + it("accepts any session with text", () => { + expect( + emptySessionError({ text: "report", sawMessage: false }), + ).toBeUndefined(); + }); }); diff --git a/tests/todos.test.ts b/tests/todos.test.ts index 7470e2c..4da877f 100644 --- a/tests/todos.test.ts +++ b/tests/todos.test.ts @@ -205,6 +205,30 @@ describe("detectTodoStubs", () => { loud.some((h) => h.path.endsWith("fetch.rs") && h.snippet === "todo!("), ).toBe(true); }); + + it("never descends into build/deploy output directories", async () => { + // Generated bundles under framework build dirs must not feed the + // pre-scan: they dominate candidate counts with minified noise (the + // freno-dev failure flagged 119 of 124 candidates inside + // `.output`/`.vercel` bundles, ballooning the scan task to 2.5 MB). + for (const rel of [ + join(".output", "public", "bundle.js"), + join(".vercel", "output", "static", "app.js"), + join(".netlify", "functions", "bundle.js"), + ]) { + const full = join(dir, rel); + await mkdir(join(full, ".."), { recursive: true }); + await writeFile( + full, + "// TODO: bundle placeholder\nfunction f(){ return 0; }\nthrow new Error('not implemented');\n", + "utf8", + ); + } + const hits = await detectTodoStubs(dir); + expect( + hits.filter((h) => /(?:\.output|\.vercel|\.netlify)[/\\]/.test(h.path)), + ).toHaveLength(0); + }); }); describe("todos check", () => { @@ -334,4 +358,25 @@ describe("todos check", () => { ); expect(task).toContain("| new: 0 | resolved: 4 |"); }); + + it("truncates giant single-line candidates so the task prompt stays bounded", async () => { + // A minified/generated single line can be hundreds of KB; embedding it + // wholesale ballooned the freno-dev task to 2.5 MB and choked the + // analysis agent. The task must carry a truncated prefix, never the + // full line. + const long = `// TODO: ${"x".repeat(400)}`; + await writeFile( + join(cwd, "huge.ts"), + `${long}\nexport function f() { return 0; }\n`, + "utf8", + ); + const task = await buildTodosScanTask(cwd, { + cwd, + target: cwd, + fix: false, + rest: [], + }); + expect(task).not.toContain("x".repeat(400)); + expect(task).toContain("…"); + }); });