mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(coding-agent): identify extensions in crash stacks
This commit is contained in:
parent
f5c946480c
commit
63787ee6ba
5 changed files with 212 additions and 2 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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()}`);
|
||||
}
|
||||
|
|
|
|||
89
packages/coding-agent/test/crash-log.test.ts
Normal file
89
packages/coding-agent/test/crash-log.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 = [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue