diff --git a/src/lib/__tests__/agent-interface.test.ts b/src/lib/__tests__/agent-interface.test.ts index 3c48975be..0eb9ce49d 100644 --- a/src/lib/__tests__/agent-interface.test.ts +++ b/src/lib/__tests__/agent-interface.test.ts @@ -8,6 +8,7 @@ import { buildAuthErrorContext, buildAgentEnv, reportMcpSetup, + AgentErrorType, } from '@lib/agent/agent-interface'; import { AgentOutputSignals } from '@lib/agent/output-signals'; import { RESUME_INSTRUCTION } from '@lib/agent/signals'; @@ -120,6 +121,49 @@ describe('runAgent', () => { Object.values(mockUIInstance.log).forEach((fn) => fn.mockReset()); }); + it('aborts an unfinished SDK run at its configured timeout', async () => { + vi.useFakeTimers(); + let controller: AbortController | undefined; + let pending: Promise | undefined; + try { + mockQuery.mockImplementation(({ options }) => { + controller = options.abortController; + return (async function* () { + await new Promise((resolve) => { + if (controller?.signal.aborted) resolve(); + else + controller?.signal.addEventListener('abort', () => resolve(), { + once: true, + }); + }); + if (controller?.signal.aborted) throw new Error('SDK query aborted'); + yield { type: 'assistant', message: { content: [] } }; + })(); + }); + + pending = runAgent( + defaultAgentConfig, + 'test prompt', + defaultOptions, + mockSpinner as unknown as SpinnerHandle, + { timeoutMs: 60_000 }, + ).catch((error) => error); + await vi.waitFor(() => expect(controller).toBeDefined()); + await vi.advanceTimersByTimeAsync(60_000); + + expect(controller?.signal.aborted).toBe(true); + expect(await pending).toEqual({ + error: AgentErrorType.AGENTIC_DETECTION_TIMEOUT, + message: 'Agent run timed out after 60s', + }); + expect(mockSpinner.stop).toHaveBeenCalledWith('Agent run timed out'); + } finally { + controller?.abort(); + if (pending) await pending; + vi.useRealTimers(); + } + }); + describe('race condition handling', () => { it('should return success when agent completes successfully then SDK cleanup fails', async () => { // This simulates the race condition: diff --git a/src/lib/agent/agent-interface.ts b/src/lib/agent/agent-interface.ts index 815498a25..18f3a6565 100644 --- a/src/lib/agent/agent-interface.ts +++ b/src/lib/agent/agent-interface.ts @@ -735,6 +735,8 @@ export async function runAgent( * aborted` events (e.g. the orchestrator's task type and id). */ analyticsProperties?: Record; + /** Abort the SDK query when this run exceeds its own deadline. */ + timeoutMs?: number; }, middleware?: { onMessage(message: any): void; @@ -869,6 +871,7 @@ export async function runAgent( // [ABORT] signal in the agent's output. Also stashes the reason so the // runner can surface it via outroData after we unwind. let abortController = new AbortController(); + let timedOut = false; let abortReason: string | null = null; // Set when a YARA hook detects a terminal violation. Returning `stopReason` // from a PostToolUse hook does NOT stop the SDK, so we abort the query and @@ -880,6 +883,15 @@ export async function runAgent( let reminted = false; let remintRequested = false; const agentConfigDir = createIsolatedAgentConfigDir(); + const timeoutMs = config?.timeoutMs; + const timeoutId = timeoutMs + ? setTimeout(() => { + if (receivedSuccessResult) return; + timedOut = true; + signalDone(); + abortController.abort(); + }, timeoutMs) + : undefined; try { // Per-program allow/disallow lists tweak BASE_ALLOWED_TOOLS. Skills are @@ -1335,6 +1347,14 @@ export async function runAgent( return { error: AgentErrorType.ABORT, message: abortReason }; } + if (timedOut) { + spinner.stop('Agent run timed out'); + return { + error: AgentErrorType.AGENTIC_DETECTION_TIMEOUT, + message: `Agent run timed out after ${timeoutMs! / 1000}s`, + }; + } + // Check for error markers in the agent's output if (signals.has('MCP_MISSING')) { logToFile('Agent error: MCP_MISSING'); @@ -1403,6 +1423,14 @@ export async function runAgent( return completeWithSuccess(error as Error); } + if (timedOut) { + spinner.stop('Agent run timed out'); + return { + error: AgentErrorType.AGENTIC_DETECTION_TIMEOUT, + message: `Agent run timed out after ${timeoutMs! / 1000}s`, + }; + } + // Check if we collected an error signal before the exception was thrown. // Surface just the API error line(s), not the entire output. const apiErrorMessage = signals.apiErrorMessage() ?? 'Unknown API error'; @@ -1426,6 +1454,7 @@ export async function runAgent( debug('Full error:', error); throw error; } finally { + if (timeoutId) clearTimeout(timeoutId); // Always capture run duration, even on abort/error, so we can alert on // long runs where the user gave up before completion. if (!receivedSuccessResult) { diff --git a/src/lib/agent/signals.ts b/src/lib/agent/signals.ts index dd087e1fa..ed2905b30 100644 --- a/src/lib/agent/signals.ts +++ b/src/lib/agent/signals.ts @@ -65,6 +65,8 @@ export const RESUME_INSTRUCTION = * These correspond to the error signals that the agent emits. */ export enum AgentErrorType { + /** The configured run deadline elapsed and the SDK query was aborted */ + AGENTIC_DETECTION_TIMEOUT = 'WIZARD_AGENTIC_DETECTION_TIMEOUT', /** Agent could not access the PostHog MCP server */ MCP_MISSING = 'WIZARD_MCP_MISSING', /** Agent could not access the setup resource */ diff --git a/src/lib/constants.ts b/src/lib/constants.ts index a0162e708..6f93dbd69 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -309,7 +309,8 @@ export function wizardUserAgentForProgram(programId?: string): string { export const DETECTION_TIMEOUT_MS = 10_000; /** Timeout for the agentic project scan (ms); past it the run falls back to root detection. */ -export const AGENTIC_DETECTION_TIMEOUT_MS = 60_000; +export const AGENTIC_DETECTION_FIRST_ATTEMPT_TIMEOUT_MS = 60_000; +export const AGENTIC_DETECTION_RETRY_TIMEOUT_MS = 90_000; /** * Timeout for the OAuth authorization flow (ms). diff --git a/src/lib/detection/__tests__/agentic-retry.test.ts b/src/lib/detection/__tests__/agentic-retry.test.ts new file mode 100644 index 000000000..f0c998262 --- /dev/null +++ b/src/lib/detection/__tests__/agentic-retry.test.ts @@ -0,0 +1,185 @@ +import { + AgenticDetectionTimeoutError, + detectProjectsWithAgent, +} from '@lib/detection/agentic'; +import { + AgentErrorType, + initializeAgent, + runAgent, +} from '@lib/agent/agent-interface'; +import { buildSession } from '@lib/wizard-session'; +import { HostResolution } from '@lib/host-resolution'; + +vi.mock('@lib/agent/agent-interface', async (importOriginal) => ({ + ...(await importOriginal()), + initializeAgent: vi.fn(), + runAgent: vi.fn(), +})); + +const init = vi.mocked(initializeAgent); +const execute = vi.mocked(runAgent); +const options = { + programId: 'posthog-integration', + targets: [{ id: 'nextjs', name: 'Next.js' }], +}; + +function session() { + const value = buildSession({ installDir: '/repo' }); + value.credentials = { + accessToken: 'token', + projectApiKey: 'key', + host: HostResolution.fromApiHost('https://us.posthog.com'), + projectId: 1, + }; + return value; +} + +function emitResult(text: string) { + return execute.mockImplementationOnce((...args) => { + args[5]?.onMessage({ type: 'result', result: text }); + return Promise.resolve({}); + }); +} + +describe('agentic detection retry', () => { + beforeEach(() => { + vi.resetAllMocks(); + init.mockImplementation(() => + Promise.resolve({ id: init.mock.calls.length } as unknown as Awaited< + ReturnType + >), + ); + }); + + it('restarts the scan once when the first result has no JSON', async () => { + emitResult('I found a Next.js project.'); + emitResult( + '{"path":".","framework":"Next.js","targetId":"nextjs","hasPostHog":false}', + ); + + const report = await detectProjectsWithAgent(session(), options); + + expect(report.projects).toEqual([ + { + path: '.', + framework: 'Next.js', + targetId: 'nextjs', + hasPostHog: false, + }, + ]); + expect(init).toHaveBeenCalledTimes(2); + expect(execute).toHaveBeenCalledTimes(2); + expect(execute.mock.calls[0][4]).toEqual( + expect.objectContaining({ timeoutMs: 60_000 }), + ); + expect(execute.mock.calls[1][4]).toEqual( + expect.objectContaining({ timeoutMs: 90_000 }), + ); + }); + + it('returns the first valid report without starting a retry', async () => { + emitResult( + '{"path":".","framework":"Next.js","targetId":"nextjs","hasPostHog":false}', + ); + + const report = await detectProjectsWithAgent(session(), options); + + expect(report.projects).toHaveLength(1); + expect(init).toHaveBeenCalledTimes(1); + expect(execute).toHaveBeenCalledTimes(1); + }); + + it('retries a timed-out first run with a fresh Haiku session', async () => { + const events: string[] = []; + execute.mockResolvedValueOnce({ + error: AgentErrorType.AGENTIC_DETECTION_TIMEOUT, + }); + emitResult( + '{"path":".","framework":"Next.js","targetId":"nextjs","hasPostHog":false}', + ); + + const report = await detectProjectsWithAgent(session(), { + ...options, + onEvent: (line) => events.push(line), + }); + + expect(report.projects).toHaveLength(1); + expect(events).toContain('Project scan timed out; retrying...'); + expect(execute.mock.calls[0][0]).not.toBe(execute.mock.calls[1][0]); + expect(execute.mock.calls[0][4]).toEqual( + expect.objectContaining({ timeoutMs: 60_000 }), + ); + expect(execute.mock.calls[1][4]).toEqual( + expect.objectContaining({ timeoutMs: 90_000 }), + ); + }); + + it('reports a typed timeout when the retry also times out', async () => { + execute.mockResolvedValue({ + error: AgentErrorType.AGENTIC_DETECTION_TIMEOUT, + }); + + await expect(detectProjectsWithAgent(session(), options)).rejects.toThrow( + AgenticDetectionTimeoutError, + ); + expect(execute).toHaveBeenCalledTimes(2); + }); + + it('accepts a streamed verdict after a no-JSON result', async () => { + const events: string[] = []; + emitResult('Found a project, but no JSON report.'); + execute.mockImplementationOnce((...args) => { + args[5]?.onMessage({ + type: 'assistant', + message: { + content: [ + { + type: 'text', + text: '{"path":".","framework":"Next.js","targetId":"nextjs","hasPostHog":false}', + }, + ], + }, + }); + args[5]?.onMessage({ type: 'result', result: 'Done.' }); + return Promise.resolve({}); + }); + + const report = await detectProjectsWithAgent(session(), { + ...options, + onEvent: (line) => events.push(line), + }); + + expect(report.projects).toEqual([ + { + path: '.', + framework: 'Next.js', + targetId: 'nextjs', + hasPostHog: false, + }, + ]); + expect(events).toContain('Retrying project scan...'); + expect(init).toHaveBeenCalledTimes(2); + expect(execute.mock.calls[0][0]).not.toBe(execute.mock.calls[1][0]); + expect(execute.mock.calls[0][1]).toBe(execute.mock.calls[1][1]); + }); + + it('stops after one retry if neither result has JSON', async () => { + emitResult('No JSON here.'); + emitResult('Still no JSON.'); + + await expect(detectProjectsWithAgent(session(), options)).rejects.toThrow( + 'Agent did not return a JSON object after retry', + ); + expect(execute).toHaveBeenCalledTimes(2); + }); + + it('does not retry an intentional abort', async () => { + emitResult('[ABORT] detection failed'); + + await expect(detectProjectsWithAgent(session(), options)).resolves.toEqual({ + repoType: 'single', + projects: [], + }); + expect(execute).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/detection/__tests__/project-scope.test.ts b/src/lib/detection/__tests__/project-scope.test.ts index 46aedaf04..5bb07b46a 100644 --- a/src/lib/detection/__tests__/project-scope.test.ts +++ b/src/lib/detection/__tests__/project-scope.test.ts @@ -1,15 +1,13 @@ import { detectProjectsWithAgent, + AgenticDetectionTimeoutError, type AgenticProject, } from '@lib/detection/agentic'; import { chooseIntegrationProject, scopeInstallDirToProject, } from '@lib/detection/project-scope'; -import { - AGENTIC_DETECTION_TIMEOUT_MS, - WIZARD_BASIC_INTEGRATION_AGENTIC_DETECTION_FLAG_KEY, -} from '@lib/constants'; +import { WIZARD_BASIC_INTEGRATION_AGENTIC_DETECTION_FLAG_KEY } from '@lib/constants'; import { authenticate } from '@lib/agent/runner/shared/authenticate'; import { buildSession } from '@lib/wizard-session'; import { analytics } from '@utils/analytics'; @@ -194,21 +192,40 @@ describe('scopeInstallDirToProject', () => { }); }); - it('leaves the session untouched and fires timeout when the scan outruns the budget', async () => { - // The scan is abandoned, not cancelled — the run must not wait on it forever. + it('leaves the session untouched and fires timeout after the retry deadline', async () => { + flagsSpy.mockResolvedValue(FLAG_ON); + scan.mockRejectedValue(new AgenticDetectionTimeoutError(2, 90_000)); + const session = buildSession({ installDir: '/repo' }); + + await scopeInstallDirToProject(session); + + expect(session.installDir).toBe('/repo'); + expect(outcomeEvent()).toMatchObject({ outcome: 'timeout' }); + }); + + it('uses a valid retry result after the old 60-second caller deadline', async () => { vi.useFakeTimers(); try { flagsSpy.mockResolvedValue(FLAG_ON); - scan.mockReturnValue(new Promise(() => undefined)); + scan.mockImplementation( + () => + new Promise((resolve) => + setTimeout( + () => resolve({ repoType: 'monorepo', projects: [web] }), + 65_000, + ), + ), + ); const session = buildSession({ installDir: '/repo' }); const done = scopeInstallDirToProject(session); - await vi.advanceTimersByTimeAsync(AGENTIC_DETECTION_TIMEOUT_MS); + + await vi.advanceTimersByTimeAsync(65_000); await done; - expect(session.installDir).toBe('/repo'); + expect(session.installDir).toBe('/repo/apps/web'); expect(outcomeEvent()).toMatchObject({ - outcome: 'timeout', - duration_ms: AGENTIC_DETECTION_TIMEOUT_MS, + outcome: 'recommended', + duration_ms: 65_000, }); } finally { vi.useRealTimers(); diff --git a/src/lib/detection/agentic.ts b/src/lib/detection/agentic.ts index aac8d282a..c95c7c6a3 100644 --- a/src/lib/detection/agentic.ts +++ b/src/lib/detection/agentic.ts @@ -18,10 +18,17 @@ import { runAgent as executeAgent, buildRunTags, AgentSignals, + AgentErrorType, } from '@lib/agent/agent-interface'; import { isAbsolute, resolve, sep } from 'path'; import { detectNodePackageManagers } from './package-manager.js'; -import { CallType, getSkillsBaseUrl, HAIKU_MODEL } from '@lib/constants'; +import { + AGENTIC_DETECTION_FIRST_ATTEMPT_TIMEOUT_MS, + AGENTIC_DETECTION_RETRY_TIMEOUT_MS, + CallType, + getSkillsBaseUrl, + HAIKU_MODEL, +} from '@lib/constants'; import { analytics } from '@utils/analytics'; import type { WizardSession } from '@lib/wizard-session'; import type { WizardRunOptions } from '@utils/types'; @@ -49,6 +56,15 @@ export type AgenticDetectionReport = { projects: AgenticProject[]; }; +export class AgenticDetectionTimeoutError extends Error { + constructor(attempt: number, timeoutMs: number) { + super( + `Project scan attempt ${attempt} timed out after ${timeoutMs / 1000}s`, + ); + this.name = 'AgenticDetectionTimeoutError'; + } +} + /** Streaming progress callback — one short activity line per agent step. */ export type DetectEvent = (line: string) => void; @@ -358,97 +374,110 @@ export async function detectProjectsWithAgent( call_type: CallType.detection, }; - const agent = await initializeAgent( - { - workingDirectory: cwd, - posthogMcpUrl: host.mcpUrl, - posthogApiKey: accessToken, - host, - detectPackageManager: detectNodePackageManagers, - skillsBaseUrl: getSkillsBaseUrl(), - programId, - integrationLabel: 'agentic-detect', - wizardMetadata, - allowedTools: ['Read', 'Grep', 'Glob'], - modelOverride: HAIKU_MODEL, - }, - runOptions, - ); + const prompt = buildPrompt(cwd, targets, purpose, recommend); + for (let attempt = 0; attempt < 2; attempt++) { + const timeoutMs = + attempt === 0 + ? AGENTIC_DETECTION_FIRST_ATTEMPT_TIMEOUT_MS + : AGENTIC_DETECTION_RETRY_TIMEOUT_MS; + const agent = await initializeAgent( + { + workingDirectory: cwd, + posthogMcpUrl: host.mcpUrl, + posthogApiKey: accessToken, + host, + detectPackageManager: detectNodePackageManagers, + skillsBaseUrl: getSkillsBaseUrl(), + programId, + integrationLabel: 'agentic-detect', + wizardMetadata, + allowedTools: ['Read', 'Grep', 'Glob'], + modelOverride: HAIKU_MODEL, + }, + runOptions, + ); - // Keeps only the transcript tail — the report JSON is the last output. - const MAX_TRANSCRIPT_CHARS = 256 * 1024; - const collected: string[] = []; - let collectedChars = 0; - const collect = (text: string): void => { - collected.push(text); - collectedChars += text.length; - while (collectedChars > MAX_TRANSCRIPT_CHARS && collected.length > 1) { - collectedChars -= collected.shift()!.length; - } - }; - let resultText = ''; + // Keeps only the transcript tail — the report JSON is the last output. + const MAX_TRANSCRIPT_CHARS = 256 * 1024; + const collected: string[] = []; + let collectedChars = 0; + const collect = (text: string): void => { + collected.push(text); + collectedChars += text.length; + while (collectedChars > MAX_TRANSCRIPT_CHARS && collected.length > 1) { + collectedChars -= collected.shift()!.length; + } + }; + let resultText = ''; - const middleware = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - onMessage: (message: any): void => { - if (message?.type === 'assistant') { - for (const block of message.message?.content ?? []) { - if (block?.type === 'text' && typeof block.text === 'string') { - collect(block.text); - const line = block.text.trim(); - if (line && onEvent) { - onEvent(line.length > 100 ? `${line.slice(0, 100)}…` : line); + const middleware = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onMessage: (message: any): void => { + if (message?.type === 'assistant') { + for (const block of message.message?.content ?? []) { + if (block?.type === 'text' && typeof block.text === 'string') { + collect(block.text); + const line = block.text.trim(); + if (line && onEvent) { + onEvent(line.length > 100 ? `${line.slice(0, 100)}…` : line); + } + } else if (block?.type === 'tool_use') { + onEvent?.(formatToolUse(block)); } - } else if (block?.type === 'tool_use') { - onEvent?.(formatToolUse(block)); } + } else if ( + message?.type === 'result' && + typeof message.result === 'string' + ) { + resultText = message.result; } - } else if ( - message?.type === 'result' && - typeof message.result === 'string' - ) { - resultText = message.result; - } - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - finalize: (_resultMessage: any, _durationMs: number): unknown => undefined, - }; + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + finalize: (_resultMessage: any, _durationMs: number): unknown => + undefined, + }; - const result = await executeAgent( - agent, - buildPrompt(cwd, targets, purpose, recommend), - runOptions, - NOOP_SPINNER, - { - spinnerMessage: 'Scanning the repo...', - successMessage: 'Detection complete', - errorMessage: 'Detection failed', - requestRemark: false, - }, - middleware, - ); + const result = await executeAgent( + agent, + prompt, + runOptions, + NOOP_SPINNER, + { + spinnerMessage: 'Scanning the repo...', + successMessage: 'Detection complete', + errorMessage: 'Detection failed', + requestRemark: false, + timeoutMs, + }, + middleware, + ); - if (result.error) { - throw new Error(result.message || `Agent error: ${result.error}`); - } + if (result.error === AgentErrorType.AGENTIC_DETECTION_TIMEOUT) { + if (attempt === 0) { + onEvent?.('Project scan timed out; retrying...'); + continue; + } + throw new AgenticDetectionTimeoutError(attempt + 1, timeoutMs); + } + if (result.error) { + throw new Error(result.message || `Agent error: ${result.error}`); + } - // Transcript first, final message last — its verdicts win path conflicts. - const output = `${collected.join('\n')}\n${resultText}`; - const derived = deriveReportJson(output); - if (derived === null) { - // The prompt tells the agent to emit `[ABORT] detection failed` when the - // repo has no recognizable project manifests. Surface that (and any other - // non-JSON terminal output that carries the abort signal) as an empty - // report so the screen renders a friendly "nothing to instrument" state - // instead of a cryptic "Agent did not return a JSON object" error. + // Transcript first, final message last — its verdicts win path conflicts. + const output = `${collected.join('\n')}\n${resultText}`; + const derived = deriveReportJson(output); + if (derived !== null) { + return coerceAgenticReport( + derived, + targets.map((t) => t.id), + { recommend, rerankIds }, + ); + } + // No manifests are a valid empty scan, not a reason to retry. if (output.includes(AgentSignals.ABORT)) { return { repoType: 'single', projects: [] }; } - throw new Error('Agent did not return a JSON object'); + if (attempt === 0) onEvent?.('Retrying project scan...'); } - return coerceAgenticReport( - derived, - targets.map((t) => t.id), - { recommend, rerankIds }, - ); + throw new Error('Agent did not return a JSON object after retry'); } diff --git a/src/lib/detection/project-scope.ts b/src/lib/detection/project-scope.ts index 73279b075..77d05c173 100644 --- a/src/lib/detection/project-scope.ts +++ b/src/lib/detection/project-scope.ts @@ -2,6 +2,7 @@ import { detectProjectsWithAgent, + AgenticDetectionTimeoutError, resolveProjectDir, type AgenticDetectionReport, type AgenticProject, @@ -11,7 +12,6 @@ import { import { authenticate } from '@lib/agent/runner/shared/authenticate'; import { FRAMEWORK_REGISTRY } from '@lib/registry'; import { - AGENTIC_DETECTION_TIMEOUT_MS, Integration, WIZARD_BASIC_INTEGRATION_AGENTIC_DETECTION_FLAG_KEY, } from '@lib/constants'; @@ -94,9 +94,6 @@ export type AgenticDetectionOutcome = | 'recommended' | 'first-instrumentable'; -/** Sentinel for a scan that outran AGENTIC_DETECTION_TIMEOUT_MS. */ -const TIMED_OUT = Symbol('timed-out'); - function captureOutcome( outcome: AgenticDetectionOutcome, properties: Record = {}, @@ -119,24 +116,24 @@ export async function scopeInstallDirToProject( getUI().log.info('Scanning the repo for projects...'); const startedAt = Date.now(); - let report: AgenticDetectionReport | typeof TIMED_OUT; + let report: AgenticDetectionReport; try { - report = await Promise.race([ - detectIntegrationProjects(session, { - // Literal, like the `authenticate` call above: importing the program - // registry here would cycle. - programId: 'posthog-integration', - recommend: true, - onEvent: (line) => logToFile('[agentic detect]', line), - }), - // The agent has no abort plumbing, so a timed-out scan is abandoned in the - // background rather than cancelled; the run stops waiting on it either way. - new Promise((resolve) => - setTimeout(() => resolve(TIMED_OUT), AGENTIC_DETECTION_TIMEOUT_MS), - ), - ]); + report = await detectIntegrationProjects(session, { + // Literal, like the `authenticate` call above: importing the program + // registry here would cycle. + programId: 'posthog-integration', + recommend: true, + onEvent: (line) => logToFile('[agentic detect]', line), + }); } catch (err) { const error = err instanceof Error ? err : new Error(String(err)); + if (error instanceof AgenticDetectionTimeoutError) { + captureOutcome('timeout', { duration_ms: Date.now() - startedAt }); + getUI().log.warn( + `${error.message}; continuing with the install dir as-is.`, + ); + return; + } analytics.captureException(error, { step: 'agentic_detection' }); captureOutcome('error', { duration_ms: Date.now() - startedAt, @@ -148,16 +145,6 @@ export async function scopeInstallDirToProject( return; } - if (report === TIMED_OUT) { - captureOutcome('timeout', { duration_ms: Date.now() - startedAt }); - getUI().log.warn( - `Project scan timed out after ${ - AGENTIC_DETECTION_TIMEOUT_MS / 1000 - }s; continuing with the install dir as-is.`, - ); - return; - } - const { projects } = report; const recommended = projects.find((p) => p.recommended === true); // The event carries at most this many projects; project_count is the true total. diff --git a/src/lib/errors/agent-map.ts b/src/lib/errors/agent-map.ts index d9a46a667..4f7efc51d 100644 --- a/src/lib/errors/agent-map.ts +++ b/src/lib/errors/agent-map.ts @@ -2,6 +2,8 @@ import { AgentErrorType } from '../agent/signals'; import { ErrorCodes, type ErrorCode } from './codes'; export const AGENT_ERROR_CODE: Record = { + [AgentErrorType.AGENTIC_DETECTION_TIMEOUT]: + ErrorCodes.AgenticDetectionTimeout, [AgentErrorType.MCP_MISSING]: ErrorCodes.AgentMcpMissing, [AgentErrorType.RESOURCE_MISSING]: ErrorCodes.AgentResourceMissing, [AgentErrorType.RATE_LIMIT]: ErrorCodes.AgentRateLimit, diff --git a/src/lib/errors/catalog.ts b/src/lib/errors/catalog.ts index e49be2929..0f0ef3b29 100644 --- a/src/lib/errors/catalog.ts +++ b/src/lib/errors/catalog.ts @@ -176,6 +176,11 @@ export const ERROR_CATALOG: Record = { retry: 'case-by-case', description: 'The agent emitted an [ABORT] signal with a reason.', }, + [ErrorCodes.AgenticDetectionTimeout]: { + group: 'agent', + retry: 'yes', + description: 'The agent run exceeded its configured time limit.', + }, [ErrorCodes.AgentMcpMissing]: { group: 'agent', retry: 'yes', diff --git a/src/lib/errors/codes.ts b/src/lib/errors/codes.ts index a4d5f04dc..c814be2f8 100644 --- a/src/lib/errors/codes.ts +++ b/src/lib/errors/codes.ts @@ -33,6 +33,7 @@ export const ErrorCodes = { SkillNotFound: 'PHW_SKILL_NOT_FOUND', SkillDownloadFailed: 'PHW_SKILL_DOWNLOAD_FAILED', AgentAbort: 'PHW_AGENT_ABORT', + AgenticDetectionTimeout: 'PHW_AGENTIC_DETECTION_TIMEOUT', AgentMcpMissing: 'PHW_AGENT_MCP_MISSING', AgentResourceMissing: 'PHW_AGENT_RESOURCE_MISSING', AgentRateLimit: 'PHW_AGENT_RATE_LIMIT', diff --git a/src/steps/add-mcp-server-to-clients/results.ts b/src/steps/add-mcp-server-to-clients/results.ts index 0f360d33a..aafb0ecb5 100644 --- a/src/steps/add-mcp-server-to-clients/results.ts +++ b/src/steps/add-mcp-server-to-clients/results.ts @@ -127,12 +127,6 @@ export const expectedFailureHint = ( )?.hint; }; -/** Remove home directories before reporting CLI failures. */ -export const scrubHomePaths = (raw: string): string => - raw - .replace(/\/(?:Users|home)\/[^/\s'"]+/g, '~') - .replace(/[A-Za-z]:\\Users\\[^\\\s'"]+/gi, '~'); - /** First non-empty line of an error, trimmed to something a TUI line can hold. */ export const summarizeFailure = (raw?: string): string | undefined => { const line = redactSecrets(raw ?? '')