feat(coding-agent): identify extensions in crash stacks

This commit is contained in:
Armin Ronacher 2026-09-21 01:00:43 +02:00
parent f5c946480c
commit 63787ee6ba
5 changed files with 212 additions and 2 deletions

View file

@ -9,6 +9,7 @@
### Fixed
- Fixed idle prompt-cache warming rebuilding expired caches when its timer or an extension decision is delayed.
- Improved crash diagnostics with hints identifying loaded extensions that appear in the stack trace.
## [0.86.1] - 2026-09-20

View file

@ -1,6 +1,7 @@
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { getAgentDir, VERSION } from "../config.ts";
import type { Extension } from "./extensions/types.ts";
export interface CrashRecord {
timestamp: string;
@ -42,6 +43,82 @@ function writeCrashLog(records: readonly CrashRecord[], path: string): void {
writeFileSync(path, `${JSON.stringify(records, null, 2)}\n`);
}
type ExtensionStackMetadata = Pick<Extension, "path" | "resolvedPath" | "sourceInfo">;
function normalizeStackPath(value: string): string {
return value.replace(/\\/g, "/").replace(/\/+$/u, "");
}
function stackContainsPath(stack: string, targetPath: string, includeDescendants: boolean): boolean {
const target = normalizeStackPath(targetPath);
if (!target || target.startsWith("<")) return false;
const caseInsensitive = /^[a-z]:\//iu.test(target);
const haystack = caseInsensitive ? stack.toLowerCase() : stack;
const needle = caseInsensitive ? target.toLowerCase() : target;
if (includeDescendants) return haystack.includes(`${needle}/`);
let index = haystack.indexOf(needle);
while (index !== -1) {
const next = haystack[index + needle.length];
if (next === undefined || next === ":" || next === ")" || /\s/u.test(next)) return true;
index = haystack.indexOf(needle, index + needle.length);
}
return false;
}
/** Find loaded extensions with source files in a stack trace. */
export function findExtensionStackMatches(
stack: string | undefined,
extensions: readonly ExtensionStackMetadata[],
): string[] {
if (!stack) return [];
const normalizedStack = stack
.split("\n")
.slice(1)
.filter((line) => /^\s+at\s/u.test(line))
.map((line) => {
try {
return decodeURI(line);
} catch {
return line;
}
})
.join("\n")
.replace(/\\/g, "/");
const matches: string[] = [];
const seen = new Set<string>();
for (const extension of extensions) {
const resolvedPath = normalizeStackPath(extension.resolvedPath);
const singleFilePackage =
extension.sourceInfo.origin === "package" &&
!/^(?:npm:|git:|https?:\/\/|ssh:\/\/)/u.test(extension.sourceInfo.source) &&
/\.[cm]?[jt]s$/u.test(extension.sourceInfo.source);
const packageRoot =
extension.sourceInfo.origin === "package" && !singleFilePackage && extension.sourceInfo.baseDir
? extension.sourceInfo.baseDir
: undefined;
const slashIndex = resolvedPath.lastIndexOf("/");
const directoryEntry = /\/index\.[cm]?[jt]s$/u.test(resolvedPath);
const matched = packageRoot
? stackContainsPath(normalizedStack, packageRoot, true)
: directoryEntry && slashIndex !== -1
? stackContainsPath(normalizedStack, resolvedPath.slice(0, slashIndex), true)
: stackContainsPath(normalizedStack, resolvedPath, false);
if (!matched) continue;
const label =
extension.sourceInfo.origin === "package" && extension.sourceInfo.source
? extension.sourceInfo.source
: extension.path;
if (!seen.has(label)) {
seen.add(label);
matches.push(label);
}
}
return matches;
}
/** Best-effort persistence for callers that are already crashing. */
export function recordCrash(
crash: { kind: CrashRecord["kind"]; error: unknown; sessionFile?: string; cwd: string },

View file

@ -73,7 +73,7 @@ import {
detectCacheMiss,
} from "../../core/cache-stats.ts";
import { formatCacheWarmingStatus, formatCacheWarmingUsage } from "../../core/cache-warmer.ts";
import { recordCrash, takeUnnotifiedCrash } from "../../core/crash-log.ts";
import { findExtensionStackMatches, recordCrash, takeUnnotifiedCrash } from "../../core/crash-log.ts";
import { DEFAULT_THINKING_LEVEL, THINKING_LEVEL_OPTIONS } from "../../core/defaults.ts";
import type {
AutocompleteProviderFactory,
@ -271,6 +271,23 @@ function isDeadTerminalError(error: unknown): boolean {
return code !== undefined && DEAD_TERMINAL_ERROR_CODES.has(code);
}
export function formatCrashExtensionHint(extensionMatches: readonly string[] | undefined): string | undefined {
const matches = Array.isArray(extensionMatches)
? extensionMatches.filter((match): match is string => typeof match === "string" && match.length > 0)
: [];
if (matches.length === 0) return undefined;
const quoted = matches.map((match) => `\`${match}\``);
const labels =
quoted.length === 1
? quoted[0]
: quoted.length === 2
? quoted.join(" and ")
: `${quoted.slice(0, -1).join(", ")}, and ${quoted[quoted.length - 1]}`;
const noun = matches.length === 1 ? "extension" : "extensions";
const pronoun = matches.length === 1 ? "it" : "them";
return `A stack frame came from loaded ${noun} ${labels}, which may be involved. Try disabling ${pronoun} with \`${APP_NAME} config\`, or run \`${APP_NAME} -ne\` to confirm.`;
}
const ANTHROPIC_SUBSCRIPTION_AUTH_WARNING =
"Anthropic subscription auth is active. Third-party harness usage draws from extra usage and is billed per token, not your Claude plan limits. Manage extra usage at https://claude.ai/settings/usage. Disable this warning in /settings.";
@ -2029,6 +2046,10 @@ export class InteractiveMode {
private async handleFatalRuntimeError(prefix: string, error: unknown): Promise<never> {
const message = error instanceof Error ? error.message : String(error);
this.showError(`${prefix}: ${message}`);
const extensionHint = this.getCrashExtensionHint(error);
if (extensionHint) {
this.chatContainer.addChild(new Text(theme.fg("warning", extensionHint), this.outputPad, 0));
}
if (this.recordCrash("fatal_error", error)) {
this.chatContainer.addChild(new Text(theme.fg("muted", this.crashReportInstructions()), this.outputPad, 0));
}
@ -2037,6 +2058,19 @@ export class InteractiveMode {
process.exit(1);
}
private getCrashExtensionHint(error: unknown): string | undefined {
try {
return formatCrashExtensionHint(
findExtensionStackMatches(
error instanceof Error ? error.stack : undefined,
this.session.resourceLoader.getExtensions().extensions,
),
);
} catch {
return undefined;
}
}
/** Persist a crash so the next start can point the user at `/bug`. Returns false when nothing was written. */
private recordCrash(kind: "uncaught_exception" | "fatal_error", error: unknown): boolean {
try {
@ -4142,6 +4176,8 @@ export class InteractiveMode {
} catch {}
console.error(`${APP_NAME} exiting due to uncaughtException:`);
console.error(error);
const extensionHint = this.getCrashExtensionHint(error);
if (extensionHint) console.error(`\n${extensionHint}`);
if (this.recordCrash("uncaught_exception", error)) {
console.error(`\n${this.crashReportInstructions()}`);
}

View file

@ -0,0 +1,89 @@
import { describe, expect, test } from "vitest";
import { findExtensionStackMatches } from "../src/core/crash-log.ts";
type StackExtension = Parameters<typeof findExtensionStackMatches>[1][number];
function packageExtension(source: string, baseDir: string, entry = "extensions/index.ts"): StackExtension {
const normalizedBaseDir = baseDir.replace(/\\/g, "/");
const resolvedPath = `${normalizedBaseDir}/${entry}`;
return {
path: resolvedPath,
resolvedPath,
sourceInfo: {
path: resolvedPath,
source,
scope: "user",
origin: "package",
baseDir,
},
};
}
describe("extension crash attribution", () => {
test("matches stack frames beneath loaded package roots", () => {
const memory = packageExtension(
"npm:pi-observational-memory",
"/home/fedora/.pi/agent/npm/node_modules/pi-observational-memory",
);
const unrelated = packageExtension("npm:unrelated", "/home/fedora/.pi/agent/npm/node_modules/unrelated");
const stack =
"TypeError: Cannot read properties of undefined (reading 'runtime')\n" +
" at streamSimple (file:///home/fedora/.local/lib/node_modules/@earendil-works/pi-coding-agent/dist/bundle/chunks/chunk-CMRUVXTE.js:1093:16944)\n" +
" at /home/fedora/.pi/agent/npm/node_modules/pi-observational-memory/src/agents/worker-stream.ts:43:45";
expect(findExtensionStackMatches(stack, [memory, unrelated])).toEqual(["npm:pi-observational-memory"]);
});
test("normalizes Windows paths and deduplicates package extensions", () => {
const root = "C:\\Users\\reporter\\.pi\\agent\\npm\\node_modules\\@scope\\memory";
const first = packageExtension("npm:@scope/memory", root, "extensions/first.ts");
const second = packageExtension("npm:@scope/memory", root, "extensions/second.ts");
const stack =
"Error: broken\n" +
" at run (c:\\users\\reporter\\.pi\\agent\\npm\\node_modules\\@scope\\memory\\src\\worker.ts:4:2)";
expect(findExtensionStackMatches(stack, [first, second])).toEqual(["npm:@scope/memory"]);
});
test("does not attribute sibling single-file packages", () => {
const extension = (name: string): StackExtension => ({
path: `/plugins/${name}.ts`,
resolvedPath: `/plugins/${name}.ts`,
sourceInfo: {
path: `/plugins/${name}.ts`,
source: `/plugins/${name}.ts`,
scope: "user",
origin: "package",
baseDir: "/plugins",
},
});
const stack = "Error: broken\n at run (file:///plugins/b.ts:4:2)";
expect(findExtensionStackMatches(stack, [extension("a"), extension("b")])).toEqual(["/plugins/b.ts"]);
});
test("ignores extension paths in the error message", () => {
const extension = packageExtension("npm:memory", "/tmp/node_modules/memory");
const stack = `Error: Failed to read ${extension.resolvedPath}\n at run (file:///opt/pi/dist/core.js:4:2)`;
expect(findExtensionStackMatches(stack, [extension])).toEqual([]);
});
test("decodes frame paths independently from malformed error text", () => {
const extension: StackExtension = {
path: "/Users/reporter/.pi/agent/extensions/local memory/index.ts",
resolvedPath: "/Users/reporter/.pi/agent/extensions/local memory/index.ts",
sourceInfo: {
path: "/Users/reporter/.pi/agent/extensions/local memory/index.ts",
source: "local",
scope: "user",
origin: "top-level",
baseDir: "/Users/reporter/.pi/agent/extensions",
},
};
const stack =
"Error: progress 100%\n at run (file:///Users/reporter/.pi/agent/extensions/local%20memory/worker.ts:4:2)";
expect(findExtensionStackMatches(stack, [extension])).toEqual([extension.path]);
});
});

View file

@ -1,6 +1,6 @@
import { type AssistantMessage, fauxAssistantMessage } from "@earendil-works/pi-ai";
import { describe, expect, test, vi } from "vitest";
import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts";
import { formatCrashExtensionHint, InteractiveMode } from "../src/modes/interactive/interactive-mode.ts";
type BugReportHintContext = {
suggestBugReport(): void;
@ -16,6 +16,13 @@ function errorMessage(errorMessage: string): AssistantMessage {
}
describe("InteractiveMode bug report hints", () => {
test("identifies extensions with frames in a crash stack", () => {
expect(formatCrashExtensionHint(["npm:pi-observational-memory"])).toBe(
"A stack frame came from loaded extension `npm:pi-observational-memory`, which may be involved. Try disabling it with `pi config`, or run `pi -ne` to confirm.",
);
expect(formatCrashExtensionHint(undefined)).toBeUndefined();
});
test("does not suggest reports for retryable provider failures", () => {
const context = { suggestBugReport: vi.fn() };
const failures = [