diff --git a/docs/error-catalog.md b/docs/error-catalog.md index 7e90f99d..fb815729 100644 --- a/docs/error-catalog.md +++ b/docs/error-catalog.md @@ -99,6 +99,7 @@ screen, debug log) keep the full detail. | `PHW_AGENT_RESOURCE_MISSING` | agent | `[ERROR-RESOURCE-MISSING]` — setup resource unavailable | yes | | `PHW_AGENT_RATE_LIMIT` | agent | LLM gateway rate limit | yes | | `PHW_AGENT_API_ERROR` | agent | other API failure during the agent run | yes | +| `PHW_AGENT_MODULE_MISSING` | agent | a wizard dependency was missing from the npx download; the user must delete the cached download and rerun | no | | `PHW_AGENT_YARA_VIOLATION` | agent | security scanner terminated the run | no | | `PHW_AGENT_NO_PROGRESS` | agent | agent ended with zero tool calls | case-by-case | | `PHW_AGENT_INCOMPLETE_TASKS` | agent | agent stopped with planned tasks open | case-by-case | diff --git a/src/lib/agent/runner/harness/pi/index.ts b/src/lib/agent/runner/harness/pi/index.ts index 772e39b3..6172086b 100644 --- a/src/lib/agent/runner/harness/pi/index.ts +++ b/src/lib/agent/runner/harness/pi/index.ts @@ -24,6 +24,7 @@ import { } from '@lib/constants'; import { analytics } from '@utils/analytics'; import { AgentErrorType } from '@lib/agent/agent-interface'; +import { isModuleNotFoundError } from '@lib/errors/module-missing'; import { AgentSignals, REMARK_INSTRUCTION } from '@lib/agent/signals'; import { AgentOutputSignals } from '@lib/agent/output-signals'; import { assembleCommandments } from '../../switchboard/commandments'; @@ -639,6 +640,9 @@ export const piBackend: AgentHarness = { captureAborted(); const lower = message.toLowerCase(); + if (isModuleNotFoundError(err)) { + return { error: AgentErrorType.MODULE_MISSING, message }; + } if (lower.includes('rate limit') || lower.includes('429')) { return { error: AgentErrorType.RATE_LIMIT, message }; } diff --git a/src/lib/agent/runner/harness/pi/task.ts b/src/lib/agent/runner/harness/pi/task.ts index 826c9fbf..26c0a97e 100644 --- a/src/lib/agent/runner/harness/pi/task.ts +++ b/src/lib/agent/runner/harness/pi/task.ts @@ -31,6 +31,7 @@ import { renderToolInventory, } from '@lib/agent/agent-prompt-loader'; import { AgentErrorType } from '@lib/agent/agent-interface'; +import { isModuleNotFoundError } from '@lib/errors/module-missing'; import { REMARK_INSTRUCTION } from '@lib/agent/signals'; import { AgentOutputSignals } from '@lib/agent/output-signals'; import { TaskStatus } from '../../sequence/orchestrator/queue'; @@ -508,6 +509,9 @@ export async function runPiTask(inputs: TaskRunInputs): Promise { } captureAborted(); const lower = message.toLowerCase(); + if (isModuleNotFoundError(err)) { + return { error: AgentErrorType.MODULE_MISSING, message }; + } if (lower.includes('rate limit') || lower.includes('429')) { return { error: AgentErrorType.RATE_LIMIT, message }; } diff --git a/src/lib/agent/runner/sequence/linear.ts b/src/lib/agent/runner/sequence/linear.ts index 94ab8684..18acf610 100644 --- a/src/lib/agent/runner/sequence/linear.ts +++ b/src/lib/agent/runner/sequence/linear.ts @@ -18,6 +18,7 @@ import { registerCleanup, } from '../../../../utils/wizard-abort'; import { ErrorCodes, AGENT_ERROR_CODE } from '@lib/errors'; +import { formatModuleMissingMessage } from '@lib/errors/module-missing'; import { analytics } from '../../../../utils/analytics'; import { formatScanReport, @@ -280,6 +281,26 @@ export async function runLinearProgram( }); } + if (agentResult.error === AgentErrorType.MODULE_MISSING) { + analytics.wizardCapture('agent module missing', { + integration: config.integrationLabel, + error_type: AgentErrorType.MODULE_MISSING, + error_message: agentResult.message, + }); + await wizardAbort({ + code: AGENT_ERROR_CODE[AgentErrorType.MODULE_MISSING], + message: formatModuleMissingMessage(agentResult.message ?? ''), + error: new WizardError( + `Dependency missing from the npx download: ${agentResult.message}`, + { + integration: config.integrationLabel, + error_type: AgentErrorType.MODULE_MISSING, + }, + AGENT_ERROR_CODE[AgentErrorType.MODULE_MISSING], + ), + }); + } + if ( agentResult.error === AgentErrorType.RATE_LIMIT || agentResult.error === AgentErrorType.API_ERROR diff --git a/src/lib/agent/runner/sequence/orchestrator/orchestrator-runner.ts b/src/lib/agent/runner/sequence/orchestrator/orchestrator-runner.ts index d6a75a59..4b005f27 100644 --- a/src/lib/agent/runner/sequence/orchestrator/orchestrator-runner.ts +++ b/src/lib/agent/runner/sequence/orchestrator/orchestrator-runner.ts @@ -43,6 +43,8 @@ import { logToFile } from '@utils/debug'; import { ringTerminalBell } from '@utils/terminal-bell'; import { wizardAbort, WizardError } from '@utils/wizard-abort'; import { ErrorCodes } from '@lib/errors'; +import { formatModuleMissingMessage } from '@lib/errors/module-missing'; +import { AgentErrorType } from '../../../agent-interface'; import type { ProgramConfig } from '@lib/programs/program-step'; import type { BootstrapResult, ProgramRun } from '../../shared/types'; import { @@ -760,6 +762,10 @@ export async function runOrchestrator( // Prompt-frontmatter model wins over the switchboard pick (§3.6 of the // switchboard plan) — the switchboard's model is the fallback when the // prompt is silent. + // A half-written npx download breaks the same import in every agent, so the + // first path that reports it — seed or task — stands for the whole run. The + // executor discards task results, so the value is collected here. + let moduleMissing: string | undefined; const seedPick = resolveHarness(switchboardCtx, 'seed'); const seedHarness = requireTaskHarness(seedPick); const seedModel = promptModelFor(seedPrompt, seedPick.harness); @@ -785,6 +791,9 @@ export async function runOrchestrator( seedResult.message ?? '' }`, ); + if (seedResult.error === AgentErrorType.MODULE_MISSING) { + moduleMissing ??= seedResult.message ?? ''; + } } analytics.wizardCapture('orchestrator seeded', { task_count: store.list().length, @@ -998,7 +1007,7 @@ export async function runOrchestrator( const taskPick = resolveHarness(switchboardCtx, task.type); const taskHarness = requireTaskHarness(taskPick); const taskModel = taskModelSpec(registry, task, taskPick.harness); - await taskHarness.runTask({ + const taskResult = await taskHarness.runTask({ session, programConfig, boot, @@ -1020,6 +1029,9 @@ export async function runOrchestrator( harness: taskPick.harness, }, }); + if (taskResult.error === AgentErrorType.MODULE_MISSING) { + moduleMissing ??= taskResult.message ?? ''; + } } finally { // Durable skills a task installed are irrelevant to later tasks — and // the sdk harness auto-loads .claude/skills into every agent — so sweep @@ -1117,6 +1129,35 @@ export async function runOrchestrator( // A failed optional task is exempt: reported per-task, never run-failing. const verdict = drainVerdict(store.list()); const blocked = verdict.blocked; + + // A run that failed because a wizard dependency never loaded gets the cache + // repair command instead of the generic "report this to us" text — no retry + // heals a corrupt extraction. A run that finished anyway keeps its result. + if ( + moduleMissing !== undefined && + (verdict.requiredFailedTypes.length > 0 || + blocked > 0 || + summary.total === 0) + ) { + analytics.wizardCapture('agent module missing', { + integration: programConfig.id, + error_type: AgentErrorType.MODULE_MISSING, + error_message: moduleMissing, + }); + await wizardAbort({ + code: ErrorCodes.AgentModuleMissing, + message: formatModuleMissingMessage(moduleMissing), + error: new WizardError( + `Dependency missing from the npx download: ${moduleMissing}`, + { + integration: programConfig.id, + error_type: AgentErrorType.MODULE_MISSING, + }, + ErrorCodes.AgentModuleMissing, + ), + }); + } + if (verdict.requiredFailedTypes.length > 0 || blocked > 0) { const failedTypes = verdict.requiredFailedTypes.join(', '); const whatFailed = failedTypes diff --git a/src/lib/agent/signals.ts b/src/lib/agent/signals.ts index f66f3ee3..f2c41b02 100644 --- a/src/lib/agent/signals.ts +++ b/src/lib/agent/signals.ts @@ -66,6 +66,8 @@ export enum AgentErrorType { RATE_LIMIT = 'WIZARD_RATE_LIMIT', /** Generic API error */ API_ERROR = 'WIZARD_API_ERROR', + /** A wizard dependency is absent — a half-written npx download */ + MODULE_MISSING = 'WIZARD_MODULE_MISSING', /** YARA scanner detected a security violation */ YARA_VIOLATION = 'WIZARD_YARA_VIOLATION', /** Agent intentionally aborted the program (emitted [ABORT] ) */ diff --git a/src/lib/errors/__tests__/module-missing.test.ts b/src/lib/errors/__tests__/module-missing.test.ts new file mode 100644 index 00000000..f5b2c2f1 --- /dev/null +++ b/src/lib/errors/__tests__/module-missing.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + formatModuleMissingMessage, + isModuleNotFoundError, +} from '../module-missing'; + +describe('isModuleNotFoundError', () => { + it('recognises the Node error code', () => { + const err = Object.assign(new Error('boom'), { + code: 'ERR_MODULE_NOT_FOUND', + }); + expect(isModuleNotFoundError(err)).toBe(true); + }); + + // A dynamic import rethrown across a boundary keeps the text, not the code. + it('recognises the message alone', () => { + expect( + isModuleNotFoundError( + new Error( + "Cannot find package '/Users/a/.npm/_npx/9f2/node_modules/chalk/index.js'", + ), + ), + ).toBe(true); + }); + + it('leaves an ordinary API failure alone', () => { + expect(isModuleNotFoundError(new Error('502 Bad Gateway'))).toBe(false); + }); + + // An npx cache hash is 16 hex characters, so it can contain "429" — which the + // harness's rate-limit substring test would otherwise claim first. + it('recognises a cache hash that looks like a rate limit', () => { + expect( + isModuleNotFoundError( + new Error( + "Cannot find package '/Users/a/.npm/_npx/429abc0d15e7f318/node_modules/chalk/index.js'", + ), + ), + ).toBe(true); + }); +}); + +describe('formatModuleMissingMessage', () => { + it('names the exact download to delete', () => { + const message = formatModuleMissingMessage( + "Cannot find package '/Users/a/.npm/_npx/9f2/node_modules/chalk/index.js'", + ); + expect(message).toContain('rm -rf "/Users/a/.npm/_npx/9f2"'); + }); + + // A Windows profile is routinely `C:\Users\John Smith`, and a POSIX home can + // hold a space too. Capturing from the last space left a relative path, and + // `rm -rf` on a relative path matches nothing and exits 0 — the user believes + // the cache is clear, reruns, and hits the identical failure. + it('keeps a POSIX cache path that contains a space whole', () => { + expect( + formatModuleMissingMessage( + "Cannot find package 'chalk' imported from /Users/First Last/.npm/_npx/9f2abc1234567890/node_modules/pi/dist/index.js", + ), + ).toContain('rm -rf "/Users/First Last/.npm/_npx/9f2abc1234567890"'); + }); + + it('keeps a Windows cache path that contains a space whole', () => { + expect( + formatModuleMissingMessage( + "Cannot find package 'chalk' imported from C:\\Users\\John Smith\\AppData\\Local\\npm-cache\\_npx\\abc1234567890def\\node_modules\\x.js", + ), + ).toContain( + 'rm -rf "C:\\Users\\John Smith\\AppData\\Local\\npm-cache\\_npx\\abc1234567890def"', + ); + }); + + // Half a path is worse than none, so a fragment with no root is not printed. + it('falls back rather than naming a path it cannot root', () => { + const message = formatModuleMissingMessage( + "Cannot find module 'foo/_npx/9f2/node_modules/x'", + ); + expect(message).not.toContain('rm -rf "foo/_npx/9f2"'); + expect(message).toMatch(/rm -rf "\/[^"]*_npx"/); + }); + + // Every wizard command reaches this message, so it must not name one. + it('asks for the same command again rather than the default flow', () => { + const message = formatModuleMissingMessage('Cannot find module x'); + expect(message).toContain('run the same wizard command again'); + expect(message).not.toContain('npx @posthog/wizard@latest'); + }); + + it('falls back to the whole npx cache', () => { + expect(formatModuleMissingMessage('Cannot find module x')).toContain( + '_npx', + ); + }); + + // `process.platform` is the OS, not the shell: a Windows user may be at a + // Command Prompt, where `Remove-Item` is not a command at all. + describe('on Windows', () => { + const originalPlatform = process.platform; + + beforeEach(() => { + Object.defineProperty(process, 'platform', { + value: 'win32', + writable: true, + }); + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + writable: true, + }); + }); + + it('labels a removal command for each shell', () => { + const dir = + 'C:\\Users\\John Smith\\AppData\\Local\\npm-cache\\_npx\\abc1234567890def'; + const message = formatModuleMissingMessage( + `Cannot find package 'chalk' imported from ${dir}\\node_modules\\x.js`, + ); + expect(message).toContain( + `PowerShell: Remove-Item -Recurse -Force "${dir}"`, + ); + expect(message).toContain(`Command Prompt: rmdir /s /q "${dir}"`); + }); + }); +}); diff --git a/src/lib/errors/agent-map.ts b/src/lib/errors/agent-map.ts index d9a46a66..47a6cbec 100644 --- a/src/lib/errors/agent-map.ts +++ b/src/lib/errors/agent-map.ts @@ -6,6 +6,7 @@ export const AGENT_ERROR_CODE: Record = { [AgentErrorType.RESOURCE_MISSING]: ErrorCodes.AgentResourceMissing, [AgentErrorType.RATE_LIMIT]: ErrorCodes.AgentRateLimit, [AgentErrorType.API_ERROR]: ErrorCodes.AgentApiError, + [AgentErrorType.MODULE_MISSING]: ErrorCodes.AgentModuleMissing, [AgentErrorType.YARA_VIOLATION]: ErrorCodes.AgentYaraViolation, [AgentErrorType.ABORT]: ErrorCodes.AgentAbort, [AgentErrorType.NO_PROGRESS]: ErrorCodes.AgentNoProgress, diff --git a/src/lib/errors/catalog.ts b/src/lib/errors/catalog.ts index 7d2c026d..ebd2d106 100644 --- a/src/lib/errors/catalog.ts +++ b/src/lib/errors/catalog.ts @@ -196,6 +196,12 @@ export const ERROR_CATALOG: Record = { retry: 'yes', description: 'The agent hit an API error other than a rate limit.', }, + [ErrorCodes.AgentModuleMissing]: { + group: 'agent', + retry: 'no', + description: + 'A wizard dependency was absent from the npx download. The user must delete the npx cache and run again.', + }, [ErrorCodes.AgentYaraViolation]: { group: 'agent', retry: 'no', diff --git a/src/lib/errors/codes.ts b/src/lib/errors/codes.ts index 6342b79b..9286905f 100644 --- a/src/lib/errors/codes.ts +++ b/src/lib/errors/codes.ts @@ -37,6 +37,7 @@ export const ErrorCodes = { AgentResourceMissing: 'PHW_AGENT_RESOURCE_MISSING', AgentRateLimit: 'PHW_AGENT_RATE_LIMIT', AgentApiError: 'PHW_AGENT_API_ERROR', + AgentModuleMissing: 'PHW_AGENT_MODULE_MISSING', AgentYaraViolation: 'PHW_AGENT_YARA_VIOLATION', AgentNoProgress: 'PHW_AGENT_NO_PROGRESS', AgentIncompleteTasks: 'PHW_AGENT_INCOMPLETE_TASKS', diff --git a/src/lib/errors/module-missing.ts b/src/lib/errors/module-missing.ts new file mode 100644 index 00000000..ef0adddb --- /dev/null +++ b/src/lib/errors/module-missing.ts @@ -0,0 +1,77 @@ +import * as os from 'os'; +import * as path from 'path'; + +/** + * A half-written `~/.npm/_npx` extraction leaves a dependency directory without + * a readable package.json, and Node then fails the dynamic import with + * ERR_MODULE_NOT_FOUND. The install is corrupt, not the API — the user fixes it + * by deleting the cached download and running the wizard again. + */ +export function isModuleNotFoundError(err: unknown): boolean { + const code = (err as { code?: unknown } | null)?.code; + if (code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND') { + return true; + } + const message = err instanceof Error ? err.message : String(err ?? ''); + return /ERR_MODULE_NOT_FOUND|Cannot find (?:package|module)/i.test(message); +} + +/** + * The exact download to delete, taken from the path Node names in the failure + * (`.../_npx//node_modules/...`). Falls back to the whole npx cache when + * the message carries no absolute path of that shape. + */ +function npxCacheDir(message: string): string { + // The part before `_npx` has to tolerate spaces — a Windows profile is + // routinely `C:\Users\John Smith` — so it is fenced by the quotes Node puts + // around a specifier rather than by whitespace, and it has to start at a real + // root (`/`, a drive, or a UNC share). Half a path is worse than none: a + // relative `rm -rf` matches nothing and exits 0, so the user believes the + // cache is clear and hits the same failure. No match instead sends them to + // the whole-cache fallback below, which does unstick them. + const match = + /(?:^|[\s'"])((?:[A-Za-z]:[/\\]|\\\\|\/)[^'"]*?[/\\]_npx[/\\][^\s/\\'"]+)/.exec( + message, + ); + if (match) return match[1]; + return process.platform === 'win32' + ? path.join( + process.env.LOCALAPPDATA ?? path.join(os.homedir(), 'AppData', 'Local'), + 'npm-cache', + '_npx', + ) + : path.join(os.homedir(), '.npm', '_npx'); +} + +export function formatModuleMissingMessage(message: string): string { + const dir = npxCacheDir(message); + // `process.platform` names the OS, not the shell. A Windows user may be at a + // PowerShell or a Command Prompt, and `Remove-Item` is only a command in the + // first, so a single unlabelled line errors out for half of them. Both are + // printed, labelled, and the user picks the one their prompt understands. + const remove = + process.platform === 'win32' + ? [ + `PowerShell: Remove-Item -Recurse -Force "${dir}"`, + `Command Prompt: rmdir /s /q "${dir}"`, + ] + : [`rm -rf "${dir}"`]; + return [ + 'Broken npx download', + '', + 'The wizard could not load one of its own dependencies. The npx cache holds', + 'an incomplete copy of it. Nothing is wrong with your project.', + '', + 'Delete the cached download:', + '', + ...remove.map((line) => ` ${line}`), + '', + // Not a literal rerun command: every wizard command reaches this message, + // so naming the default flow would send an `audit` user into an install. + 'Then run the same wizard command again. npx fetches a fresh copy.', + '', + `Details: ${message}`, + '', + 'Still stuck? Email wizard@posthog.com and we will help.', + ].join('\n'); +}