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
This commit is contained in:
2026-08-11 12:12:15 -04:00
parent 595ce48f3c
commit d8be026a2b
9 changed files with 174 additions and 21 deletions

View File

@@ -71,8 +71,9 @@ noise the user cannot act on.
## Skip (directory names — never descend into) ## Skip (directory names — never descend into)
`.cache`, `.git`, `.hg`, `.idea`, `.next`, `.nuxt`, `.pygienium`, `.ralpi`, `.cache`, `.git`, `.hg`, `.idea`, `.netlify`, `.next`, `.nuxt`, `.output`,
`.svelte-kit`, `.svn`, `.turbo`, `.vscode`, `__pycache__`, `build`, `coverage`, `.pygienium`, `.ralpi`, `.svelte-kit`, `.svn`, `.turbo`, `.vercel`, `.vscode`,
`__pycache__`, `build`, `coverage`,
`dist`, `node_modules`, `out`, `vendor`, `venv` (and `.venv`) `dist`, `node_modules`, `out`, `vendor`, `venv` (and `.venv`)
## Skip (file patterns) ## Skip (file patterns)

View File

@@ -87,8 +87,9 @@ noise the user cannot act on.
## Skip (directory names — never descend into) ## Skip (directory names — never descend into)
`.cache`, `.git`, `.hg`, `.idea`, `.next`, `.nuxt`, `.pygienium`, `.ralpi`, `.cache`, `.git`, `.hg`, `.idea`, `.netlify`, `.next`, `.nuxt`, `.output`,
`.svelte-kit`, `.svn`, `.turbo`, `.vscode`, `__pycache__`, `build`, `coverage`, `.pygienium`, `.ralpi`, `.svelte-kit`, `.svn`, `.turbo`, `.vercel`, `.vscode`,
`__pycache__`, `build`, `coverage`,
`dist`, `node_modules`, `out`, `vendor`, `venv` (and `.venv`) `dist`, `node_modules`, `out`, `vendor`, `venv` (and `.venv`)
## Skip (file patterns) ## Skip (file patterns)

View File

@@ -36,8 +36,9 @@ noise the user cannot act on.
## Skip (directory names — never descend into) ## Skip (directory names — never descend into)
`.cache`, `.git`, `.hg`, `.idea`, `.next`, `.nuxt`, `.pygienium`, `.ralpi`, `.cache`, `.git`, `.hg`, `.idea`, `.netlify`, `.next`, `.nuxt`, `.output`,
`.svelte-kit`, `.svn`, `.turbo`, `.vscode`, `__pycache__`, `build`, `coverage`, `.pygienium`, `.ralpi`, `.svelte-kit`, `.svn`, `.turbo`, `.vercel`, `.vscode`,
`__pycache__`, `build`, `coverage`,
`dist`, `node_modules`, `out`, `vendor`, `venv` (and `.venv`) `dist`, `node_modules`, `out`, `vendor`, `venv` (and `.venv`)
## Skip (file patterns) ## Skip (file patterns)

View File

@@ -100,8 +100,9 @@ noise the user cannot act on.
## Skip (directory names — never descend into) ## Skip (directory names — never descend into)
`.cache`, `.git`, `.hg`, `.idea`, `.next`, `.nuxt`, `.pygienium`, `.ralpi`, `.cache`, `.git`, `.hg`, `.idea`, `.netlify`, `.next`, `.nuxt`, `.output`,
`.svelte-kit`, `.svn`, `.turbo`, `.vscode`, `__pycache__`, `build`, `coverage`, `.pygienium`, `.ralpi`, `.svelte-kit`, `.svn`, `.turbo`, `.vercel`, `.vscode`,
`__pycache__`, `build`, `coverage`,
`dist`, `node_modules`, `out`, `vendor`, `venv` (and `.venv`) `dist`, `node_modules`, `out`, `vendor`, `venv` (and `.venv`)
## Skip (file patterns) ## Skip (file patterns)

View File

@@ -256,16 +256,15 @@ async function runSessionToCompletion(
unsubscribe(); unsubscribe();
// Surface session errors that didn't throw but left no useful output. // Surface session errors that didn't throw but left no useful output.
// A session ending with stopReason "error" and no text means the model // A session ending with stopReason "error" and no text means the model
// call failed silently — treat that as a failed run, not ok:true. // call failed silently — treat that as a failed run, not ok:true. A
if (acc.errorMessage) { // session that settled with NO text and NO observed events means the
return { ok: false, text: acc.text, error: acc.errorMessage }; // model never produced anything at all (dead provider stream, failed
} // start) — reporting that as a successful analysis would let an empty
if (!acc.text.trim() && acc.stopReason === "error") { // scan masquerade as a clean one, and the check's verify hook would
return { // fail only later with a confusing "artifact missing" error.
ok: false, const sessionError = emptySessionError(acc);
text: acc.text, if (sessionError) {
error: "sub-agent session ended in error with no output.", return { ok: false, text: acc.text, error: sessionError };
};
} }
return { ok: true, text: acc.text }; return { ok: true, text: acc.text };
} catch (err) { } catch (err) {
@@ -287,12 +286,34 @@ async function runSessionToCompletion(
export interface SessionEventAccumulator { export interface SessionEventAccumulator {
/** Joined assistant text seen so far (text_delta stream). */ /** Joined assistant text seen so far (text_delta stream). */
text: string; text: string;
/** Whether any assistant-message or tool event was observed at all. */
sawMessage: boolean;
/** stopReason of the final assistant message, when reported. */ /** stopReason of the final assistant message, when reported. */
stopReason?: string; stopReason?: string;
/** errorMessage of the final assistant message, when reported. */ /** errorMessage of the final assistant message, when reported. */
errorMessage?: string; 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 * Interpret one session event into the running accumulator and forward the
* stream-driving events to the chat. * stream-driving events to the chat.
@@ -313,6 +334,16 @@ export function applySessionEvent(
): void { ): void {
if (!event) return; if (!event) return;
try { 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 ( if (
event.type === "message_update" && event.type === "message_update" &&
event.assistantMessageEvent?.type === "text_delta" event.assistantMessageEvent?.type === "text_delta"

View File

@@ -56,6 +56,13 @@ export const SCOPE_EXCLUDE_DIRS: ReadonlySet<string> = new Set([
".nuxt", ".nuxt",
".turbo", ".turbo",
".svelte-kit", ".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__", "__pycache__",
".venv", ".venv",
"venv", "venv",

View File

@@ -124,6 +124,21 @@ const MAX_CANDIDATES = 500;
const FALLBACK_CAP = 16; const FALLBACK_CAP = 16;
/** Candidate list embedded in the live prompt is truncated at this many. */ /** Candidate list embedded in the live prompt is truncated at this many. */
const PROMPT_CAP = 40; 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. */ /** Extract the declared function name from a header line, when present. */
function headerName(line: string): string | undefined { function headerName(line: string): string | undefined {
@@ -369,7 +384,7 @@ function renderFindings(
parts.push(`## ${title}`); parts.push(`## ${title}`);
list.slice(0, FALLBACK_CAP).forEach((c, i) => { list.slice(0, FALLBACK_CAP).forEach((c, i) => {
parts.push( 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) { if (list.length > FALLBACK_CAP) {
@@ -434,7 +449,7 @@ export async function buildTodosScanTask(
const candidateList = candidates const candidateList = candidates
.slice(0, PROMPT_CAP) .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) { if (candidates.length > PROMPT_CAP) {
candidateList.push( candidateList.push(
` - … and ${candidates.length - PROMPT_CAP} more (truncated for brevity)`, ` - … and ${candidates.length - PROMPT_CAP} more (truncated for brevity)`,

View File

@@ -10,12 +10,13 @@
import { describe, expect, it } from "bun:test"; import { describe, expect, it } from "bun:test";
import { import {
applySessionEvent, applySessionEvent,
emptySessionError,
type SessionEventAccumulator, type SessionEventAccumulator,
} from "../src/agent-runner.js"; } from "../src/agent-runner.js";
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent"; import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
function fresh(): SessionEventAccumulator { function fresh(): SessionEventAccumulator {
return { text: "" }; return { text: "", sawMessage: false };
} }
/** Build a typed-as-unknown event so malformed shapes compile in tests. */ /** Build a typed-as-unknown event so malformed shapes compile in tests. */
@@ -134,4 +135,54 @@ describe("applySessionEvent", () => {
expect(forwarded).toEqual([]); expect(forwarded).toEqual([]);
expect(acc.text).toBe("x"); 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();
});
}); });

View File

@@ -205,6 +205,30 @@ describe("detectTodoStubs", () => {
loud.some((h) => h.path.endsWith("fetch.rs") && h.snippet === "todo!("), loud.some((h) => h.path.endsWith("fetch.rs") && h.snippet === "todo!("),
).toBe(true); ).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", () => { describe("todos check", () => {
@@ -334,4 +358,25 @@ describe("todos check", () => {
); );
expect(task).toContain("| new: 0 | resolved: 4 |"); 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("…");
});
}); });