From ebee145b3cd5a8f0e2677984e4551fa191a79951 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Mon, 21 Sep 2026 18:08:54 -0400 Subject: [PATCH 01/10] refactor(agent): make runAgent a function with progress and interaction contracts runAgent(config, input, {onProgress?, interaction?, signal?}) returns a RunResult and never rejects. RunConfig and RunInput replace the session reads, AgentProgress replaces the getUI() calls, an optional AgentInteraction replaces the getUI() answerer, and every former wizardAbort returns as a failure with the same fields. An unexpected throw returns as outcome 'crashed' with the original error attached. Gates, authenticate, token refresh, flag fetch, binding resolution and the exit move to src/lib/programs/run-agent-legacy.ts, which maps each progress event to one WizardUI call so every existing caller keeps its output. authenticate moves to programs unchanged. The allowlist architecture test is removed; import enforcement lands with the A2 move. Generated-By: PostHog Desktop Task-Id: d14e92bb-6ee1-49b5-8502-39cb80079589 --- package.json | 1 - scripts/tui-host.no-jest.ts | 4 +- .../architecture/import-boundaries.test.ts | 403 ------------- .../architecture/known-violations.json | 76 --- src/__tests__/cli.test.ts | 2 +- src/__tests__/provision-cli.test.ts | 2 +- src/lib/__tests__/agent-interface.test.ts | 33 +- .../__tests__/run-agent-standalone.test.ts | 388 +++++++++++++ src/lib/agent/agent-interface.ts | 111 +++- src/lib/agent/agent-runner.ts | 3 +- src/lib/agent/progress.ts | 100 ++++ src/lib/agent/runner/README.md | 58 +- .../agent/runner/harness/anthropic/index.ts | 46 +- src/lib/agent/runner/harness/pi/index.ts | 74 ++- src/lib/agent/runner/harness/pi/task.ts | 18 +- src/lib/agent/runner/harness/pi/tasks.ts | 33 +- src/lib/agent/runner/harness/types.ts | 39 +- src/lib/agent/runner/index.ts | 285 ++++------ src/lib/agent/runner/sequence/linear.ts | 279 ++++----- .../__tests__/seeded-decline-skip.test.ts | 3 - .../__tests__/task-notice-timeout.test.ts | 43 +- .../runner/sequence/orchestrator/executor.ts | 15 + .../orchestrator/orchestrator-runner.ts | 226 +++++--- src/lib/agent/runner/shared/ask.ts | 42 ++ src/lib/agent/runner/shared/bootstrap.ts | 327 ++--------- src/lib/agent/runner/shared/errors.ts | 16 +- .../agent/runner/shared/progress-collector.ts | 117 ++++ src/lib/agent/runner/shared/types.ts | 238 +++++++- src/lib/agent/runner/switchboard/sequence.ts | 20 +- .../detection/__tests__/project-scope.test.ts | 4 +- src/lib/detection/project-scope.ts | 2 +- .../refresh-access-token-if-needed.test.ts | 0 .../shared => programs}/authenticate.ts | 2 +- src/lib/programs/posthog-integration/index.ts | 5 +- src/lib/programs/run-agent-legacy.ts | 530 ++++++++++++++++++ .../runners/__tests__/mint-recovery.test.ts | 4 +- src/lib/runners/run-non-interactive.ts | 2 +- src/lib/runners/run-wizard.ts | 4 +- vitest.config.ts | 4 +- 39 files changed, 2180 insertions(+), 1379 deletions(-) delete mode 100644 src/__tests__/architecture/import-boundaries.test.ts delete mode 100644 src/__tests__/architecture/known-violations.json create mode 100644 src/lib/agent/__tests__/run-agent-standalone.test.ts create mode 100644 src/lib/agent/progress.ts create mode 100644 src/lib/agent/runner/shared/ask.ts create mode 100644 src/lib/agent/runner/shared/progress-collector.ts rename src/lib/{agent/runner/shared => programs}/__tests__/refresh-access-token-if-needed.test.ts (100%) rename src/lib/{agent/runner/shared => programs}/authenticate.ts (98%) create mode 100644 src/lib/programs/run-agent-legacy.ts diff --git a/package.json b/package.json index 4df515d69..4a2b82474 100644 --- a/package.json +++ b/package.json @@ -137,7 +137,6 @@ "test:tui": "vitest run --project tui", "test:cli": "vitest run --project cli", "test:harness": "vitest run --project harness", - "test:arch": "vitest run --project architecture", "test:goldens:update": "vitest run -u", "test:coverage": "pnpm build && vitest run --coverage", "test:e2e": "pnpm build && ./e2e-tests/run.sh", diff --git a/scripts/tui-host.no-jest.ts b/scripts/tui-host.no-jest.ts index ded2d55a2..ead31e380 100644 --- a/scripts/tui-host.no-jest.ts +++ b/scripts/tui-host.no-jest.ts @@ -27,10 +27,10 @@ import type { Harness, Sequence } from '@lib/constants'; import { buildSession } from '@lib/wizard-session'; import { initLocalDev } from '@lib/local-dev'; import { configureGatewayFromCIEnvironment } from '@lib/gateway-session'; -import { runAgent } from '@lib/agent/agent-runner'; +import { runAgent } from '@lib/programs/run-agent-legacy'; import { TaskStreamPush, createFileDestination } from '@lib/task-stream/index'; import { getAuditChecks } from '@lib/programs/audit/types'; -import { authenticate } from '@lib/agent/runner/shared/authenticate'; +import { authenticate } from '@lib/programs/authenticate'; import { getOrAskForProjectData } from '@utils/setup-utils'; import { logToFile } from '@utils/debug'; import { join } from 'path'; diff --git a/src/__tests__/architecture/import-boundaries.test.ts b/src/__tests__/architecture/import-boundaries.test.ts deleted file mode 100644 index 8220182ae..000000000 --- a/src/__tests__/architecture/import-boundaries.test.ts +++ /dev/null @@ -1,403 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; -import { fileURLToPath } from 'url'; - -export type Surface = 'env' | 'legacy' | 'agent' | 'tui' | 'cli'; - -const HERE = path.dirname(fileURLToPath(import.meta.url)); -const REPO_ROOT = path.resolve(HERE, '../../..'); - -const SURFACE_RULES: ReadonlyArray boolean]> = - [ - ['env', (p) => p === 'src/env.ts'], - [ - 'agent', - (p) => - p.startsWith('src/lib/agent/') || - p.startsWith('src/lib/middleware/') || - p === 'src/lib/gateway-session.ts' || - p === 'src/lib/yara-hooks.ts' || - p === 'src/lib/yara-policy.ts' || - p === 'src/lib/wizard-tools/mcp.ts', - ], - [ - 'tui', - (p) => - p.startsWith('src/ui/tui/') || - p === 'src/ui/logging-ui.ts' || - p === 'src/ui/headless-ui.ts' || - p === 'src/commands/factories/family-picker.tsx' || - /^src\/lib\/programs\/[^/]+\/content\//.test(p) || - /^src\/lib\/programs\/[^/]+\/tips\.ts$/.test(p), - ], - [ - 'cli', - (p) => - p === 'bin.ts' || - p === 'src/wizard.ts' || - p === 'src/telemetry.ts' || - p.startsWith('src/commands/') || - p.startsWith('src/lib/runners/'), - ], - ]; - -export function classifySurface(relPath: string): Surface { - const p = relPath.split(path.sep).join('/'); - for (const [surface, matches] of SURFACE_RULES) { - if (matches(p)) return surface; - } - return 'legacy'; -} - -export const ALLOWED_IMPORTS: Record = { - env: [], - legacy: ['env', 'legacy'], - agent: ['env', 'legacy', 'agent'], - tui: ['env', 'legacy', 'tui'], - cli: ['env', 'legacy', 'agent', 'tui', 'cli'], -}; - -const TUI_ONLY_PACKAGES = ['ink', 'react', '@inkjs/ui', 'ink-testing-library']; - -const SKIP_DIRS = new Set([ - '__tests__', - '__mocks__', - '__fixtures__', - '__snapshots__', - 'node_modules', - 'dist', - 'coverage', -]); - -const SPECIFIER_PATTERNS = [ - /import\s+(?:type\s+)?[^'"]*?from\s*['"]([^'"]+)['"]/g, - /import\s*['"]([^'"]+)['"]/g, - /export\s+(?:type\s+)?[^'"]*?from\s*['"]([^'"]+)['"]/g, - /import\(\s*['"]([^'"]+)['"]\s*\)/g, - /require\(\s*['"]([^'"]+)['"]\s*\)/g, -]; - -const REGEX_PRECEDERS = new Set([ - '\n', - '(', - ')', - ',', - '=', - ':', - '[', - '!', - '&', - '|', - '?', - '{', - '}', - ';', - '+', - '-', - '*', - '%', - '^', - '~', - '<', - '>', -]); - -function endOfString(source: string, start: number, quote: string): number { - let i = start + 1; - while (i < source.length) { - if (source[i] === '\\') { - i += 2; - continue; - } - if (source[i] === quote) return i + 1; - i++; - } - return source.length; -} - -function endOfRegex(source: string, start: number): number { - let i = start + 1; - let inClass = false; - while (i < source.length) { - const c = source[i]; - if (c === '\\') { - i += 2; - continue; - } - if (c === '\n') return start + 1; - if (c === '[') inClass = true; - else if (c === ']') inClass = false; - else if (c === '/' && !inClass) return i + 1; - i++; - } - return source.length; -} - -function stripComments(source: string): string { - let out = ''; - let prev = '\n'; - let i = 0; - while (i < source.length) { - const c = source[i]; - const next = source[i + 1]; - if (c === '/' && next === '/') { - while (i < source.length && source[i] !== '\n') i++; - continue; - } - if (c === '/' && next === '*') { - i += 2; - while (i < source.length && !(source[i] === '*' && source[i + 1] === '/')) - i++; - i += 2; - out += ' '; - continue; - } - if (c === '"' || c === "'" || c === '`') { - const end = endOfString(source, i, c); - out += source.slice(i, end); - prev = c; - i = end; - continue; - } - if (c === '/' && REGEX_PRECEDERS.has(prev)) { - const end = endOfRegex(source, i); - out += source.slice(i, end); - prev = '/'; - i = end; - continue; - } - out += c; - if (c === '\n' || !/\s/.test(c)) prev = c; - i++; - } - return out; -} - -function toRepoRelative(abs: string): string { - return path.relative(REPO_ROOT, abs).split(path.sep).join('/'); -} - -function isFile(abs: string): boolean { - return fs.statSync(abs, { throwIfNoEntry: false })?.isFile() ?? false; -} - -function collectFiles(absDir: string, into: string[]): void { - for (const entry of fs.readdirSync(absDir, { withFileTypes: true })) { - const abs = path.join(absDir, entry.name); - if (entry.isDirectory()) { - if (!SKIP_DIRS.has(entry.name)) collectFiles(abs, into); - continue; - } - if (!entry.isFile()) continue; - if (!/\.tsx?$/.test(entry.name)) continue; - if (/\.d\.ts$/.test(entry.name) || /\.test\.tsx?$/.test(entry.name)) - continue; - into.push(toRepoRelative(abs)); - } -} - -function loadAliases(): ReadonlyArray { - const tsconfig = JSON.parse( - fs.readFileSync(path.join(REPO_ROOT, 'tsconfig.build.json'), 'utf8'), - ) as { compilerOptions?: { paths?: Record } }; - return Object.entries(tsconfig.compilerOptions?.paths ?? {}).map( - ([pattern, targets]) => [pattern, targets[0]] as const, - ); -} - -function aliasTarget( - spec: string, - aliases: ReadonlyArray, -): string | null { - for (const [pattern, target] of aliases) { - if (pattern.endsWith('*')) { - const prefix = pattern.slice(0, -1); - if (spec.startsWith(prefix)) { - return path.resolve( - REPO_ROOT, - target.slice(0, -1) + spec.slice(prefix.length), - ); - } - } else if (spec === pattern) { - return path.resolve(REPO_ROOT, target); - } - } - return null; -} - -function probe(base: string): string | null { - const candidates: string[] = []; - if (base.endsWith('.js')) { - const stem = base.slice(0, -3); - candidates.push(`${stem}.ts`, `${stem}.tsx`); - } - candidates.push( - `${base}.ts`, - `${base}.tsx`, - path.join(base, 'index.ts'), - path.join(base, 'index.tsx'), - base, - ); - return candidates.find(isFile) ?? null; -} - -function specifiersIn(text: string): string[] { - const found = new Set(); - for (const pattern of SPECIFIER_PATTERNS) { - pattern.lastIndex = 0; - let match = pattern.exec(text); - while (match !== null) { - found.add(match[1]); - match = pattern.exec(text); - } - } - return [...found]; -} - -type Analysis = { - files: string[]; - edges: string[]; - violations: ReadonlyArray<{ key: string; rule: string }>; - unresolved: string[]; -}; - -function analyze(): Analysis { - const files: string[] = ['bin.ts']; - collectFiles(path.join(REPO_ROOT, 'src'), files); - files.sort(); - - const aliases = loadAliases(); - const edges = new Set(); - const violations = new Map(); - const unresolved = new Set(); - - for (const file of files) { - const text = stripComments( - fs.readFileSync(path.join(REPO_ROOT, file), 'utf8'), - ); - const from = classifySurface(file); - const allowed = ALLOWED_IMPORTS[from]; - - for (const spec of specifiersIn(text)) { - const base = spec.startsWith('.') - ? path.resolve(REPO_ROOT, path.dirname(file), spec) - : aliasTarget(spec, aliases); - - if (base === null) { - const tuiOnly = - TUI_ONLY_PACKAGES.includes(spec) || spec.startsWith('react/'); - if (tuiOnly && from !== 'tui') { - violations.set(`${file} -> pkg:${spec}`, 'ink-outside-tui'); - } - continue; - } - - const resolved = probe(base); - if (resolved === null) { - unresolved.add(`${file} -> ${spec}`); - continue; - } - - const target = toRepoRelative(resolved); - const key = `${file} -> ${target}`; - edges.add(key); - - const to = classifySurface(target); - if (!allowed.includes(to)) violations.set(key, `matrix:${from}->${to}`); - } - } - - return { - files, - edges: [...edges].sort(), - violations: [...violations] - .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - .map(([key, rule]) => ({ key, rule })), - unresolved: [...unresolved].sort(), - }; -} - -const analysis = analyze(); - -const known = ( - JSON.parse( - fs.readFileSync(path.join(HERE, 'known-violations.json'), 'utf8'), - ) as { violations: string[] } -).violations; - -if (process.env.PRINT_VIOLATIONS) { - const byRule = new Map(); - const byImporter = new Map(); - for (const { key, rule } of analysis.violations) { - byRule.set(rule, (byRule.get(rule) ?? 0) + 1); - const file = key.split(' -> ')[0]; - byImporter.set(file, (byImporter.get(file) ?? 0) + 1); - } - const lines = [ - `files scanned: ${analysis.files.length}`, - `edges: ${analysis.edges.length}`, - `violations: ${analysis.violations.length}`, - `unresolved: ${analysis.unresolved.length}`, - ...analysis.unresolved.map((u) => ` unresolved: ${u}`), - ...[...byRule] - .sort((a, b) => b[1] - a[1]) - .map(([rule, count]) => ` ${rule}: ${count}`), - 'top importers:', - ...[...byImporter] - .sort((a, b) => b[1] - a[1]) - .slice(0, 10) - .map(([file, count]) => ` ${file}: ${count}`), - ]; - process.stderr.write(`${lines.join('\n')}\n`); - process.stderr.write( - `${JSON.stringify( - { violations: analysis.violations.map((v) => v.key) }, - null, - 2, - )}\n`, - ); -} - -describe('import boundaries', () => { - it('resolves every internal specifier', () => { - expect(analysis.unresolved).toEqual([]); - }); - - it('introduces no violation outside known-violations.json', () => { - const knownSet = new Set(known); - const added = analysis.violations - .filter(({ key }) => !knownSet.has(key)) - .map(({ key, rule }) => `${key} [${rule}]`); - expect(added).toEqual([]); - }); - - it('keeps known-violations.json free of stale entries', () => { - const current = new Set(analysis.violations.map((v) => v.key)); - const stale = known.filter((key) => !current.has(key)); - expect( - stale, - 'stale entries, delete them from known-violations.json', - ).toEqual([]); - }); -}); - -describe('surface classification', () => { - it('maps representative paths to their surface', () => { - expect(classifySurface('src/env.ts')).toBe('env'); - expect(classifySurface('src/utils/analytics.ts')).toBe('legacy'); - expect(classifySurface('src/lib/agent/agent-runner.ts')).toBe('agent'); - expect(classifySurface('src/ui/tui/App.tsx')).toBe('tui'); - expect(classifySurface('bin.ts')).toBe('cli'); - expect(classifySurface('src/lib/wizard-tools/mcp.ts')).toBe('agent'); - expect(classifySurface('src/lib/wizard-tools/tools.ts')).toBe('legacy'); - expect(classifySurface('src/commands/factories/family-picker.tsx')).toBe( - 'tui', - ); - expect( - classifySurface('src/lib/programs/posthog-integration/content/index.tsx'), - ).toBe('tui'); - expect( - classifySurface('src/lib/programs/posthog-integration/index.ts'), - ).toBe('legacy'); - }); -}); diff --git a/src/__tests__/architecture/known-violations.json b/src/__tests__/architecture/known-violations.json deleted file mode 100644 index d523b2a73..000000000 --- a/src/__tests__/architecture/known-violations.json +++ /dev/null @@ -1,76 +0,0 @@ -{ - "violations": [ - "src/commands/factories/family-picker.tsx -> src/commands/command.ts", - "src/env.ts -> src/lib/headless-mode.ts", - "src/lib/agent/mcp-prompt-streaming.ts -> src/ui/tui/services/mcp-suggested-prompts-services.ts", - "src/lib/detection/agentic.ts -> src/lib/agent/agent-interface.ts", - "src/lib/detection/project-scope.ts -> src/lib/agent/runner/shared/authenticate.ts", - "src/lib/errors/agent-map.ts -> src/lib/agent/signals.ts", - "src/lib/programs/agent-skill/index.ts -> src/lib/agent/agent-runner.ts", - "src/lib/programs/agent-skill/index.ts -> src/lib/programs/agent-skill/content/index.tsx", - "src/lib/programs/ai-observability/index.ts -> src/lib/programs/agent-skill/content/index.tsx", - "src/lib/programs/audit/detect.ts -> src/lib/agent/agent-runner.ts", - "src/lib/programs/audit/index.ts -> src/lib/agent/agent-runner.ts", - "src/lib/programs/dispatch-family.ts -> src/commands/command.ts", - "src/lib/programs/dispatch-family.ts -> src/commands/factories/shared.ts", - "src/lib/programs/error-tracking-upload-source-maps/detect.ts -> src/lib/agent/agent-runner.ts", - "src/lib/programs/error-tracking-upload-source-maps/index.ts -> src/lib/agent/agent-runner.ts", - "src/lib/programs/error-tracking-upload-source-maps/index.ts -> src/lib/programs/error-tracking-upload-source-maps/content/index.tsx", - "src/lib/programs/error-tracking-upload-source-maps/prompt.ts -> src/lib/agent/agent-interface.ts", - "src/lib/programs/error-tracking/index.ts -> src/lib/agent/runner/shared/types.ts", - "src/lib/programs/error-tracking/index.ts -> src/lib/programs/error-tracking/content/index.tsx", - "src/lib/programs/error-tracking/index.ts -> src/lib/programs/error-tracking/content/tips.ts", - "src/lib/programs/events-audit/index.ts -> src/lib/agent/agent-runner.ts", - "src/lib/programs/mcp-analytics/index.ts -> src/lib/agent/agent-runner.ts", - "src/lib/programs/metrics/index.ts -> src/lib/programs/agent-skill/content/index.tsx", - "src/lib/programs/migration/index.ts -> src/lib/agent/agent-runner.ts", - "src/lib/programs/migration/index.ts -> src/lib/programs/migration/content/index.tsx", - "src/lib/programs/posthog-integration/index.ts -> src/lib/agent/agent-interface.ts", - "src/lib/programs/posthog-integration/index.ts -> src/lib/agent/agent-runner.ts", - "src/lib/programs/posthog-integration/index.ts -> src/lib/agent/runner/shared/bootstrap.ts", - "src/lib/programs/posthog-integration/index.ts -> src/lib/programs/posthog-integration/content/index.tsx", - "src/lib/programs/program-registry.ts -> src/lib/programs/agent-skill/content/index.tsx", - "src/lib/programs/program-step.ts -> src/lib/agent/agent-runner.ts", - "src/lib/programs/program-step.ts -> src/ui/tui/components/TipsCard.tsx", - "src/lib/programs/program-step.ts -> src/ui/tui/primitives/index.ts", - "src/lib/programs/program-step.ts -> src/ui/tui/store.ts", - "src/lib/programs/replay-vision/index.ts -> src/lib/agent/agent-runner.ts", - "src/lib/programs/revenue-analytics/detect.ts -> src/lib/agent/agent-runner.ts", - "src/lib/programs/revenue-analytics/index.ts -> src/lib/programs/revenue-analytics/content/index.tsx", - "src/lib/programs/self-driving/detect.ts -> src/lib/agent/agent-runner.ts", - "src/lib/programs/self-driving/index.ts -> src/lib/agent/agent-runner.ts", - "src/lib/programs/self-driving/index.ts -> src/lib/programs/self-driving/content/index.tsx", - "src/lib/programs/self-driving/index.ts -> src/lib/programs/self-driving/content/pricing.ts", - "src/lib/programs/self-driving/index.ts -> src/lib/programs/self-driving/content/tips.ts", - "src/lib/programs/self-driving/prompt.ts -> src/lib/agent/agent-interface.ts", - "src/lib/programs/self-driving/prompt.ts -> src/lib/agent/agent-runner.ts", - "src/lib/programs/warehouse-source/detect.ts -> src/lib/agent/agent-runner.ts", - "src/lib/programs/warehouse-source/index.ts -> src/lib/agent/agent-runner.ts", - "src/lib/programs/warehouse-source/index.ts -> src/lib/programs/warehouse-source/content/index.tsx", - "src/lib/programs/web-analytics-doctor/detect.ts -> src/lib/agent/agent-runner.ts", - "src/lib/task-stream/event-plan-watcher.ts -> src/ui/tui/store.ts", - "src/lib/task-stream/task-stream-push.ts -> src/ui/tui/store.ts", - "src/lib/wizard-session.ts -> src/lib/agent/claude-settings.ts", - "src/lib/wizard-tools/index.ts -> src/lib/wizard-tools/mcp.ts", - "src/lib/wizard-tools/tools.ts -> src/lib/yara-hooks.ts", - "src/steps/add-mcp-server-to-clients/index.ts -> src/telemetry.ts", - "src/steps/add-or-update-environment-variables.ts -> src/telemetry.ts", - "src/steps/run-prettier.ts -> src/telemetry.ts", - "src/steps/upload-environment-variables/index.ts -> src/telemetry.ts", - "src/ui/index.ts -> src/ui/logging-ui.ts", - "src/ui/logging-ui.ts -> src/lib/agent/claude-settings.ts", - "src/ui/tui/components/PhaseVisuals.tsx -> src/lib/agent/agent-phase.ts", - "src/ui/tui/components/TokenCostHud.tsx -> src/lib/agent/token-pricing.ts", - "src/ui/tui/exit-line.ts -> src/lib/agent/token-pricing.ts", - "src/ui/tui/ink-ui.ts -> src/lib/agent/claude-settings.ts", - "src/ui/tui/playground/demos/RunScreenDemo.tsx -> src/lib/agent/agent-phase.ts", - "src/ui/tui/screens/ManagedSettingsScreen.tsx -> src/lib/agent/claude-settings.ts", - "src/ui/tui/services/mcp-suggested-prompts-services.ts -> src/lib/agent/mcp-prompt-streaming.ts", - "src/ui/tui/store.ts -> src/lib/agent/claude-settings.ts", - "src/ui/tui/store.ts -> src/lib/agent/token-pricing.ts", - "src/ui/wizard-ui.ts -> src/lib/agent/claude-settings.ts", - "src/utils/package-manager.ts -> src/telemetry.ts", - "src/utils/setup-utils.ts -> src/telemetry.ts", - "src/utils/wizard-abort.ts -> src/ui/logging-ui.ts" - ] -} diff --git a/src/__tests__/cli.test.ts b/src/__tests__/cli.test.ts index 2b8a723f7..e4ebbe951 100644 --- a/src/__tests__/cli.test.ts +++ b/src/__tests__/cli.test.ts @@ -112,7 +112,7 @@ vi.mock('../utils/wizard-abort', async (importOriginal) => ({ ...(await importOriginal()), wizardAbort: vi.fn(), })); -vi.mock('../lib/agent/agent-runner', () => ({ +vi.mock('../lib/programs/run-agent-legacy', () => ({ runAgent: vi.fn().mockResolvedValue(undefined), })); diff --git a/src/__tests__/provision-cli.test.ts b/src/__tests__/provision-cli.test.ts index d1b561ba4..10b6539b2 100644 --- a/src/__tests__/provision-cli.test.ts +++ b/src/__tests__/provision-cli.test.ts @@ -63,7 +63,7 @@ vi.mock('../utils/wizard-abort', async (importOriginal) => ({ ...(await importOriginal()), wizardAbort: vi.fn(), })); -vi.mock('../lib/agent/agent-runner', () => ({ +vi.mock('../lib/programs/run-agent-legacy', () => ({ runAgent: vi.fn().mockResolvedValue(undefined), })); diff --git a/src/lib/__tests__/agent-interface.test.ts b/src/lib/__tests__/agent-interface.test.ts index 3c48975be..7ca36c2ca 100644 --- a/src/lib/__tests__/agent-interface.test.ts +++ b/src/lib/__tests__/agent-interface.test.ts @@ -12,7 +12,6 @@ import { import { AgentOutputSignals } from '@lib/agent/output-signals'; import { RESUME_INSTRUCTION } from '@lib/agent/signals'; import { analytics } from '@utils/analytics'; -import { wizardAbort } from '@utils/wizard-abort'; import { Sequence } from '@lib/constants'; import type { WizardRunOptions } from '@utils/types'; import type { SpinnerHandle } from '@ui'; @@ -24,11 +23,6 @@ import { // Mock dependencies vi.mock('../../utils/analytics'); vi.mock('../../utils/debug'); -// wizardAbort exits the process; the 401 tests below need it to just reject. -vi.mock('@utils/wizard-abort', async (importOriginal) => ({ - ...(await importOriginal()), - wizardAbort: vi.fn(), -})); // Mock the SDK module const mockQuery = vi.fn(); @@ -695,6 +689,10 @@ describe('subprocess gateway credentials', () => { describe('gateway re-mint on 401', () => { const spinner = { start: vi.fn(), stop: vi.fn(), message: vi.fn() }; + // Where the run reports the auth screen; stands where getUI() used to. + const emit = vi.fn(); + const authErrors = () => + emit.mock.calls.filter(([e]) => e.kind === 'authError'); const options: WizardRunOptions = { debug: false, installDir: '/test/dir', @@ -722,6 +720,7 @@ describe('gateway re-mint on 401', () => { triageProvider: () => Promise.resolve('false_positive'), gatewayAuth, refreshGatewayAuth, + emit, }); const run = (cfg: ReturnType) => runAgent(cfg, 'test prompt', options, spinner as unknown as SpinnerHandle, { @@ -778,7 +777,7 @@ describe('gateway re-mint on 401', () => { beforeEach(() => { vi.clearAllMocks(); mockUIInstance.spinner.mockReturnValue(spinner); - vi.mocked(wizardAbort).mockRejectedValue(new Error('wizardAbort: exit')); + emit.mockReset(); }); it('mints once and resumes the session when an aged bearer is rejected', async () => { @@ -794,7 +793,7 @@ describe('gateway re-mint on 401', () => { expect(result).toEqual({}); expect(refresh).toHaveBeenCalledTimes(1); - expect(wizardAbort).not.toHaveBeenCalled(); + expect(authErrors()).toHaveLength(0); expect(mockQuery).toHaveBeenCalledTimes(2); const [first, second] = mockQuery.mock.calls.map((c) => c[0]); expect(first.options.resume).toBeUndefined(); @@ -833,11 +832,12 @@ describe('gateway re-mint on 401', () => { expect(refresh).toHaveBeenCalledTimes(1); expect(mockQuery).toHaveBeenCalledTimes(2); - expect(mockUIInstance.showAuthError).toHaveBeenCalledTimes(1); - expect(wizardAbort).toHaveBeenCalledTimes(1); - // In production wizardAbort exits; the mocked rejection surfaces as the - // run's API error. - expect(result.error).toBe('WIZARD_API_ERROR'); + expect(authErrors()).toHaveLength(1); + // The auth screen is reported, and the decided failure goes back to the + // caller, which owns the exit. + expect(result.error).toBeUndefined(); + expect(result.failure?.message).toBe('Authentication failed (401)'); + expect(result.failure?.code).toBeDefined(); }); it('judges a failed resumed session on its own error, not the old 401', async () => { @@ -874,7 +874,7 @@ describe('gateway re-mint on 401', () => { expect(result.error).toBe('WIZARD_API_ERROR'); expect(result.message).toContain('500'); expect(result.message).not.toContain('401'); - expect(mockUIInstance.showAuthError).not.toHaveBeenCalled(); + expect(authErrors()).toHaveLength(0); }); it('does not re-mint when a fresh bearer is rejected', async () => { @@ -888,9 +888,8 @@ describe('gateway re-mint on 401', () => { // A fresh token the gateway rejects is a bad credential, not age. expect(refresh).not.toHaveBeenCalled(); expect(mockQuery).toHaveBeenCalledTimes(1); - expect(mockUIInstance.showAuthError).toHaveBeenCalledTimes(1); - expect(wizardAbort).toHaveBeenCalledTimes(1); - expect(result.error).toBe('WIZARD_API_ERROR'); + expect(authErrors()).toHaveLength(1); + expect(result.failure?.message).toBe('Authentication failed (401)'); }); }); diff --git a/src/lib/agent/__tests__/run-agent-standalone.test.ts b/src/lib/agent/__tests__/run-agent-standalone.test.ts new file mode 100644 index 000000000..9a08c7569 --- /dev/null +++ b/src/lib/agent/__tests__/run-agent-standalone.test.ts @@ -0,0 +1,388 @@ +/** + * `runAgent(config, input, options)` runs with no UI, no store, no session and + * no program registry: a fake harness stands in for the SDK, and everything + * the run reports arrives through `onProgress` or comes back in the result. + * + * The `@ui` mock below throws on use. It is never reached — that is the + * assertion the whole file rests on. + */ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { Harness, Sequence } from '@lib/constants'; +import { HostResolution } from '@lib/host-resolution'; +import { OutroKind, type PendingQuestion } from '@lib/wizard-session'; +import { AGENT_ERROR_CODE, ErrorCodes } from '@lib/errors'; +import { AgentErrorType } from '@lib/agent/signals'; +import type { AgentProgress } from '@lib/agent/progress'; +import type { + AgentHarness, + BackendRunInputs, +} from '@lib/agent/runner/harness/types'; + +vi.mock('@ui', () => ({ + getUI: () => { + throw new Error('the agent reached for getUI()'); + }, + setUI: () => { + throw new Error('the agent reached for setUI()'); + }, +})); +vi.mock('@utils/debug'); +vi.mock('@utils/analytics', () => ({ + analytics: { + build: 'test', + runId: 'run-1', + wizardCapture: vi.fn(), + capture: vi.fn(), + captureException: vi.fn(), + setTag: vi.fn(), + shutdown: vi.fn().mockResolvedValue(undefined), + }, +})); +vi.mock('@lib/gateway-session', async (importOriginal) => ({ + ...(await importOriginal()), + gatewayAuth: vi.fn().mockResolvedValue({ + gatewayUrl: 'https://gateway.test', + token: 'phe_run', + teamId: 1, + refreshAtMs: Date.now() + 3_600_000, + }), +})); + +// The fake harness: reports a little of everything, then returns what the +// current test told it to. +const harnessState = vi.hoisted(() => ({ + result: {} as { error?: string; message?: string; failure?: unknown }, + throws: undefined as Error | undefined, + lastInputs: undefined as unknown, + askQuestions: undefined as PendingQuestion['questions'] | undefined, +})); +vi.mock('@lib/agent/runner/switchboard/harness', () => { + const fake: AgentHarness = { + name: Harness.pi, + async run(inputs: BackendRunInputs) { + harnessState.lastInputs = inputs; + const { emit, spinner } = inputs; + emit({ kind: 'log', level: 'step', message: 'Initializing agent' }); + spinner.start('Working'); + emit({ kind: 'status', message: 'Installing the SDK' }); + emit({ + kind: 'tasks', + tasks: [{ content: 'Install', status: 'completed' }], + }); + emit({ kind: 'url', which: 'dashboard', url: 'https://d/1' }); + emit({ + kind: 'usage', + delta: { + inputTokens: 10, + outputTokens: 5, + cacheReadTokens: 0, + cacheCreationTokens: 0, + cacheCreation5m: 0, + cacheCreation1h: 0, + }, + }); + if (harnessState.askQuestions && inputs.askBridge) { + const { answers } = await inputs.askBridge.request({ + questions: harnessState.askQuestions, + }); + emit({ + kind: 'status', + message: `answered:${JSON.stringify(answers)}`, + }); + } + if (harnessState.throws) throw harnessState.throws; + spinner.stop('Done'); + return harnessState.result as never; + }, + }; + return { + HARNESS_OPTIONS: { [Harness.pi]: fake }, + getHarness: () => fake, + resolveHarness: () => ({ harness: Harness.pi, model: 'm' }), + }; +}); + +import { runAgent } from '@lib/agent/runner'; +import type { RunConfig, RunInput } from '@lib/agent/runner'; +import { analytics } from '@utils/analytics'; + +let tmp: string; + +const config = (over: Partial = {}): RunConfig => ({ + programId: 'test-program', + run: { + integrationLabel: 'test-integration', + spinnerMessage: 'Working...', + successMessage: 'Done!', + estimatedDurationMinutes: 1, + reportFile: 'report.md', + docsUrl: 'https://docs.test', + abortCases: [ + { + match: /no stripe/i, + message: 'No Stripe here', + body: 'Stripe is required.', + errorCode: ErrorCodes.AgentAbort, + }, + ], + }, + composed: false, + binding: { sequence: Sequence.linear, harness: Harness.pi, model: 'm' }, + switchboard: { program: 'test-program', flags: {} }, + skillsBaseUrl: 'https://skills.test', + wizardFlags: {}, + wizardFlagPayloads: {}, + wizardMetadata: {}, + ...over, +}); + +const input = (over: Partial = {}): RunInput => ({ + installDir: tmp, + credentials: { + accessToken: 'tok', + projectApiKey: 'phc_test', + host: HostResolution.fromApiHost('https://us.posthog.com'), + projectId: 1, + }, + project: null, + apiUser: null, + skillId: 'test-integration', + flags: { + ci: false, + signup: false, + debug: false, + e2eAsk: false, + localMcp: false, + captureAio: false, + benchmark: false, + yaraReport: false, + }, + host: {}, + ...over, +}); + +beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'run-agent-standalone-')); + harnessState.result = {}; + harnessState.throws = undefined; + harnessState.lastInputs = undefined; + harnessState.askQuestions = undefined; + vi.mocked(analytics.shutdown).mockClear(); +}); + +afterEach(() => fs.rmSync(tmp, { recursive: true, force: true })); + +describe('runAgent standalone', () => { + it('runs to success with an observer and an answerer', async () => { + const events: AgentProgress[] = []; + const ask = vi.fn(() => Promise.resolve({ q1: 'yes' })); + harnessState.askQuestions = [ + { id: 'q1', prompt: 'Continue?', kind: 'single' as const }, + ]; + + const result = await runAgent(config(), input(), { + onProgress: (e) => events.push(e), + interaction: { ask }, + }); + + expect(result.outcome).toBe('success'); + expect(result.skillId).toBe('test-integration'); + expect(result.outro).toEqual({ + kind: OutroKind.Success, + message: 'Done!', + reportFile: 'report.md', + docsUrl: 'https://docs.test', + continueUrl: undefined, + }); + expect(result.failure).toBeUndefined(); + + // The answerer saw the question the bridge built, and its answer came back. + expect(ask).toHaveBeenCalledTimes(1); + const [question] = ask.mock.calls[0] as unknown as [PendingQuestion]; + expect(question.questions[0].id).toBe('q1'); + expect(question.source).toBe('test-integration'); + expect(events).toContainEqual({ + kind: 'status', + message: 'answered:{"q1":"yes"}', + }); + + // Emission order: started first, completion then completed last. + const kinds = events.map( + (e) => `${e.kind}${'phase' in e ? `:${e.phase}` : ''}`, + ); + expect(kinds[0]).toBe('lifecycle:started'); + expect(kinds.slice(-2)).toEqual(['completion', 'lifecycle:completed']); + + // The agent's own snapshot, independent of the observer. + expect(result.snapshot.dashboardUrl).toBe('https://d/1'); + expect(result.snapshot.tasks).toEqual([ + { content: 'Install', status: 'completed' }, + ]); + expect(result.snapshot.statusMessages[0]).toBe('Installing the SDK'); + expect(result.snapshot.usage).toEqual({ + inputTokens: 10, + outputTokens: 5, + cacheReadTokens: 0, + cacheCreationTokens: 0, + }); + expect(analytics.shutdown).toHaveBeenCalledWith('success'); + }); + + it('runs to a complete result with no options at all', async () => { + const result = await runAgent(config(), input()); + + expect(result.outcome).toBe('success'); + expect(result.outro?.kind).toBe(OutroKind.Success); + expect(result.snapshot.dashboardUrl).toBe('https://d/1'); + // No answerer: no bridge is installed, so `wizard_ask` reports unavailable + // rather than hanging. + const inputs = harnessState.lastInputs as BackendRunInputs; + expect(inputs.askBridge).toBeUndefined(); + }); + + it('installs no bridge in CI even with an answerer, as before', async () => { + await runAgent( + config(), + input({ + flags: { + ci: true, + signup: false, + debug: false, + e2eAsk: false, + localMcp: false, + captureAio: false, + benchmark: false, + yaraReport: false, + }, + }), + { interaction: { ask: () => Promise.resolve({}) } }, + ); + const inputs = harnessState.lastInputs as BackendRunInputs; + expect(inputs.askBridge).toBeUndefined(); + }); + + it('returns an agent abort as a decided failure with the matched case', async () => { + harnessState.result = { + error: AgentErrorType.ABORT, + message: 'No Stripe found', + }; + const events: AgentProgress[] = []; + + const result = await runAgent(config(), input(), { + onProgress: (e) => events.push(e), + }); + + expect(result.outcome).toBe('aborted'); + expect(result.failure?.code).toBe(ErrorCodes.AgentAbort); + expect(result.failure?.outroData).toMatchObject({ + kind: OutroKind.Error, + message: 'No Stripe here', + body: 'Stripe is required.', + errorDetail: { reason: 'No Stripe found' }, + }); + expect(result.failure?.error?.message).toBe( + 'Agent aborted: No Stripe found', + ); + expect(events.some((e) => e.kind === 'completion')).toBe(false); + expect(analytics.shutdown).not.toHaveBeenCalled(); + }); + + it('returns a coded failure for a harness error', async () => { + harnessState.result = { error: AgentErrorType.NO_PROGRESS }; + + const result = await runAgent(config(), input()); + + expect(result.outcome).toBe('failed'); + expect(result.failure?.code).toBe( + AGENT_ERROR_CODE[AgentErrorType.NO_PROGRESS], + ); + expect(result.failure?.message).toContain('without changing your project'); + }); + + it('passes a harness-decided failure through untouched', async () => { + const failure = { code: ErrorCodes.AgentAbort, message: 'decided' }; + harnessState.result = { failure }; + + const result = await runAgent(config(), input()); + + expect(result.outcome).toBe('failed'); + expect(result.failure).toBe(failure); + }); + + it('skips the terminal outro and the shutdown for a composed sub-run', async () => { + const events: AgentProgress[] = []; + + const result = await runAgent(config({ composed: true }), input(), { + onProgress: (e) => events.push(e), + }); + + expect(result.outcome).toBe('success'); + expect(result.outro).toBeUndefined(); + expect(events.some((e) => e.kind === 'completion')).toBe(false); + expect( + events.some((e) => e.kind === 'lifecycle' && e.phase === 'completed'), + ).toBe(false); + expect(analytics.shutdown).not.toHaveBeenCalled(); + }); + + it('finishes when the observer throws on every event', async () => { + const result = await runAgent(config(), input(), { + onProgress: () => { + throw new Error('projection broke'); + }, + }); + + expect(result.outcome).toBe('success'); + expect(result.snapshot.tasks).toHaveLength(1); + }); + + it('calls the bound hooks with the run credentials', async () => { + const postRun = vi.fn().mockResolvedValue(undefined); + const buildOutroData = vi.fn(() => ({ + kind: OutroKind.Success, + message: 'custom', + })); + + const result = await runAgent( + config({ hooks: { postRun, buildOutroData } }), + input(), + ); + + expect(postRun).toHaveBeenCalledWith( + expect.objectContaining({ projectApiKey: 'phc_test' }), + ); + expect(buildOutroData).toHaveBeenCalledTimes(1); + expect(result.outro).toEqual({ + kind: OutroKind.Success, + message: 'custom', + }); + }); + + it('returns a crash as a result instead of rejecting', async () => { + const boom = new Error('SDK exploded'); + harnessState.throws = boom; + + const result = await runAgent(config(), input()); + + expect(result.outcome).toBe('crashed'); + expect(result.failure?.error).toBe(boom); + expect(result.failure?.message).toBe('SDK exploded'); + expect(result.failure?.code).toBe(ErrorCodes.InternalUnhandled); + // What was reported before the crash survives in the snapshot. + expect(result.snapshot.statusMessages).toContain('Installing the SDK'); + }); + + it('is cancelled by a signal that is already aborted', async () => { + const controller = new AbortController(); + controller.abort(); + + const result = await runAgent(config(), input(), { + signal: controller.signal, + }); + + expect(result.outcome).toBe('cancelled'); + expect(harnessState.lastInputs).toBeUndefined(); + }); +}); diff --git a/src/lib/agent/agent-interface.ts b/src/lib/agent/agent-interface.ts index 815498a25..602923329 100644 --- a/src/lib/agent/agent-interface.ts +++ b/src/lib/agent/agent-interface.ts @@ -6,8 +6,11 @@ import path from 'path'; import * as os from 'os'; import { createRequire } from 'node:module'; -import { getUI, type SpinnerHandle } from '@ui'; -import type { TokenUsageDelta } from '@ui/wizard-ui'; +import type { + ProgressEmitter, + SpinnerHandle, + TokenUsageDelta, +} from './progress'; import { debug, logToFile, initLogFile, getLogFilePath } from '@utils/debug'; import type { WizardRunOptions } from '@utils/types'; import { analytics } from '@utils/analytics'; @@ -27,7 +30,8 @@ import { type AdditionalFeature, ADDITIONAL_FEATURE_PROMPTS, } from '@lib/wizard-session'; -import { wizardAbort, WizardError } from '@utils/wizard-abort'; +import { WizardError } from '@utils/wizard-abort'; +import type { AgentFailure } from './runner/shared/types'; import { createCustomHeaders } from '@utils/custom-headers'; import type { HostResolution } from '@lib/host-resolution'; import { @@ -243,6 +247,12 @@ export type AgentConfig = { * `--capture-aio` is off. Constructed once per run by the harness. */ capture?: AioCapture; + /** + * Where the run reports: log lines, tasks, status, URLs, usage, the handoff + * document and the auth-error detail. Absent → the run reports nowhere and + * still completes. + */ + emit?: ProgressEmitter; }; /** @@ -354,8 +364,12 @@ type AgentRunConfig = { program?: string; /** Resolved sequence, for the sequence-axis commandments. */ sequence: Sequence; + /** Where the run reports. A no-op when the caller passed none. */ + emit?: ProgressEmitter; }; +const NO_PROGRESS: ProgressEmitter = () => undefined; + /** * Global identifiers attached to every LLM gateway trace for a run. They ride on * each `$ai_generation` the gateway emits (in the `X-PostHog-Properties` blob @@ -531,6 +545,7 @@ export async function initializeAgent( initLogFile(); logToFile('Agent initialization starting'); logToFile('Install directory:', options.installDir); + const emit = config.emit ?? NO_PROGRESS; try { // Configure model routing (inherited by the SDK subprocess). All model @@ -661,6 +676,7 @@ export async function initializeAgent( program: config.integrationLabel, // A queue context is present only on a task run; that is the sequence. sequence: config.orchestrator ? Sequence.orchestrator : Sequence.linear, + emit, }; logToFile('Agent config:', { @@ -689,9 +705,11 @@ export async function initializeAgent( return agentRunConfig; } catch (error) { - getUI().log.error( - `Failed to initialize agent: ${(error as Error).message}`, - ); + emit({ + kind: 'log', + level: 'error', + message: `Failed to initialize agent: ${(error as Error).message}`, + }); logToFile('Agent initialization error:', error); debug('Agent initialization error:', error); throw error; @@ -740,7 +758,12 @@ export async function runAgent( onMessage(message: any): void; finalize(resultMessage: any, totalDurationMs: number): any; }, -): Promise<{ error?: AgentErrorType; message?: string }> { +): Promise<{ + error?: AgentErrorType; + message?: string; + failure?: AgentFailure; +}> { + const emit = agentConfig.emit ?? NO_PROGRESS; const { spinnerMessage = 'Customizing your PostHog setup...', successMessage = 'PostHog integration complete', @@ -854,7 +877,7 @@ export async function runAgent( // CostTrackerPlugin.onFinalize uses to correct per-turn drift. const totalCostUsd = Number(lastResultMessage?.total_cost_usd ?? 0); if (totalCostUsd > 0) { - getUI().setFinalTokenCostUsd(totalCostUsd); + emit({ kind: 'finalCost', usd: totalCostUsd }); } try { middleware?.finalize(lastResultMessage, durationMs); @@ -879,6 +902,9 @@ export async function runAgent( let sessionId: string | undefined; let reminted = false; let remintRequested = false; + // A 401 on a fresh bearer: the auth screen was reported, and this is the + // failure the caller ends the run with. The query is aborted to unwind. + let authFailure: AgentFailure | undefined; const agentConfigDir = createIsolatedAgentConfigDir(); try { @@ -1162,6 +1188,7 @@ export async function runAgent( options, spinner, signals, + emit, receivedSuccessResult, tasks, agentConfig.suppressTaskRender ?? false, @@ -1240,15 +1267,20 @@ export async function runAgent( ...authError, sessionExpired, }); - getUI().showAuthError({ - hasSettingsConflict: authError.hasSettingsConflict, - conflicts: authError.conflicts, - usingManagedLogin: authError.usingManagedLogin, - credentialPlaces: authError.credentialPlaces, - sessionExpired, - logFilePath: getLogFilePath(), + emit({ + kind: 'authError', + detail: { + hasSettingsConflict: authError.hasSettingsConflict, + conflicts: authError.conflicts, + usingManagedLogin: authError.usingManagedLogin, + credentialPlaces: authError.credentialPlaces, + sessionExpired, + logFilePath: getLogFilePath(), + }, }); - await wizardAbort({ + // The caller ends the run with this; the query is abandoned here + // where the process used to exit. + authFailure = { code: authCode, message: 'Authentication failed (401)', error: new WizardError( @@ -1264,7 +1296,9 @@ export async function runAgent( }, authCode, ), - }); + }; + abortController.abort(); + break; } try { @@ -1290,6 +1324,7 @@ export async function runAgent( } catch (error) { // The abort we asked for; anything else belongs to the outer catch. if (remintRequested) return 'remint'; + if (authFailure) return 'done'; throw error; } return remintRequested ? 'remint' : 'done'; @@ -1321,6 +1356,12 @@ export async function runAgent( await runQuery(sessionId); } + // A fresh bearer was rejected. The auth screen is already up; hand the + // decided failure to the caller, which owns the exit. + if (authFailure) { + return { failure: authFailure }; + } + // A YARA hook detected a terminal violation and aborted the run. if (yaraViolationReason) { logToFile('Agent error: YARA_VIOLATION'); @@ -1421,14 +1462,19 @@ export async function runAgent( // No API error found, re-throw the original exception spinner.stop(errorMessage); - getUI().log.error(`Error: ${(error as Error).message}`); + emit({ + kind: 'log', + level: 'error', + message: `Error: ${(error as Error).message}`, + }); logToFile('Agent run failed:', error); debug('Full error:', error); throw error; } finally { // Always capture run duration, even on abort/error, so we can alert on - // long runs where the user gave up before completion. - if (!receivedSuccessResult) { + // long runs where the user gave up before completion. A 401 never reached + // this block before (the process exited first), so it still does not count. + if (!receivedSuccessResult && !authFailure) { const durationMs = Date.now() - startTime; analytics.wizardCapture('agent aborted', { duration_ms: durationMs, @@ -1737,6 +1783,7 @@ function handleSDKMessage( options: WizardRunOptions, spinner: SpinnerHandle, signals: AgentOutputSignals, + emit: ProgressEmitter, receivedSuccessResult = false, tasks?: Map, // The orchestrator owns the TUI task panel (it renders its queue). Suppress the @@ -1762,7 +1809,7 @@ function handleSDKMessage( const sorted = Array.from(tasks.values()).sort( (a, b) => rank(a.status) - rank(b.status), ); - getUI().syncTodos(sorted); + emit({ kind: 'tasks', tasks: sorted.map((t) => ({ ...t })) }); }; logToFile(`SDK Message: ${message.type}`, JSON.stringify(message, null, 2)); @@ -1777,7 +1824,7 @@ function handleSDKMessage( // dedup for SDK-retried turns — see addTokenUsage's doc comment), so // this stays live-updating for every run, not just `--benchmark`. const tokenUsageDelta = extractTokenUsageDelta(message); - if (tokenUsageDelta) getUI().addTokenUsage(tokenUsageDelta); + if (tokenUsageDelta) emit({ kind: 'usage', delta: tokenUsageDelta }); // Extract text content from assistant messages const content = message.message?.content; @@ -1797,7 +1844,7 @@ function handleSDKMessage( const statusMatch = block.text.match(statusRegex); if (statusMatch) { const statusText = statusMatch[1].trim(); - getUI().pushStatus(statusText); + emit({ kind: 'status', message: statusText }); spinner.message(statusText); } @@ -1811,7 +1858,11 @@ function handleSDKMessage( ); const dashboardMatch = block.text.match(dashboardRegex); if (dashboardMatch) { - getUI().setDashboardUrl(dashboardMatch[1].trim()); + emit({ + kind: 'url', + which: 'dashboard', + url: dashboardMatch[1].trim(), + }); } // Check for [NOTEBOOK_URL] markers @@ -1824,7 +1875,11 @@ function handleSDKMessage( ); const notebookMatch = block.text.match(notebookRegex); if (notebookMatch) { - getUI().setNotebookUrl(notebookMatch[1].trim()); + emit({ + kind: 'url', + which: 'notebook', + url: notebookMatch[1].trim(), + }); } } @@ -1847,7 +1902,7 @@ function handleSDKMessage( // Mirror the active tool into the Visualizer's "stage" indicator. if (block.type === 'tool_use') { const stage = classifyToolToStage((block as ToolUseBlock).name); - if (stage) getUI().setStage(stage); + if (stage) emit({ kind: 'stage', stage }); } } } @@ -1900,7 +1955,7 @@ function handleSDKMessage( // mode race conditions). Full message already logged above via JSON dump. if (message.errors && !receivedSuccessResult) { for (const err of message.errors) { - getUI().log.error(`Error: ${err}`); + emit({ kind: 'log', level: 'error', message: `Error: ${err}` }); logToFile('ERROR:', err); } } @@ -1915,7 +1970,7 @@ function handleSDKMessage( // Full message already logged above via JSON dump. if (message.errors && !receivedSuccessResult) { for (const err of message.errors) { - getUI().log.error(`Error: ${err}`); + emit({ kind: 'log', level: 'error', message: `Error: ${err}` }); logToFile('ERROR:', err); } } diff --git a/src/lib/agent/agent-runner.ts b/src/lib/agent/agent-runner.ts index 8b92c4ebe..3adf72fd9 100644 --- a/src/lib/agent/agent-runner.ts +++ b/src/lib/agent/agent-runner.ts @@ -1,11 +1,12 @@ /** * Re-export shim. The runner has been split into agent/runner/. * Import from there directly; this shim keeps existing importers working. + * The session-driven `runAgent(programConfig, session)` lives in + * `src/lib/programs/run-agent-legacy.ts`. */ export { runAgent, - runProgram, shouldDisableAsk, type ProgramRun, type BootstrapResult, diff --git a/src/lib/agent/progress.ts b/src/lib/agent/progress.ts new file mode 100644 index 000000000..a85820dd3 --- /dev/null +++ b/src/lib/agent/progress.ts @@ -0,0 +1,100 @@ +/** + * The agent's public progress and interaction contracts. + * + * `runAgent` reports through one optional callback and asks through one + * optional set of capabilities. Neither reaches into a UI singleton, a store, + * or a session: every payload is copied data, every question is awaited on an + * injected answerer. The legacy adapter in `src/lib/programs/run-agent-legacy.ts` + * maps these back onto `WizardUI` one call per event, so the terminal output of + * every existing runner is unchanged. + */ + +import type { + AskAnswers, + OutroData, + PendingQuestion, + TaskNotice, +} from '@lib/wizard-session'; +import type { + AuthErrorDetail, + SpinnerHandle, + TokenUsageDelta, +} from '@ui/wizard-ui'; + +export type { AuthErrorDetail, SpinnerHandle, TokenUsageDelta }; + +/** One task as the host renders it. The same shape `WizardUI.syncTodos` takes. */ +export interface TaskSnapshot { + content: string; + status: string; + activeForm?: string; +} + +export type ProgressLogLevel = 'info' | 'warn' | 'error' | 'success' | 'step'; + +/** + * Everything the agent reports while it runs. One event per former + * `getUI()` call, in the same order, with the same payload, so a reducer that + * maps each case back onto `WizardUI` reproduces today's output exactly. + * + * Payloads are copies. Never a store, a setter, a function or a live + * collection. The callback returns nothing and the agent never branches on it. + */ +export type AgentProgress = + /** The run's main work has started (`WizardUI.startRun`). */ + | { kind: 'lifecycle'; phase: 'started' } + /** The run finished and the host may show its outro (`WizardUI.outro`). */ + | { kind: 'lifecycle'; phase: 'completed'; message: string } + /** The run spinner (`WizardUI.spinner()`), one handle per run. */ + | { + kind: 'spinner'; + action: 'start' | 'stop' | 'message'; + message?: string; + } + /** A log line (`WizardUI.log[level]`). */ + | { kind: 'log'; level: ProgressLogLevel; message: string } + /** A `[STATUS]` line the agent printed (`WizardUI.pushStatus`). */ + | { kind: 'status'; message: string } + /** The full task list, already sorted for display (`WizardUI.syncTodos`). */ + | { kind: 'tasks'; tasks: TaskSnapshot[] } + /** The stage of work derived from the active tool (`WizardUI.setStage`). */ + | { kind: 'stage'; stage: string } + /** A PostHog URL the agent created (`setDashboardUrl` / `setNotebookUrl`). */ + | { kind: 'url'; which: 'dashboard' | 'notebook'; url: string } + /** One assistant turn's token usage (`WizardUI.addTokenUsage`). */ + | { kind: 'usage'; delta: TokenUsageDelta } + /** The SDK's authoritative run cost (`WizardUI.setFinalTokenCostUsd`). */ + | { kind: 'finalCost'; usd: number } + /** The gateway returned 401; a failure follows (`WizardUI.showAuthError`). */ + | { kind: 'authError'; detail: AuthErrorDetail } + /** The run's final outro payload (`WizardUI.setOutroData`). */ + | { kind: 'completion'; outro: OutroData }; + +export type ProgressEmitter = (event: AgentProgress) => void; + +/** + * The questions the agent may need a person (or a script) to answer. Every + * capability is optional. With none supplied the agent installs no ask bridge, + * so `wizard_ask` returns its existing "not available" error, and an optional + * task notice is declined — the same path a `--ci` run takes today. + */ +export interface AgentInteraction { + /** + * Open a question and resolve with the answers. The bridge that calls this + * owns the timeout, the `__cancelled__` sentinel and the analytics; `signal` + * aborts when the run is cancelled so the host can dismiss its overlay. + */ + ask?: ( + question: PendingQuestion, + context: { signal: AbortSignal }, + ) => Promise; + /** Dismiss the in-flight question as cancelled (timeouts call this). */ + cancelAsk?: () => void; + /** Offer an optional step and resolve with whether to keep it. */ + taskNotice?: ( + notice: TaskNotice, + context: { signal: AbortSignal }, + ) => Promise; + /** Dismiss an in-flight task notice as declined (timeouts call this). */ + cancelTaskNotice?: () => void; +} diff --git a/src/lib/agent/runner/README.md b/src/lib/agent/runner/README.md index 86fee7f1b..b9f9bde83 100644 --- a/src/lib/agent/runner/README.md +++ b/src/lib/agent/runner/README.md @@ -39,19 +39,43 @@ for the coordinated change checklist. Five layers, each with its own job. Nothing crosses layers unless it has to. -**The entry point** (`index.ts`) is the front door. It receives a program config -and a session, and orchestrates the run at the highest level. +**The entry point** (`index.ts`) is the front door: + +```ts +runAgent(config: RunConfig, input: RunInput, options?: { + onProgress?: (event: AgentProgress) => void; + interaction?: AgentInteraction; + signal?: AbortSignal; +}): Promise +``` -**Bootstrap** (`shared/bootstrap.ts`) is the on-ramp. Every run starts with the -same setup work — health checks, settings conflicts, OAuth, PostHog feature flag -fetch, MCP URL, AI opt-in gate. Whether the run turns out to be linear or -orchestrator, anthropic or pi, the setup is the same. +`RunConfig` is resolved execution data: the program id, its run definition, the +binding the caller resolved, flags, run tags and tool lists. `RunInput` is the +invocation snapshot: directory, credentials, project, skill. The agent reports +through `onProgress` (one event per former UI call, copied data, never awaited) +and asks through `interaction` (an optional answerer for `wizard_ask` and task +notices). It returns a `RunResult` — outcome, outro, a failure with the same +fields `wizardAbort` takes, and a snapshot of what it reported. It never +renders, never reads a session, never exits the process and never rejects: a +decided failure is `aborted` or `failed`, an error the agent did not decide is +`crashed` with the original error attached. Types live in `shared/types.ts` and +`../progress.ts`. + +Everything the caller decides first — health and settings gates, OAuth, the AI +opt-in gate, post-auth gates, feature flags, token refresh, the binding — lives +in `src/lib/programs/run-agent-legacy.ts`, which also maps progress back onto +`getUI()` for today's runners. + +**Prepare** (`shared/bootstrap.ts`) is the on-ramp inside the agent: logging +targets, the gateway mint and the scan-triage classifier. Whether the run turns +out to be linear or orchestrator, anthropic or pi, the setup is the same. **The switchboard** (`switchboard/`) is the router. Given a program id + the fetched flags + any CLI overrides, it returns a `ProgramBinding` — which query shape (sequence), which agent SDK (harness), which model. Two independent middleware chains, one per axis, apply precedence rules (CLI > flag > program -config > default). This is the only layer that makes routing decisions. +config > default). The caller resolves the run-level binding; the orchestrator +re-resolves the harness per task role from the same inputs. **Sequences** (`sequence/`) are LLM query shapes. Once the switchboard has picked one, that sequence takes over the run and owns _how the LLM's work is @@ -72,8 +96,7 @@ gateway. ## How they connect -- Bootstrap prepares shared services, including triage selected for the resolved - harness. +- Prepare mints the gateway token and builds triage for the resolved harness. - The switchboard knows which sequences and harnesses exist (via its two registries), but not what they do. - A sequence knows how to shape a conversation, but delegates the actual model @@ -84,12 +107,13 @@ Each layer is replaceable. ## Flow -1. Program picked → session built. -2. Bootstrap runs (shared setup, fetches PostHog flags). -3. Switchboard resolves a `ProgramBinding { sequence, harness, model }`. -4. Analytics tags the run with its bindings. -5. Sequence takes over — shapes the LLM's work into one conversation (linear) or - many (orchestrator). -6. Harness drives each conversation through its SDK, using the bound model, on +1. The caller runs its gates, authenticates, fetches PostHog flags and resolves + a `ProgramBinding { sequence, harness, model }`; analytics tags the run. +2. `runAgent(config, input, options)` prepares (mint, triage). +3. Sequence takes over — shapes the LLM's work into one conversation (linear) or + many (orchestrator), reporting through `onProgress`. +4. Harness drives each conversation through its SDK, using the bound model, on the PostHog LLM gateway. -7. Cleanup runs (scan report, settings restore, outro). +5. The scan report flushes; `runAgent` returns a `RunResult`. +6. The caller applies it: an outro is already reported, a decided failure goes + to `wizardAbort`, a crash is rethrown for the runner's own handling. diff --git a/src/lib/agent/runner/harness/anthropic/index.ts b/src/lib/agent/runner/harness/anthropic/index.ts index 4f5e449ec..342175f8c 100644 --- a/src/lib/agent/runner/harness/anthropic/index.ts +++ b/src/lib/agent/runner/harness/anthropic/index.ts @@ -1,6 +1,5 @@ // Supported legacy SDK fallback; both this adapter and Pi implement run and runTask. -import { getUI } from '@ui'; import { Harness } from '@lib/constants'; import { initializeAgent, @@ -9,7 +8,8 @@ import { import { createAioCapture } from '@lib/agent/aio-capture'; import { getLogFilePath, logToFile } from '@utils/debug'; import { detectNodePackageManagers } from '@lib/detection/package-manager'; -import { sessionToOptions } from '@lib/agent/runner/shared/bootstrap'; +import { runOptions } from '@lib/agent/runner/shared/bootstrap'; +import { createEmitLog } from '@lib/agent/runner/shared/progress-collector'; import type { AgentResult, AgentHarness, @@ -22,30 +22,32 @@ export const anthropicBackend: AgentHarness = { async run(inputs: BackendRunInputs): Promise { const { - session, - config, - programConfig, + config: runConfig, + input, boot, + emit, prompt, spinner, askBridge, middleware, model, } = inputs; + const config = runConfig.run; const { skillsBaseUrl, credentials, wizardFlags, wizardMetadata } = boot; const { accessToken, host, projectApiKey } = credentials; + const log = createEmitLog(emit); const capture = createAioCapture({ - enabled: session.captureAio, + enabled: input.flags.captureAio, projectApiKey, apiHost: host.apiHost, runTags: wizardMetadata, }); - getUI().log.step('Initializing Claude agent...'); + log.step('Initializing Claude agent...'); const agent = await initializeAgent( { - workingDirectory: session.installDir, + workingDirectory: input.installDir, posthogMcpUrl: host.mcpUrl, posthogApiKey: accessToken, host, @@ -59,22 +61,22 @@ export const anthropicBackend: AgentHarness = { integrationLabel: config.integrationLabel, askBridge, askMaxQuestions: config.maxQuestions, - allowedTools: programConfig.allowedTools, - disallowedTools: programConfig.disallowedTools, - getPendingQuestion: () => session.pendingQuestion, + allowedTools: runConfig.allowedTools, + disallowedTools: runConfig.disallowedTools, modelOverride: model, capture, + emit, }, - sessionToOptions(session), + runOptions(input), ); - getUI().log.step(`Verbose logs: ${getLogFilePath()}`); - getUI().log.success("Agent initialized. Let's get cooking!"); + log.step(`Verbose logs: ${getLogFilePath()}`); + log.success("Agent initialized. Let's get cooking!"); logToFile('[agent-runner] agent initialized'); return executeAgent( agent, prompt, - sessionToOptions(session), + runOptions(input), spinner, { estimatedDurationMinutes: config.estimatedDurationMinutes, @@ -94,9 +96,10 @@ export const anthropicBackend: AgentHarness = { async runTask(inputs: TaskRunInputs): Promise { const { - session, - programConfig, + config, + input, boot, + emit, prompt, spinner, model, @@ -111,10 +114,10 @@ export const anthropicBackend: AgentHarness = { requestRemark, analyticsProperties, } = inputs; - const options = sessionToOptions(session); + const options = runOptions(input); const capture = createAioCapture({ - enabled: session.captureAio, + enabled: input.flags.captureAio, projectApiKey: boot.credentials.projectApiKey, apiHost: boot.credentials.host.apiHost, runTags: boot.wizardMetadata, @@ -125,7 +128,7 @@ export const anthropicBackend: AgentHarness = { // enqueue_task attribute to the right agent when tasks run in parallel. const agent = await initializeAgent( { - workingDirectory: session.installDir, + workingDirectory: input.installDir, posthogMcpUrl: boot.credentials.host.mcpUrl, posthogApiKey: boot.credentials.accessToken, host: boot.credentials.host, @@ -134,12 +137,13 @@ export const anthropicBackend: AgentHarness = { programId: boot.programId, wizardFlags: boot.wizardFlags, wizardMetadata: boot.wizardMetadata, - integrationLabel: programConfig.id, + integrationLabel: config.programId, // Only a task allowed to ask carries a bridge, so the Write/Edit pause // that rides on a pending question stays inside that task's agent. askBridge, orchestrator, capture, + emit, }, options, ); diff --git a/src/lib/agent/runner/harness/pi/index.ts b/src/lib/agent/runner/harness/pi/index.ts index 092e59718..82c705615 100644 --- a/src/lib/agent/runner/harness/pi/index.ts +++ b/src/lib/agent/runner/harness/pi/index.ts @@ -14,7 +14,6 @@ import fs from 'fs'; import path from 'path'; -import { getUI } from '@ui'; import { getLogFilePath, logToFile } from '@utils/debug'; import { Harness, @@ -41,6 +40,8 @@ import type { TaskRunInputs, } from '../types'; import type { BootstrapResult } from '@lib/agent/runner/shared/types'; +import type { ProgressEmitter } from '@lib/agent/progress'; +import { createEmitLog } from '@lib/agent/runner/shared/progress-collector'; import type { TaskStore } from './tasks'; import { completionFailure, runErrorType } from './completion'; @@ -147,10 +148,19 @@ export function extractText(message: unknown): string { * the MCP creates them) into the outro link, mirroring the anthropic path's * signal parsing (#9). The marker carries the URL the MCP returned. */ -export function applyOutroMarkers(textBlock: string): void { +export function applyOutroMarkers( + textBlock: string, + emit: ProgressEmitter, +): void { const markers: Array<[string, (url: string) => void]> = [ - [AgentSignals.DASHBOARD_URL, (url) => getUI().setDashboardUrl(url)], - [AgentSignals.NOTEBOOK_URL, (url) => getUI().setNotebookUrl(url)], + [ + AgentSignals.DASHBOARD_URL, + (url) => emit({ kind: 'url', which: 'dashboard', url }), + ], + [ + AgentSignals.NOTEBOOK_URL, + (url) => emit({ kind: 'url', which: 'notebook', url }), + ], ]; for (const [marker, apply] of markers) { const idx = textBlock.indexOf(marker); @@ -195,20 +205,22 @@ export const piBackend: AgentHarness = { name: Harness.pi, async run(inputs: BackendRunInputs): Promise { - const { session, boot, prompt, spinner, config, programConfig } = inputs; + const { config: runConfig, input, boot, emit, prompt, spinner } = inputs; + const config = runConfig.run; const modelId = inputs.model; + const log = createEmitLog(emit); const capture = createAioCapture({ - enabled: session.captureAio, + enabled: input.flags.captureAio, projectApiKey: boot.credentials.projectApiKey, apiHost: boot.credentials.host.apiHost, runTags: boot.wizardMetadata, }); // Init banner (parity #5). - getUI().log.step('Initializing Wizard agent...'); - getUI().log.step(`Verbose logs: ${getLogFilePath()}`); - getUI().log.success("Agent initialized. Let's get cooking!"); + log.step('Initializing Wizard agent...'); + log.step(`Verbose logs: ${getLogFilePath()}`); + log.success("Agent initialized. Let's get cooking!"); spinner.start(config.spinnerMessage ?? 'Customizing your PostHog setup...'); @@ -306,11 +318,11 @@ export const piBackend: AgentHarness = { const { createSecurityExtension } = await import('./security'); const security = createSecurityExtension({ - disallowedTools: programConfig.disallowedTools, + disallowedTools: runConfig.disallowedTools, getWizardAskPending: () => askState.pending, triageProvider: boot.triageProvider, // Where pi's bash runs; the rm allowance is confined to this tree. - workingDirectory: session.installDir, + workingDirectory: input.installDir, }); // Pay warlock's WASM-init + rule-compile cost now, off the tool-call @@ -360,11 +372,11 @@ export const piBackend: AgentHarness = { } const resourceLoader = new DefaultResourceLoader({ - cwd: session.installDir, + cwd: input.installDir, agentDir: getAgentDir(), systemPrompt: assembleCommandments({ - program: programConfig.id, + program: runConfig.programId, sequence: Sequence.linear, harness: Harness.pi, caps: { bash: true, posthogMcp }, @@ -390,12 +402,14 @@ export const piBackend: AgentHarness = { const { createWizardPiTaskTools } = await import('./tasks'); const { createDispatchAgentTool } = await import('./subagent'); // Created once so the run loop can read the store for the completion guard. - const wizardTaskTools = createWizardPiTaskTools(); + const wizardTaskTools = createWizardPiTaskTools((tasks) => + emit({ kind: 'tasks', tasks }), + ); // The one bash the agent (and its subagents) may use: every subprocess it // spawns gets a scrubbed env, so no secret or ambient variable reaches an // `npm install`. Shared with the subagent so the lockdown is inherited. const scrubbedBash = withMode( - createBashToolDefinition(session.installDir, { + createBashToolDefinition(input.installDir, { spawnHook: (ctx) => ({ ...ctx, env: buildScrubbedEnv() }), }), 'sequential', @@ -406,18 +420,18 @@ export const piBackend: AgentHarness = { // defaults so we can supply the env-scrubbed bash above; read/edit/write // are the stock definitions. Reads run in parallel so a batched turn of // independent reads executes at once; edit/write/bash stay sequential. - withMode(createReadToolDefinition(session.installDir), 'parallel'), - withMode(createEditToolDefinition(session.installDir), 'sequential'), - withMode(createWriteToolDefinition(session.installDir), 'sequential'), + withMode(createReadToolDefinition(input.installDir), 'parallel'), + withMode(createEditToolDefinition(input.installDir), 'sequential'), + withMode(createWriteToolDefinition(input.installDir), 'sequential'), scrubbedBash, // Native ls/find/grep so the agent explores with proper tools instead // of fence-blocked `bash {ls/find}` (the profiled retry-spirals came // from this gap). Parallel — exploration batches cleanly. - withMode(createLsToolDefinition(session.installDir), 'parallel'), - withMode(createFindToolDefinition(session.installDir), 'parallel'), - withMode(createGrepToolDefinition(session.installDir), 'parallel'), + withMode(createLsToolDefinition(input.installDir), 'parallel'), + withMode(createFindToolDefinition(input.installDir), 'parallel'), + withMode(createGrepToolDefinition(input.installDir), 'parallel'), ...createWizardPiTools({ - workingDirectory: session.installDir, + workingDirectory: input.installDir, skillsBaseUrl: boot.skillsBaseUrl, triageProvider: boot.triageProvider, detectPackageManager: config.detectPackageManager, @@ -431,7 +445,7 @@ export const piBackend: AgentHarness = { }, // Skip wizard_ask when the program disallows it (bare pi tool names // don't match the MCP-prefixed disallow list at the security gate). - disallowedTools: programConfig.disallowedTools, + disallowedTools: runConfig.disallowedTools, }), // Task/todo tools (#526): render the todo list live in the TUI, parity // with the anthropic path. @@ -442,7 +456,7 @@ export const piBackend: AgentHarness = { createDispatchAgentTool({ model, modelRegistry: registry, - cwd: session.installDir, + cwd: input.installDir, agentDir: getAgentDir(), securityFactory: security.factory as (pi: unknown) => void, bashTool: scrubbedBash, @@ -456,8 +470,8 @@ export const piBackend: AgentHarness = { // Reasoning effort from the switchboard capability matrix (undefined = // pi's default). Sent as `reasoning_effort` for openai-completions. thinkingLevel: caps.thinkingLevel, - cwd: session.installDir, - sessionManager: SessionManager.inMemory(session.installDir), + cwd: input.installDir, + sessionManager: SessionManager.inMemory(input.installDir), resourceLoader, // Disable the default built-in tools; `customTools` re-registers // read/edit/write + an env-scrubbed bash, so no subprocess inherits the @@ -508,12 +522,12 @@ export const piBackend: AgentHarness = { const assistant = extractText(event.message).trim(); if (assistant) { logToFile(`[pi] assistant: ${assistant.slice(0, 1000)}`); - applyOutroMarkers(assistant); + applyOutroMarkers(assistant, emit); // Surface [STATUS] lines into the live spinner + status history, // mirroring the anthropic path — pi otherwise drops them. const statusText = lastStatusLine(assistant); if (statusText) { - getUI().pushStatus(statusText); + emit({ kind: 'status', message: statusText }); spinner.message(statusText); } for (const line of assistant.split('\n')) signals.push(line); @@ -644,7 +658,7 @@ export const piBackend: AgentHarness = { // on completion; pi's `rm` is fence-blocked, so the agent can't — clean it // up host-side rather than leave a stale (often empty) artifact (#15). try { - const planFile = path.join(session.installDir, '.posthog-events.json'); + const planFile = path.join(input.installDir, '.posthog-events.json'); if (fs.existsSync(planFile)) await fs.promises.rm(planFile); } catch (err) { logToFile(`[pi] .posthog-events.json cleanup skipped: ${String(err)}`); @@ -668,7 +682,7 @@ export const piBackend: AgentHarness = { const message = err instanceof Error ? err.message : String(err); logToFile(`[pi] run error: ${message}`); spinner.stop(config.errorMessage ?? `${config.integrationLabel} failed`); - getUI().log.error(`pi backend error: ${message}`); + log.error(`pi backend error: ${message}`); const error = runErrorType(message); captureAborted(error); return { error, message }; diff --git a/src/lib/agent/runner/harness/pi/task.ts b/src/lib/agent/runner/harness/pi/task.ts index 37deec62d..9d7adc97c 100644 --- a/src/lib/agent/runner/harness/pi/task.ts +++ b/src/lib/agent/runner/harness/pi/task.ts @@ -16,7 +16,6 @@ * Loaded lazily from `index.ts` (typebox/ESM constraint, same as tools.ts). */ -import { getUI } from '@ui'; import { logToFile } from '@utils/debug'; import { analytics } from '@utils/analytics'; import { @@ -166,9 +165,10 @@ function isSettled(ctx: OrchestratorToolsContext): boolean { export async function runPiTask(inputs: TaskRunInputs): Promise { const { - session, - programConfig, + config, + input, boot, + emit, prompt, spinner, model: modelId, @@ -187,7 +187,7 @@ export async function runPiTask(inputs: TaskRunInputs): Promise { if (spinnerMessage) spinner.start(spinnerMessage); const capture = createAioCapture({ - enabled: session.captureAio, + enabled: input.flags.captureAio, projectApiKey: boot.credentials.projectApiKey, apiHost: boot.credentials.host.apiHost, runTags: boot.wizardMetadata, @@ -319,10 +319,10 @@ export async function runPiTask(inputs: TaskRunInputs): Promise { const orchestratorTools = allowedOrchestratorTools(disallowedTools); const resourceLoader = new DefaultResourceLoader({ - cwd: session.installDir, + cwd: input.installDir, agentDir: getAgentDir(), systemPrompt: assembleCommandments({ - program: programConfig.id, + program: config.programId, sequence: Sequence.orchestrator, harness: Harness.pi, caps: { bash: codingTools.has('bash'), posthogMcp }, @@ -339,7 +339,7 @@ export async function runPiTask(inputs: TaskRunInputs): Promise { // The task's coding tools, gated by its allow list. Reads and searches run // in parallel; mutating tools stay sequential. Bash subprocesses get the // scrubbed env, same as the linear run. - const dir = session.installDir; + const dir = input.installDir; const codingToolFactories = { read: () => withMode(createReadToolDefinition(dir), 'parallel'), edit: () => withMode(createEditToolDefinition(dir), 'sequential'), @@ -436,10 +436,10 @@ export async function runPiTask(inputs: TaskRunInputs): Promise { const assistant = extractText(event.message).trim(); if (assistant) { logToFile(`[pi-task] assistant: ${assistant.slice(0, 1000)}`); - applyOutroMarkers(assistant); + applyOutroMarkers(assistant, emit); const statusText = lastStatusLine(assistant); if (statusText) { - getUI().pushStatus(statusText); + emit({ kind: 'status', message: statusText }); spinner.message(statusText); } for (const line of assistant.split('\n')) signals.push(line); diff --git a/src/lib/agent/runner/harness/pi/tasks.ts b/src/lib/agent/runner/harness/pi/tasks.ts index e12f66e1e..0ee0f4462 100644 --- a/src/lib/agent/runner/harness/pi/tasks.ts +++ b/src/lib/agent/runner/harness/pi/tasks.ts @@ -1,15 +1,15 @@ /** * Task/todo parity for pi (#526). The same four Task tools the anthropic path * exposes (TaskCreate/Update/Get/List), as pi `defineTool` tools backed by a - * shared in-memory store. Every mutation pushes the list to the TUI via - * `getUI().syncTodos`, so the todo panel updates live under pi exactly like the - * anthropic path — the thing that was missing before. + * shared in-memory store. Every mutation reports the list through `onSync` + * (a `tasks` progress event), so the todo panel updates live under pi exactly + * like the anthropic path — the thing that was missing before. */ import { Type } from 'typebox'; import { defineTool } from '@earendil-works/pi-coding-agent'; import type { ToolDefinition } from '@earendil-works/pi-coding-agent'; -import { getUI } from '@ui'; +import type { TaskSnapshot } from '@lib/agent/progress'; export type TaskStatus = 'pending' | 'in_progress' | 'completed'; export interface TaskEntry { @@ -26,22 +26,23 @@ function text(s: string): { return { content: [{ type: 'text', text: s }], details: {} }; } -function syncToTui(store: TaskStore): void { - getUI().syncTodos( - Array.from(store.values()).map((t) => ({ - content: t.content, - status: t.status, - activeForm: t.activeForm, - })), - ); +function snapshot(store: TaskStore): TaskSnapshot[] { + return Array.from(store.values()).map((t) => ({ + content: t.content, + status: t.status, + activeForm: t.activeForm, + })); } -/** Build the four Task tools over a fresh store. */ -export function createWizardPiTaskTools(): { +/** Build the four Task tools over a fresh store. `onSync` gets the list after every mutation. */ +export function createWizardPiTaskTools( + onSync: (tasks: TaskSnapshot[]) => void = () => undefined, +): { tools: ToolDefinition[]; store: TaskStore; } { const store: TaskStore = new Map(); + const syncToTui = (): void => onSync(snapshot(store)); const taskCreate = defineTool({ name: 'TaskCreate', @@ -64,7 +65,7 @@ export function createWizardPiTaskTools(): { status: 'pending', activeForm: args.activeForm, }); - syncToTui(store); + syncToTui(); return text(`Created ${id}`); }, }); @@ -97,7 +98,7 @@ export function createWizardPiTaskTools(): { status: (args.status as TaskStatus) ?? existing.status, activeForm: args.activeForm ?? existing.activeForm, }); - syncToTui(store); + syncToTui(); return text(`Updated ${args.taskId}`); }, }); diff --git a/src/lib/agent/runner/harness/types.ts b/src/lib/agent/runner/harness/types.ts index 67c59e5c9..35c082c21 100644 --- a/src/lib/agent/runner/harness/types.ts +++ b/src/lib/agent/runner/harness/types.ts @@ -13,23 +13,27 @@ * per drained task. A harness without orchestrator support omits the method; * `orchestrator-runner.ts` checks for it at the call site and fails loudly * rather than silently downgrading. + * + * A harness reports through `emit` and never reaches for a UI. It returns an + * error classification, or a decided `failure` the sequence must return as + * the run's result. */ -import type { WizardSession } from '@lib/wizard-session'; import type { AdditionalFeature } from '@lib/wizard-session'; import type { Harness } from '@lib/constants'; -import type { ProgramConfig } from '@lib/programs/program-step'; -import type { SpinnerHandle } from '@ui'; import type { WizardAskBridge } from '@lib/wizard-ask-bridge'; import type { AgentErrorType } from '@lib/agent/agent-interface'; +import type { ProgressEmitter, SpinnerHandle } from '@lib/agent/progress'; import type { OrchestratorToolsContext } from '@lib/agent/runner/sequence/orchestrator/queue-tools'; import type { EffortLevel, ThinkingLevel, } from '@lib/agent/runner/switchboard/models'; import type { - ProgramRun, + AgentFailure, BootstrapResult, + RunConfig, + RunInput, } from '@lib/agent/runner/shared/types'; /** The benchmark/telemetry hook threaded through a run, if enabled. */ @@ -40,14 +44,14 @@ export interface RunMiddleware { /** * Everything a runner needs to run one program. Assembled by `linear.ts` from - * the bootstrap result and the program config; the runner consumes it and never + * the prepared run and the run config; the runner consumes it and never * re-derives run context. */ export interface BackendRunInputs { - session: WizardSession; - config: ProgramRun; - programConfig: ProgramConfig; + config: RunConfig; + input: RunInput; boot: BootstrapResult; + emit: ProgressEmitter; /** The fully assembled prompt. */ prompt: string; /** Installed framework-skill path, when the program installs one. */ @@ -56,7 +60,7 @@ export interface BackendRunInputs { spinner: SpinnerHandle; /** Interactive question bridge; undefined in CI/headless (ask disabled). */ askBridge?: WizardAskBridge; - /** Benchmark middleware, when `session.benchmark` is set. */ + /** Benchmark middleware, when `--benchmark` is set. */ middleware?: RunMiddleware; /** Gateway model id resolved from the (runner, model) pair. */ model: string; @@ -64,8 +68,16 @@ export interface BackendRunInputs { thinkingLevel?: EffortLevel; } -/** What a runner reports back: an error classification, or nothing on success. */ -export type AgentResult = { error?: AgentErrorType; message?: string }; +/** + * What a runner reports back: an error classification, or nothing on success. + * `failure` is a fully decided abort the harness already reported to the host + * (the 401 auth screen); the sequence returns it as the run's result. + */ +export type AgentResult = { + error?: AgentErrorType; + message?: string; + failure?: AgentFailure; +}; /** * One orchestrator-mode unit of work — the seed plan, or one drained task. @@ -75,9 +87,10 @@ export type AgentResult = { error?: AgentErrorType; message?: string }; * them from the program-level config the linear pipeline assembles once. */ export interface TaskRunInputs { - session: WizardSession; - programConfig: ProgramConfig; + config: RunConfig; + input: RunInput; boot: BootstrapResult; + emit: ProgressEmitter; /** The fully assembled per-task or seed prompt. */ prompt: string; spinner: SpinnerHandle; diff --git a/src/lib/agent/runner/index.ts b/src/lib/agent/runner/index.ts index f11bf73c8..6c60112aa 100644 --- a/src/lib/agent/runner/index.ts +++ b/src/lib/agent/runner/index.ts @@ -1,214 +1,129 @@ /** * Unified program runner — dispatcher. * - * Single configurable pipeline for all programs. Each program - * provides a ProgramRun (via the `run` field on ProgramConfig) - * that controls: - * - Whether a skill is pre-installed or discovered at runtime - * - How the agent prompt is built - * - What MCP servers and package manager detector to use - * - What happens after the agent completes + * One callable, `runAgent(config, input, options)`, runs a program's agent + * pipeline to a decided result. `config` is resolved execution data (which + * program, how it is routed, which flags apply); `input` is the invocation + * snapshot (directory, credentials, project); `options` carries an optional + * progress observer, an optional answerer and an optional cancel signal. * - * The pipeline runs a shared bootstrap (logging, health check, settings, OAuth, - * flags, MCP url), then forks. The `orchestrator` variant routes to the - * experimental task-queue runner. Every other variant runs the fixed linear - * pipeline: + * The pipeline prepares the run (logging targets, gateway mint, scan triage), + * then forks. The `orchestrator` variant routes to the task-queue runner. + * Every other variant runs the fixed linear pipeline: * [skill install] → agent init → prompt → run → errors → [postRun] → outro + * + * The agent reports and asks, it never renders, never reads a session, never + * exits the process and never rejects. A decided failure comes back in + * `RunResult.failure` with the same fields `wizardAbort` takes; an error the + * agent did not decide (a refused mint, an SDK crash) comes back as + * `outcome: 'crashed'` with the original error attached, so a caller can keep + * handling it the way it always did. The legacy adapter in + * `src/lib/programs/run-agent-legacy.ts` rebuilds today's session-driven + * behavior on top of this call for every existing caller. */ -import type { WizardSession } from '../../wizard-session'; -import { analytics } from '@utils/analytics'; -import { - Sequence, - WIZARD_ORCHESTRATOR_FLAG_KEY, - WIZARD_SELF_DRIVING_USE_PI_HARNESS_FLAG_KEY, -} from '@lib/constants'; +import { Sequence } from '@lib/constants'; +import { classifyRunFailure } from '@lib/errors'; import { logToFile } from '@utils/debug'; -import { getUI } from '../../../ui'; -import type { ProgramConfig } from '../../programs/program-step'; -import type { ProgramRun, BootstrapResult } from './shared/types'; -import { bootstrapProgram } from './shared/bootstrap'; -import { - getSequence, - resolveBinding, - type ProgramBinding, - type SwitchboardCtx, -} from './switchboard'; +import type { + RunAgentOptions, + RunConfig, + RunInput, + RunResult, +} from './shared/types'; +import { prepareRun } from './shared/bootstrap'; +import { createProgressCollector } from './shared/progress-collector'; +import { getSequence } from './switchboard'; import { flushScanReport } from '../../yara-hooks'; -import { startAuditLedgerWatcher } from '../../programs/audit/ledger-watcher'; -import { registerCleanup } from '../../../utils/wizard-abort'; export type { - ProgramRun, - BootstrapResult, AbortCase, - PromptContext, + AgentFailure, + BootstrapResult, Credentials, + ProgramRun, + PromptContext, + ResolvedBinding, + RunAgentOptions, + RunConfig, + RunFlags, + RunHooks, + RunInput, + RunOutcome, + RunResult, + RunSnapshot, + SeedTaskEntry, } from './shared/types'; +export type { + AgentInteraction, + AgentProgress, + ProgressEmitter, +} from '@lib/agent/progress'; export { shouldDisableAsk } from './shared/bootstrap'; - -/** - * Resolve a ProgramConfig's agent run definition and execute the pipeline. - * Entry point for bin.ts — handles buildRunConfig, bootstrap, and (future) run field. - */ -export async function runAgent( - programConfig: ProgramConfig, - session: WizardSession, - options: { composed?: boolean } = {}, -): Promise { - if (!programConfig.run) { - throw new Error(`Program "${programConfig.id}" has no run configuration.`); - } - - // Before `run()` resolves: an audit seeds the ledger from inside its recipe, - // and a watcher started later would ignore that write as pre-existing. - const ledger = programConfig.auditLedgerFile - ? startAuditLedgerWatcher(session.installDir, programConfig.auditLedgerFile) - : null; - if (ledger) registerCleanup(() => ledger.stop()); - - try { - const runDef = - typeof programConfig.run === 'function' - ? await programConfig.run(session) - : programConfig.run; - - await runProgram(session, runDef, programConfig, options); - } finally { - ledger?.stop(); - } -} +export { resolveBinding } from './switchboard'; +export type { ProgramBinding, SwitchboardCtx } from './switchboard'; /** * Run a program's agent pipeline. * - * Bootstrap → bind the program via the switchboard (resolve which sequence - * and harness will run it, tag both axes) → dispatch to the resolved - * sequence's runner. + * Prepare → dispatch to the sequence the binding names → return its result + * with the agent's own snapshot of what it reported. Missing observers change + * nothing; a throwing observer is logged and the run continues. */ -export async function runProgram( - session: WizardSession, - config: ProgramRun, - programConfig: ProgramConfig, - options: { composed?: boolean } = {}, -): Promise { - const boot = await bootstrapProgram(session, config, programConfig); +export async function runAgent( + config: RunConfig, + input: RunInput, + options: RunAgentOptions = {}, +): Promise { + const collector = createProgressCollector(options.onProgress); + const { emit } = collector; + const signal = options.signal ?? new AbortController().signal; + const log = (message: string) => + emit({ kind: 'log', level: 'info', message }); // Flush the warlock scan report once, at this single seam, on every - // termination path and for every harness (linear, orchestrator, or future): - // - registerCleanup covers the abort/cancel path (wizardAbort runs the - // registered cleanups; the success path never calls them) - // - the finally covers normal completion and any direct throw that unwinds - // through here - // flushScanReport is idempotent (it zeroes scan state), so the overlap is a - // harmless no-op. No harness has to know reporting exists. - registerCleanup(() => flushScanReport(session)); + // termination path and for every harness (linear, orchestrator, or future). + // flushScanReport is idempotent (it zeroes scan state), so a caller that also + // flushes from its own cleanup path sees a harmless no-op. No harness has to + // know reporting exists. try { - const binding = resolveProgramRunner( - session, - programConfig, - boot, - options.composed ?? false, - ); - if (binding.sequence === Sequence.orchestrator) { - getUI().log.info('Task-queue orchestrator enabled.'); + const boot = await prepareRun(config, input); + if (config.binding.sequence === Sequence.orchestrator) { + log('Task-queue orchestrator enabled.'); } - return await getSequence(binding.sequence).run( - session, + logToFile( + `[agent-runner] run program=${config.programId} sequence=${config.binding.sequence}` + + ` harness=${config.binding.harness} composed=${config.composed}`, + ); + const result = await getSequence(config.binding.sequence).run({ config, - programConfig, + input, boot, - options.composed ?? false, - ); + emit, + interaction: options.interaction, + signal, + }); + return { + ...result, + skillId: input.skillId, + snapshot: collector.snapshot(), + }; + } catch (error) { + // Not a decision the agent made. Hand it back whole rather than throw, so + // every ending of a run is a result the caller reads the same way. + const failure = classifyRunFailure(error); + logToFile('[agent-runner] run crashed:', error); + return { + outcome: 'crashed', + skillId: input.skillId, + failure: { + code: failure.code, + message: failure.message, + error: error instanceof Error ? error : new Error(String(error)), + }, + snapshot: collector.snapshot(), + }; } finally { - flushScanReport(session); + flushScanReport({ yaraReport: input.flags.yaraReport }); } } - -/** - * Resolve which sequence and harness will run a program (CLI → PostHog flag → - * per-program binding → default), tag both axes onto analytics, and return the - * binding for downstream dispatch. - * - * The one place `runner/index.ts` reaches into the switchboard — every other - * concern (bootstrap, cleanup, dispatch, per-task per-role harness picks) is - * either upstream or downstream of this call. - */ -function resolveProgramRunner( - session: WizardSession, - programConfig: ProgramConfig, - boot: BootstrapResult, - composed: boolean, -): ProgramBinding { - const ctx = { - program: programConfig.id, - composed, - flags: boot.wizardFlags, - flagPayloads: boot.wizardFlagPayloads, - cliHarness: session.harness, - cliSequence: session.sequence, - cliModel: session.model, - }; - const binding = resolveBinding(ctx); - tagBinding(boot, binding); - captureSwitchboardDecision(ctx, binding); - return binding; -} - -/** - * One event + one log line per run: what entered the switchboard, which - * precedence rung decided each axis, and the final pick. - */ -function captureSwitchboardDecision( - ctx: SwitchboardCtx, - binding: ProgramBinding, -): void { - const trace = ctx.trace ?? {}; - // Unpinned orchestrator runs choose a model per task from the context-mill agent prompts; the orchestrator logs that map once the prompts load. - const perTaskModel = - binding.sequence === Sequence.orchestrator && trace.model === 'binding'; - const model = perTaskModel ? 'chosen-per-task' : binding.model; - const modelSource = perTaskModel ? 'agent-prompts' : trace.model; - analytics.wizardCapture('switchboard resolved', { - program: ctx.program, - flag_self_driving_use_pi_harness: - ctx.flags[WIZARD_SELF_DRIVING_USE_PI_HARNESS_FLAG_KEY], - flag_self_driving_pi_payload: JSON.stringify( - ctx.flagPayloads?.[WIZARD_SELF_DRIVING_USE_PI_HARNESS_FLAG_KEY] ?? null, - ), - flag_orchestrator: ctx.flags[WIZARD_ORCHESTRATOR_FLAG_KEY], - cli_harness: ctx.cliHarness, - cli_sequence: ctx.cliSequence, - cli_model: ctx.cliModel, - harness_source: trace.harness, - model_source: modelSource, - sequence_source: trace.sequence, - harness: binding.harness, - model, - thinking_level: binding.thinkingLevel, - sequence: binding.sequence, - }); - logToFile( - `[switchboard] decision: program=${ctx.program}` + - ` in(orchestrator=${ctx.flags[WIZARD_ORCHESTRATOR_FLAG_KEY] ?? '-'},` + - ` cli=${ctx.cliHarness ?? '-'}/${ctx.cliSequence ?? '-'}/${ - ctx.cliModel ?? '-' - })` + - ` → harness=${binding.harness} (${trace.harness ?? '?'})` + - ` model=${model} (${modelSource ?? '?'})` + - ` sequence=${binding.sequence} (${trace.sequence ?? '?'})`, - ); -} - -/** - * Tag the run with its two routing axes. Sequence is stable for the whole - * run; harness reflects the run-level (default-role) resolution — orchestrator - * per-task calls emit their own `harness` property in their events so per-task - * aggregations attribute correctly. - */ -function tagBinding(boot: BootstrapResult, binding: ProgramBinding): void { - analytics.setTag('sequence', binding.sequence); - analytics.setTag('harness', binding.harness); - boot.wizardMetadata.SEQUENCE = binding.sequence; - boot.wizardMetadata.HARNESS = binding.harness; -} diff --git a/src/lib/agent/runner/sequence/linear.ts b/src/lib/agent/runner/sequence/linear.ts index 94ab86841..38aed52f1 100644 --- a/src/lib/agent/runner/sequence/linear.ts +++ b/src/lib/agent/runner/sequence/linear.ts @@ -1,112 +1,125 @@ /** * The linear pipeline. Single execution path for all non-orchestrator programs, * both skill-based (revenue analytics) and framework-based (core integration). - * The `ProgramRun` controls what varies between them; `programConfig` carries the - * program-level static metadata (tool allow/disallow lists, etc.). + * The `ProgramRun` controls what varies between them; `RunConfig` + * carries the program-level static metadata (tool allow/disallow lists, etc.). + * + * Reports through `emit`, asks through `interaction`, and returns a decided + * `RunResult`. Every former `getUI()` call is one progress event in the same + * place; every former `wizardAbort` is a returned failure with the same + * arguments, so the caller's exit sequence is unchanged. */ -import type { WizardSession } from '../../../wizard-session'; -import { OutroKind } from '../../../wizard-session'; -import { getUI } from '../../../../ui'; +import { OutroKind, type OutroData } from '@lib/wizard-session'; import { AgentErrorType, AgentSignals } from '../../agent-interface'; -import { restoreClaudeSettings } from '../../claude-settings'; import { logToFile } from '../../../../utils/debug'; import { createBenchmarkPipeline } from '../../../middleware/benchmark'; -import { - wizardAbort, - WizardError, - registerCleanup, -} from '../../../../utils/wizard-abort'; +import { WizardError } from '../../../../utils/wizard-abort'; import { ErrorCodes, AGENT_ERROR_CODE } from '@lib/errors'; import { analytics } from '../../../../utils/analytics'; -import { - formatScanReport, - formatYaraAbortMessage, - writeScanReport, -} from '../../../yara-hooks'; +import { formatYaraAbortMessage } from '../../../yara-hooks'; import { installSkillById } from '../../../wizard-tools'; -import { createWizardAskBridge } from '../../../wizard-ask-bridge'; -import type { ProgramConfig } from '../../../programs/program-step'; import { assemblePrompt } from '../../agent-prompt'; -import type { ProgramRun, BootstrapResult } from '../shared/types'; -import { abortOnInstallFailure } from '../shared/errors'; -import { shouldDisableAsk, sessionToOptions } from '../shared/bootstrap'; -import { resolveHarness, getHarness } from '../switchboard'; +import type { + AgentFailure, + RunResult, + RunSnapshot, + SequenceContext, +} from '../shared/types'; +import { installFailure } from '../shared/errors'; +import { shouldDisableAsk, runOptions } from '../shared/bootstrap'; +import { createEmitSpinner } from '../shared/progress-collector'; +import { createAskBridge } from '../shared/ask'; +import { getHarness } from '../switchboard'; -export async function runLinearProgram( - session: WizardSession, - config: ProgramRun, - programConfig: ProgramConfig, - boot: BootstrapResult, - composed = false, -): Promise { - const { skillsBaseUrl, credentials, wizardFlags, project } = boot; +/** The snapshot the sequence returns; `runAgent` fills it from its collector. */ +const PENDING_SNAPSHOT: RunSnapshot = { + tasks: [], + statusMessages: [], + usage: { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + }, +}; + +const failed = (failure: AgentFailure): RunResult => ({ + outcome: 'failed', + failure, + snapshot: PENDING_SNAPSHOT, +}); + +const cancelled = (): RunResult => ({ + outcome: 'cancelled', + failure: { message: 'Wizard setup cancelled.' }, + snapshot: PENDING_SNAPSHOT, +}); + +export async function runLinearProgram({ + config, + input, + boot, + emit, + interaction, + signal, +}: SequenceContext): Promise { + const { run, composed } = config; + const { skillsBaseUrl, credentials, project } = boot; const { projectApiKey, host, projectId } = credentials; + if (signal.aborted) return cancelled(); + // 5. Skill install (if skillId provided) let skillPath: string | undefined; - if (config.skillId) { - logToFile(`[agent-runner] installing skill ${config.skillId}`); + if (run.skillId) { + logToFile(`[agent-runner] installing skill ${run.skillId}`); const installResult = await installSkillById( - config.skillId, - session.installDir, + run.skillId, + input.installDir, skillsBaseUrl, { triage: boot.triageProvider }, ); if (installResult.kind !== 'ok') { - await abortOnInstallFailure(config.integrationLabel, installResult); - return; + return failed(installFailure(run.integrationLabel, installResult)); } skillPath = installResult.path; logToFile(`[agent-runner] skill installed at ${skillPath}`); } - // 6. Initialize agent - const spinner = getUI().spinner(); - - const restoreSettings = () => restoreClaudeSettings(session.installDir); - getUI().onEnterScreen('outro', restoreSettings); + if (signal.aborted) return cancelled(); - if (session.yaraReport) { - registerCleanup(() => { - const reportPath = writeScanReport(); - if (reportPath) { - const summary = formatScanReport(); - getUI().log.info(`YARA scan report: ${reportPath}${summary ?? ''}`); - } - }); - } + // 6. Initialize agent + const spinner = createEmitSpinner(emit); - getUI().startRun(); + emit({ kind: 'lifecycle', phase: 'started' }); // wizard_ask needs an answerer. A human answers at the keyboard; the e2e // snapshot/MCP host answers via its driver and sets WIZARD_ASK_AUTODRIVE. // CI/signup with neither has no answerer, so we omit the bridge and the tool // returns an actionable error rather than hanging on a never-resolving prompt. const askDisabled = - shouldDisableAsk(session) && process.env.WIZARD_ASK_AUTODRIVE !== '1'; - const askBridge = askDisabled + shouldDisableAsk(input.flags) && process.env.WIZARD_ASK_AUTODRIVE !== '1'; + const ask = askDisabled ? undefined - : createWizardAskBridge({ - getSource: () => session.skillId ?? config.integrationLabel, - showQuestion: (q) => getUI().requestQuestion(q), - cancelQuestion: () => getUI().cancelPendingQuestion(), - richLinks: config.richLinks ?? false, - timeoutMs: config.askTimeoutMs, + : createAskBridge(interaction, signal, { + getSource: () => input.skillId ?? run.integrationLabel, + richLinks: run.richLinks ?? false, + timeoutMs: run.askTimeoutMs, }); - const middleware = session.benchmark - ? createBenchmarkPipeline(spinner, sessionToOptions(session)) + const middleware = input.flags.benchmark + ? createBenchmarkPipeline(spinner, runOptions(input)) : undefined; // 7. Build prompt - const prompt = assemblePrompt(config, { + const prompt = assemblePrompt(run, { projectId, projectApiKey, host, skillPath, orgAiDataProcessingApproved: - session.apiUser?.organization?.is_ai_data_processing_approved ?? null, + input.apiUser?.organization?.is_ai_data_processing_approved ?? null, teamProductOptIns: project ? { sessionReplay: project.session_recording_opt_in ?? null, @@ -117,37 +130,34 @@ export async function runLinearProgram( }); logToFile(`[agent-runner] prompt assembled (${prompt.length} chars)`); - // 8. Resolve the (runner, model) pair from the central plan and run the agent - // through the selected runner. The runner owns the agent loop + model - // transport; everything around it (skill install, prompt, ask bridge, error - // routing, outro) stays here so every runner shares it. - const pick = resolveHarness({ - program: programConfig.id, - flags: wizardFlags, - flagPayloads: boot.wizardFlagPayloads, - cliHarness: session.harness, - cliModel: session.model, - }); - const agentResult = await getHarness(pick.harness).run({ - session, + // 8. Run the agent through the run-level harness. The harness owns the agent + // loop + model transport; everything around it (skill install, prompt, ask + // bridge, error routing, outro) stays here so every harness shares it. + const { harness, model, thinkingLevel } = config.binding; + const agentResult = await getHarness(harness).run({ config, - programConfig, + input, boot, + emit, prompt, skillPath, spinner, - askBridge, + askBridge: ask, middleware, - model: pick.model, - thinkingLevel: pick.thinkingLevel, + model, + thinkingLevel, }); - // 9. Error handling (full set from both runners) + // 9. Error handling (full set from both harnesses) + if (agentResult.failure) { + return failed(agentResult.failure); + } + if (agentResult.error === AgentErrorType.ABORT) { const reason = agentResult.message ?? ''; - const matched = config.abortCases?.find((c) => c.match.test(reason)); + const matched = run.abortCases?.find((c) => c.match.test(reason)); const abortCode = matched?.errorCode ?? ErrorCodes.AgentAbort; - const outroData: WizardSession['outroData'] = matched + const outroData: OutroData = matched ? { kind: OutroKind.Error, message: matched.message, @@ -158,44 +168,48 @@ export async function runLinearProgram( } : { kind: OutroKind.Error, - message: `${config.integrationLabel} aborted`, + message: `${run.integrationLabel} aborted`, body: reason || 'The agent aborted the program.', - docsUrl: config.docsUrl, + docsUrl: run.docsUrl, errorCode: abortCode, errorDetail: { reason }, }; analytics.wizardCapture('agent aborted', { - integration: config.integrationLabel, + integration: run.integrationLabel, reason, matched: matched?.message ?? null, }); - await wizardAbort({ - outroData, - code: abortCode, - error: new WizardError( - `Agent aborted: ${reason}`, - { - integration: config.integrationLabel, - error_type: AgentErrorType.ABORT, - reason, - }, - abortCode, - ), - }); + return { + outcome: 'aborted', + failure: { + outroData, + code: abortCode, + error: new WizardError( + `Agent aborted: ${reason}`, + { + integration: run.integrationLabel, + error_type: AgentErrorType.ABORT, + reason, + }, + abortCode, + ), + }, + snapshot: PENDING_SNAPSHOT, + }; } if (agentResult.error === AgentErrorType.MCP_MISSING) { - await wizardAbort({ + return failed({ code: AGENT_ERROR_CODE[AgentErrorType.MCP_MISSING], message: 'Could not access the PostHog MCP server\n\n' + 'The wizard was unable to connect to the PostHog MCP server.\n' + 'This could be due to a network issue or a configuration problem.\n\n' + - `Please try again, or check the documentation:\n${config.docsUrl}`, + `Please try again, or check the documentation:\n${run.docsUrl}`, error: new WizardError( 'Agent could not access PostHog MCP server', { - integration: config.integrationLabel, + integration: run.integrationLabel, error_type: AgentErrorType.MCP_MISSING, signal: AgentSignals.ERROR_MCP_MISSING, }, @@ -205,16 +219,16 @@ export async function runLinearProgram( } if (agentResult.error === AgentErrorType.RESOURCE_MISSING) { - await wizardAbort({ + return failed({ code: AGENT_ERROR_CODE[AgentErrorType.RESOURCE_MISSING], message: 'Could not access the setup resource\n\n' + 'This may indicate a version mismatch or a temporary service issue.\n\n' + - `Please try again, or check the documentation:\n${config.docsUrl}`, + `Please try again, or check the documentation:\n${run.docsUrl}`, error: new WizardError( 'Agent could not access setup resource', { - integration: config.integrationLabel, + integration: run.integrationLabel, error_type: AgentErrorType.RESOURCE_MISSING, signal: AgentSignals.ERROR_RESOURCE_MISSING, }, @@ -224,13 +238,13 @@ export async function runLinearProgram( } if (agentResult.error === AgentErrorType.YARA_VIOLATION) { - await wizardAbort({ + return failed({ code: AGENT_ERROR_CODE[AgentErrorType.YARA_VIOLATION], message: formatYaraAbortMessage(), error: new WizardError( 'YARA scanner terminated session', { - integration: config.integrationLabel, + integration: run.integrationLabel, error_type: AgentErrorType.YARA_VIOLATION, }, AGENT_ERROR_CODE[AgentErrorType.YARA_VIOLATION], @@ -240,10 +254,10 @@ export async function runLinearProgram( if (agentResult.error === AgentErrorType.NO_PROGRESS) { analytics.wizardCapture('agent no progress', { - integration: config.integrationLabel, + integration: run.integrationLabel, error_type: AgentErrorType.NO_PROGRESS, }); - await wizardAbort({ + return failed({ code: AGENT_ERROR_CODE[AgentErrorType.NO_PROGRESS], message: 'The Wizard exited without changing your project. Please contact the ' + @@ -251,7 +265,7 @@ export async function runLinearProgram( error: new WizardError( 'Agent made no progress', { - integration: config.integrationLabel, + integration: run.integrationLabel, error_type: AgentErrorType.NO_PROGRESS, }, AGENT_ERROR_CODE[AgentErrorType.NO_PROGRESS], @@ -261,10 +275,10 @@ export async function runLinearProgram( if (agentResult.error === AgentErrorType.INCOMPLETE_TASKS) { analytics.wizardCapture('agent incomplete tasks', { - integration: config.integrationLabel, + integration: run.integrationLabel, error_type: AgentErrorType.INCOMPLETE_TASKS, }); - await wizardAbort({ + return failed({ code: AGENT_ERROR_CODE[AgentErrorType.INCOMPLETE_TASKS], message: 'The Wizard exited without completing its planned tasks. Please contact ' + @@ -272,7 +286,7 @@ export async function runLinearProgram( error: new WizardError( 'Agent left planned tasks incomplete', { - integration: config.integrationLabel, + integration: run.integrationLabel, error_type: AgentErrorType.INCOMPLETE_TASKS, }, AGENT_ERROR_CODE[AgentErrorType.INCOMPLETE_TASKS], @@ -285,12 +299,12 @@ export async function runLinearProgram( agentResult.error === AgentErrorType.API_ERROR ) { analytics.wizardCapture('agent api error', { - integration: config.integrationLabel, + integration: run.integrationLabel, error_type: agentResult.error, error_message: agentResult.message, }); - await wizardAbort({ + return failed({ code: AGENT_ERROR_CODE[agentResult.error], message: `API Error\n\n${ agentResult.message || 'Unknown error' @@ -298,7 +312,7 @@ export async function runLinearProgram( error: new WizardError( `API error: ${agentResult.message}`, { - integration: config.integrationLabel, + integration: run.integrationLabel, error_type: agentResult.error, }, AGENT_ERROR_CODE[agentResult.error], @@ -307,39 +321,36 @@ export async function runLinearProgram( } // 10. Post-run hooks - if (config.postRun) { - await config.postRun(session, credentials); + if (config.hooks?.postRun) { + await config.hooks.postRun(credentials); } // A composed sub-run (integration inside self-driving) skips the terminal // outro + analytics shutdown so the shared client survives the host's run. - if (composed) return; + if (composed) { + return { outcome: 'success', snapshot: PENDING_SNAPSHOT }; + } // 11. Outro - // Push outro data through the UI (not via direct `session.outroData = ...` - // mutation) so the live store gets the value. agent-runner's `session` - // parameter is captured at runAgent() invocation time, and any `setKey` - // call between then and here (e.g. setDashboardUrl, setNotebookUrl) forks - // the session reference — direct mutation then lands on a stale snapshot - // that the screen never reads. UI.setOutroData() goes through the store - // and also merges in any post-snapshot URLs from the live session. - const outroData = config.buildOutroData - ? config.buildOutroData(session, credentials) + const outroData: OutroData | undefined = config.hooks?.buildOutroData + ? config.hooks.buildOutroData(credentials) : { kind: OutroKind.Success, - message: config.successMessage, - reportFile: config.reportFile, - docsUrl: config.docsUrl, - continueUrl: session.signup + message: run.successMessage, + reportFile: run.reportFile, + docsUrl: run.docsUrl, + continueUrl: input.flags.signup ? `${host.appHost}/products?source=wizard` : undefined, }; if (outroData) { - getUI().setOutroData(outroData); + emit({ kind: 'completion', outro: outroData }); } - getUI().outro(config.successMessage); + emit({ kind: 'lifecycle', phase: 'completed', message: run.successMessage }); // 12. Analytics shutdown await analytics.shutdown('success'); + + return { outcome: 'success', outro: outroData, snapshot: PENDING_SNAPSHOT }; } diff --git a/src/lib/agent/runner/sequence/orchestrator/__tests__/seeded-decline-skip.test.ts b/src/lib/agent/runner/sequence/orchestrator/__tests__/seeded-decline-skip.test.ts index d2379b32c..9ee3fca2a 100644 --- a/src/lib/agent/runner/sequence/orchestrator/__tests__/seeded-decline-skip.test.ts +++ b/src/lib/agent/runner/sequence/orchestrator/__tests__/seeded-decline-skip.test.ts @@ -10,9 +10,6 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -vi.mock('@ui', () => ({ - getUI: () => ({ showTaskNotice: vi.fn(), cancelTaskNotice: vi.fn() }), -})); vi.mock('@utils/analytics', () => ({ analytics: { wizardCapture: vi.fn(), diff --git a/src/lib/agent/runner/sequence/orchestrator/__tests__/task-notice-timeout.test.ts b/src/lib/agent/runner/sequence/orchestrator/__tests__/task-notice-timeout.test.ts index c6acf1014..40c6642f8 100644 --- a/src/lib/agent/runner/sequence/orchestrator/__tests__/task-notice-timeout.test.ts +++ b/src/lib/agent/runner/sequence/orchestrator/__tests__/task-notice-timeout.test.ts @@ -19,9 +19,6 @@ const { showTaskNotice, cancelTaskNotice, wizardCapture, captureException } = captureException: vi.fn(), })); -vi.mock('@ui', () => ({ - getUI: () => ({ showTaskNotice, cancelTaskNotice }), -})); vi.mock('@utils/analytics', () => ({ analytics: { wizardCapture, @@ -38,6 +35,12 @@ import { TASK_NOTICE_TIMEOUT_MS, } from '@lib/agent/runner/sequence/orchestrator/orchestrator-runner'; +/** The answerer under test, standing where `getUI()` used to. */ +const interaction = { + taskNotice: (notice: TaskNotice) => showTaskNotice(notice), + cancelTaskNotice: () => cancelTaskNotice(), +}; + const NOTICE: TaskNotice = { title: 'Connect your data sources', body: ['We found some sources.'], @@ -67,7 +70,7 @@ describe('task notice timeout', () => { // Nobody presses anything. showTaskNotice.mockReturnValue(new Promise(() => undefined)); - const promise = offerSeededTask(NOTICE, 1000); + const promise = offerSeededTask(NOTICE, 1000, interaction); vi.advanceTimersByTime(1000); await expect(promise).resolves.toEqual({ keep: false, timedOut: true }); @@ -83,7 +86,7 @@ describe('task notice timeout', () => { try { showTaskNotice.mockResolvedValue(true); - const result = await offerSeededTask(NOTICE, 1000); + const result = await offerSeededTask(NOTICE, 1000, interaction); expect(result).toEqual({ keep: true, timedOut: false }); vi.advanceTimersByTime(5000); @@ -102,10 +105,12 @@ describe('task notice timeout', () => { // Both decline, but only one of them means "the user was not there" — // the run reports them differently, and now skips them differently too. - await expect(offerSeededTask(NOTICE, 1000)).resolves.toEqual({ - keep: false, - timedOut: false, - }); + await expect(offerSeededTask(NOTICE, 1000, interaction)).resolves.toEqual( + { + keep: false, + timedOut: false, + }, + ); expect(cancelTaskNotice).not.toHaveBeenCalled(); } finally { vi.useRealTimers(); @@ -164,7 +169,9 @@ describe('askSeededConsent', () => { it('records an acceptance', async () => { showTaskNotice.mockResolvedValue(true); - await expect(askSeededConsent('warehouse', NOTICE, 1000)).resolves.toEqual({ + await expect( + askSeededConsent('warehouse', NOTICE, 1000, interaction), + ).resolves.toEqual({ keep: true, timedOut: false, errored: false, @@ -174,7 +181,9 @@ describe('askSeededConsent', () => { it('records an explicit decline', async () => { showTaskNotice.mockResolvedValue(false); - await expect(askSeededConsent('warehouse', NOTICE, 1000)).resolves.toEqual({ + await expect( + askSeededConsent('warehouse', NOTICE, 1000, interaction), + ).resolves.toEqual({ keep: false, timedOut: false, errored: false, @@ -186,7 +195,7 @@ describe('askSeededConsent', () => { try { showTaskNotice.mockReturnValue(new Promise(() => undefined)); - const promise = askSeededConsent('warehouse', NOTICE, 1000); + const promise = askSeededConsent('warehouse', NOTICE, 1000, interaction); vi.advanceTimersByTime(1000); await expect(promise).resolves.toEqual({ @@ -202,7 +211,7 @@ describe('askSeededConsent', () => { it('reports the answer on one event per offer', async () => { showTaskNotice.mockResolvedValue(true); - await askSeededConsent('warehouse', NOTICE, 1000); + await askSeededConsent('warehouse', NOTICE, 1000, interaction); expect(wizardCapture).toHaveBeenCalledTimes(1); expect(wizardCapture).toHaveBeenCalledWith( @@ -216,7 +225,9 @@ describe('askSeededConsent', () => { // that could not be put to the user must never be read as a yes. showTaskNotice.mockRejectedValue(new Error('UI blew up')); - await expect(askSeededConsent('warehouse', NOTICE, 1000)).resolves.toEqual({ + await expect( + askSeededConsent('warehouse', NOTICE, 1000, interaction), + ).resolves.toEqual({ keep: false, timedOut: false, errored: true, @@ -247,7 +258,9 @@ describe('offering notices from the seed loop', () => { const answers: { keep: boolean; timedOut: boolean; errored: boolean }[] = []; for (const entry of entries) { - answers.push(await askSeededConsent(entry.type, NOTICE, 60_000)); + answers.push( + await askSeededConsent(entry.type, NOTICE, 60_000, interaction), + ); } return answers; }; diff --git a/src/lib/agent/runner/sequence/orchestrator/executor.ts b/src/lib/agent/runner/sequence/orchestrator/executor.ts index 616d29442..e17e8c269 100644 --- a/src/lib/agent/runner/sequence/orchestrator/executor.ts +++ b/src/lib/agent/runner/sequence/orchestrator/executor.ts @@ -10,6 +10,7 @@ * injected: the real one spins up a fresh agent, the tests use a fake. */ import { analytics } from '@utils/analytics'; +import type { AgentFailure } from '../../shared/types'; import { logToFile } from '@utils/debug'; import { TaskStatus, type QueueStore, type QueuedTask } from './queue'; @@ -33,6 +34,19 @@ export type TaskResolver = ( * (via the task agent calling complete_task). */ export type RunTask = (task: QueuedTask) => Promise; +/** + * Thrown by a `RunTask` when its harness returned a decided failure that ends + * the whole run (a 401 the auth screen already reported), not just the task. + * `drainQueue` lets it through so the sequence can return the failure where + * the harness used to exit the process. + */ +export class RunTaskFatal extends Error { + constructor(public readonly failure: AgentFailure) { + super(failure.message ?? 'agent run failed'); + this.name = 'RunTaskFatal'; + } +} + export interface DrainOptions { /** Backstop against a pathological always-one-more-pending loop. */ maxStarts: number; @@ -51,6 +65,7 @@ async function runOne( try { await runTask(task); } catch (error) { + if (error instanceof RunTaskFatal) throw error; // The task threw rather than reporting. The outcome check below handles // the queue; the exception itself should never be silent. logToFile(`[executor] runTask threw for ${task.type}:`, error); diff --git a/src/lib/agent/runner/sequence/orchestrator/orchestrator-runner.ts b/src/lib/agent/runner/sequence/orchestrator/orchestrator-runner.ts index 8778431ad..da9b0d4e2 100644 --- a/src/lib/agent/runner/sequence/orchestrator/orchestrator-runner.ts +++ b/src/lib/agent/runner/sequence/orchestrator/orchestrator-runner.ts @@ -20,31 +20,28 @@ import { writeFileSync, } from 'fs'; import * as path from 'path'; -import { - OutroKind, - type TaskNotice, - type WizardSession, -} from '@lib/wizard-session'; -import { - POSTHOG_DOCS_URL, - WIZARD_CONTACT_EMAIL, - type Integration, -} from '@lib/constants'; -import { FRAMEWORK_REGISTRY } from '@lib/registry'; +import { OutroKind, type TaskNotice } from '@lib/wizard-session'; +import { POSTHOG_DOCS_URL, WIZARD_CONTACT_EMAIL } from '@lib/constants'; import { installSkillById, fetchSkillMenu, type SkillEntry, } from '@lib/wizard-tools'; -import { getUI } from '@ui'; import { analytics } from '@utils/analytics'; import { ciExcludedTaskTypes } from '@utils/ci-flag-overrides'; import { logToFile } from '@utils/debug'; import { ringTerminalBell } from '@utils/terminal-bell'; -import { wizardAbort, WizardError } from '@utils/wizard-abort'; +import { WizardError } from '@utils/wizard-abort'; import { ErrorCodes } from '@lib/errors'; -import type { ProgramConfig } from '@lib/programs/program-step'; -import type { BootstrapResult, ProgramRun } from '../../shared/types'; +import type { AgentInteraction } from '@lib/agent/progress'; +import type { + AgentFailure, + RunResult, + RunSnapshot, + SequenceContext, +} from '../../shared/types'; +import { createEmitSpinner } from '../../shared/progress-collector'; +import { createAskBridge } from '../../shared/ask'; import { areSeededTasksEnabled, getHarness, @@ -61,14 +58,11 @@ import { TaskStatus, type QueuedTask, } from './queue'; -import { drainQueue, type RunTask } from './executor'; +import { drainQueue, RunTaskFatal, type RunTask } from './executor'; import { RunMetrics } from './run-metrics'; import { dependencyClosure, uncoveredBySink } from './queue-tools'; import { deferSeededTasks } from './seeded-deps'; -import { - createWizardAskBridge, - LONGER_ASK_TIMEOUT_MS, -} from '@lib/wizard-ask-bridge'; +import { LONGER_ASK_TIMEOUT_MS } from '@lib/wizard-ask-bridge'; import { shouldDisableAsk } from '../../shared/bootstrap'; import { agentRunTools, @@ -183,7 +177,7 @@ export function resolveSkillVariantId( } /** - * The framework reference is the full `integration` skill. `session.skillId` is + * The framework reference is the full `integration` skill. `input.skillId` is * the bare framework (e.g. `django`), but the skill menu ids it as * `integration-`. */ @@ -222,7 +216,14 @@ export const TASK_NOTICE_TIMEOUT_MS = 5 * 60 * 1000; export async function offerSeededTask( notice: TaskNotice, timeoutMs: number = TASK_NOTICE_TIMEOUT_MS, + interaction?: AgentInteraction, + signal: AbortSignal = new AbortController().signal, ): Promise<{ keep: boolean; timedOut: boolean }> { + // No one to show the notice to: a step nobody can answer for must not run. + // The same answer a non-interactive host gives today. + if (!interaction?.taskNotice) return { keep: false, timedOut: false }; + const { taskNotice, cancelTaskNotice } = interaction; + let timer: ReturnType | undefined; let timedOut = false; const timeout = new Promise((resolve) => { @@ -230,12 +231,12 @@ export async function offerSeededTask( timedOut = true; // Dismisses the overlay and settles the showTaskNotice promise too, so // the losing side of the race cannot leave a modal on screen. - getUI().cancelTaskNotice(); + cancelTaskNotice?.(); resolve(false); }, timeoutMs); }); try { - const keep = await Promise.race([getUI().showTaskNotice(notice), timeout]); + const keep = await Promise.race([taskNotice(notice, { signal }), timeout]); return { keep, timedOut }; } finally { if (timer) clearTimeout(timer); @@ -337,8 +338,15 @@ export async function askSeededConsent( type: string, notice: TaskNotice, timeoutMs?: number, + interaction?: AgentInteraction, + signal?: AbortSignal, ): Promise { - const consent = await offerSeededTask(notice, timeoutMs).then( + const consent = await offerSeededTask( + notice, + timeoutMs, + interaction, + signal, + ).then( (answer): SeededConsent => ({ ...answer, errored: false }), (err: unknown): SeededConsent => { logToFile( @@ -427,33 +435,58 @@ export function displayOrder( .map((entry) => entry.task); } -export async function runOrchestrator( - session: WizardSession, - config: ProgramRun, - programConfig: ProgramConfig, - boot: BootstrapResult, -): Promise { +/** The snapshot the sequence returns; `runAgent` fills it from its collector. */ +const PENDING_SNAPSHOT: RunSnapshot = { + tasks: [], + statusMessages: [], + usage: { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + }, +}; + +const failed = (failure: AgentFailure): RunResult => ({ + outcome: 'failed', + failure, + snapshot: PENDING_SNAPSHOT, +}); + +export async function runOrchestrator({ + config, + input, + boot, + emit, + interaction, + signal, +}: SequenceContext): Promise { const runId = randomUUID(); + const { run } = config; + const programId = config.programId; + + if (signal.aborted) { + return { + outcome: 'cancelled', + failure: { message: 'Wizard setup cancelled.' }, + snapshot: PENDING_SNAPSHOT, + }; + } // Switchboard context — reused for every per-role harness resolution below. - const switchboardCtx = { - program: programConfig.id, - flags: boot.wizardFlags, - flagPayloads: boot.wizardFlagPayloads, - cliHarness: session.harness, - cliSequence: session.sequence, - cliModel: session.model, - }; + // The caller resolved the run-level binding from it; per-task roles overlay + // `binding.contextMillOverride[role]` on the same inputs. + const switchboardCtx = { ...config.switchboard, trace: undefined }; // The WHAT (agent prompts) is served from context-mill. Fetch the registry // once up front: its types drive enqueue validation, and resolving a task to // its run config is then synchronous, with no mid-drain network latency. - const flow = programConfig.agentFlow ?? programConfig.id; + const flow = config.agentFlow ?? programId; const registry = await loadAgentRegistry(boot.skillsBaseUrl, flow, { exclude: ciExcludedTaskTypes(), // Baked into the prompts at load, so enqueue, dispatch, and telemetry all read one effective spec. overrides: resolveStageOverrides( - programConfig.id, + programId, boot.wizardFlags, boot.wizardFlagPayloads, ), @@ -492,7 +525,7 @@ export async function runOrchestrator( ? Date.parse(t.finishedAt) - Date.parse(t.startedAt) : undefined; - const store = new QueueStore(session.installDir, runId, { + const store = new QueueStore(input.installDir, runId, { onTransition: (event, task) => { const pick = resolveHarness(switchboardCtx, task.type); // Mirror dispatch's allow-list fallback so attribution names the model that runs. @@ -565,20 +598,20 @@ export async function runOrchestrator( let commandmentsPath: string | undefined; let referenceInstallPath: string | undefined; const menuSkillEntries = await fetchSkillMenuEntries(boot.skillsBaseUrl); - // The framework key for reference + variant resolution. `session.integration` - // is the detected framework and always wins; `session.skillId` is the - // fallback for the basic-integration path, where bootstrap sets it to the + // The framework key for reference + variant resolution. `input.integration` + // is the detected framework and always wins; `input.skillId` is the + // fallback for the basic-integration path, where the caller sets it to the // framework label. Programs whose run config carries their own skill id // (agent-skill commands like replay-vision) would otherwise leak that id in - // here as a bogus framework after bootstrap overwrites the detect result. - const framework = session.integration ?? session.skillId ?? undefined; + // here as a bogus framework after the caller overwrites the detect result. + const framework = input.integration ?? input.skillId ?? undefined; const referenceSkillId = framework ? resolveReferenceSkillId(menuSkillEntries, framework) : undefined; if (referenceSkillId) { const ref = await installSkillById( referenceSkillId, - session.installDir, + input.installDir, boot.skillsBaseUrl, { skillsRoot: path.join(QUEUE_DIR_NAME, 'reference'), @@ -588,11 +621,11 @@ export async function runOrchestrator( if (ref.kind === 'ok') { referenceInstallPath = ref.path; const example = path.join(ref.path, 'references', 'EXAMPLE.md'); - if (existsSync(path.join(session.installDir, example))) { + if (existsSync(path.join(input.installDir, example))) { examplePath = example; } const commandments = path.join(ref.path, 'references', 'COMMANDMENTS.md'); - if (existsSync(path.join(session.installDir, commandments))) { + if (existsSync(path.join(input.installDir, commandments))) { commandmentsPath = commandments; } } else { @@ -627,11 +660,9 @@ export async function runOrchestrator( } } if (missingVariants.length > 0) { - // The framework's own docs page from its config; generic docs when detection found none. - const docsUrl = framework - ? FRAMEWORK_REGISTRY[framework as Integration]?.metadata.docsUrl - : undefined; - await wizardAbort({ + // The framework's own docs page, resolved by the caller; generic docs when detection found none. + const docsUrl = framework ? input.frameworkDocsUrl : undefined; + return failed({ code: ErrorCodes.AgentOrchestratorSkillVariantMissing, message: 'Setup instructions for this project failed to download.\n' + @@ -662,27 +693,28 @@ export async function runOrchestrator( }; logToFile( - `[orchestrator] START program=${programConfig.id} dir=${session.installDir} run=${runId}`, + `[orchestrator] START program=${programId} dir=${input.installDir} run=${runId}`, ); analytics.wizardCapture('orchestrator started', { - program_id: programConfig.id, + program_id: programId, }); - getUI().startRun(); + emit({ kind: 'lifecycle', phase: 'started' }); // Label precedence: what the orchestrator set at enqueue, then the agent // prompt's default, then the bare type. const labelFor = (t: { type: string; label?: string }) => t.label ?? registry.get(t.type)?.label ?? t.type; const renderQueue = () => - getUI().syncTodos( - displayOrder(store.list(), (t) => + emit({ + kind: 'tasks', + tasks: displayOrder(store.list(), (t) => registry.runnerSeededTypes.includes(t.type), ).map((t) => ({ content: labelFor(t), status: toTodoStatus(t.status), activeForm: labelFor(t), })), - ); + }); // Each task's run binds the wizard-tools MCP server to a per-task // orchestrator context so complete_task / enqueue_task attribute correctly @@ -709,7 +741,7 @@ export async function runOrchestrator( // Kill switch: off (or unset), the wizard queues nothing itself and the run // is byte-identical to a project with no detected sources. const seedEntries = areSeededTasksEnabled(boot.wizardFlags) - ? programConfig.seedTasks?.(session) ?? [] + ? config.seedTasks?.() ?? [] : []; const seededTypes: string[] = []; // Kept so their dependencies can be resolved once the planner has run — they @@ -758,7 +790,13 @@ export async function runOrchestrator( // not, which is why the offer lives here and not there. seededConsent.set( task.id, - await askSeededConsent(seeded.type, seeded.notice), + await askSeededConsent( + seeded.type, + seeded.notice, + undefined, + interaction, + signal, + ), ); } logToFile(`[orchestrator] runner-seeded task ${seeded.type}`); @@ -790,11 +828,11 @@ export async function runOrchestrator( // One bridge for the run, handed only to a task whose prompt allows asking. // Absent in CI and signup, where nobody can answer. - const askBridge = shouldDisableAsk(session) + const askBridge = shouldDisableAsk(input.flags) ? undefined - : createWizardAskBridge({ - getSource: () => session.skillId ?? programConfig.id, - showQuestion: (q) => { + : createAskBridge(interaction, signal, { + getSource: () => input.skillId ?? programId, + beforeShow: () => { // How late the first ask lands is the measure of this run shape: it // should follow the autonomous work, not interrupt it. metrics.recordAsk(Date.now()); @@ -804,16 +842,14 @@ export async function runOrchestrator( // unanswered ask still times out into the deep-link fallback — it just // gives a person who stepped away a chance to come back first. ringTerminalBell(); - return getUI().requestQuestion(q); }, - cancelQuestion: () => getUI().cancelPendingQuestion(), - richLinks: config.richLinks ?? false, + richLinks: run.richLinks ?? false, // A task ask waits on a person, and the drain waits it out — the // executor holds the task's promise — so this is the only real limit. timeoutMs: LONGER_ASK_TIMEOUT_MS, }); - const spinner = getUI().spinner(); + const spinner = createEmitSpinner(emit); // 1. Seed the queue with the orchestrator agent. It is itself an agent prompt // (the WHAT), so its model and tools come from its frontmatter. The seed @@ -826,9 +862,10 @@ export async function runOrchestrator( const seedHarness = requireTaskHarness(seedPick); const seedModel = promptModelFor(seedPrompt, seedPick.harness); const seedResult = await seedHarness.runTask({ - session, - programConfig, + config, + input, boot, + emit, prompt: assembleSeedPrompt(promptContext, seedPrompt.body, store.list()), spinner, model: requireKnownModel(seedModel.model, seedPick.model), @@ -841,6 +878,9 @@ export async function runOrchestrator( requestRemark: false, analyticsProperties: { task_type: 'seed', harness: seedPick.harness }, }); + // A decided failure (a 401 the harness already reported) ends the run here, + // before anything is queued, exactly where the harness used to exit. + if (seedResult.failure) return failed(seedResult.failure); if (seedResult.error) { logToFile( `[orchestrator] seed error: ${seedResult.error} ${ @@ -941,7 +981,7 @@ export async function runOrchestrator( analytics.wizardCapture('orchestrator sink invariant violated', { uncovered_types: unwaited.map((t) => t.type), }); - await wizardAbort({ + return failed({ code: ErrorCodes.AgentOrchestratorSinkInvariant, message: `The wizard could not plan this setup: the final step would have skipped ${unwaited .map((t) => t.type) @@ -965,7 +1005,7 @@ export async function runOrchestrator( // Task agents can install durable skills mid-run (load_skill), and only the // framework reference docs earn a place — snapshot what was already there so // the sweeps remove exactly what this run added. - const claudeSkillsDir = path.join(session.installDir, '.claude', 'skills'); + const claudeSkillsDir = path.join(input.installDir, '.claude', 'skills'); const preexistingSkills = new Set( existsSync(claudeSkillsDir) ? readdirSync(claudeSkillsDir) : [], ); @@ -998,7 +1038,7 @@ export async function runOrchestrator( } const result = await installSkillById( variantId, - session.installDir, + input.installDir, boot.skillsBaseUrl, { skillsRoot: taskSkillsRoot, triage: boot.triageProvider }, ); @@ -1029,10 +1069,11 @@ export async function runOrchestrator( const taskPick = resolveHarness(switchboardCtx, task.type); const taskHarness = requireTaskHarness(taskPick); const taskModel = taskModelSpec(registry, task, taskPick.harness); - await taskHarness.runTask({ - session, - programConfig, + const taskResult = await taskHarness.runTask({ + config, + input, boot, + emit, prompt: assembleTaskPrompt(promptContext, resolved.prompt, skillPaths), spinner, model: requireKnownModel(taskModel.model, taskPick.model), @@ -1051,6 +1092,10 @@ export async function runOrchestrator( harness: taskPick.harness, }, }); + // A decided failure (a 401 the harness already reported) is the run's, + // not the task's: stop the drain and report it, where the harness used + // to exit the process. + if (taskResult.failure) throw new RunTaskFatal(taskResult.failure); } finally { // Durable skills a task installed are irrelevant to later tasks — and // the sdk harness auto-loads .claude/skills into every agent — so sweep @@ -1074,13 +1119,17 @@ export async function runOrchestrator( renderQueue(); } + let fatal: AgentFailure | undefined; try { await drainQueue(store, runTask); + } catch (error) { + if (!(error instanceof RunTaskFatal)) throw error; + fatal = error.failure; } finally { try { if (referenceSkillId && referenceInstallPath) { promoteReferenceSkill( - path.join(session.installDir, referenceInstallPath), + path.join(input.installDir, referenceInstallPath), claudeSkillsDir, referenceSkillId, ); @@ -1095,7 +1144,7 @@ export async function runOrchestrator( // cache folder (queue, handoffs, reference example, installed task // instructions). The .DELETE-ME.md inside is the fallback if we don't. try { - rmSync(path.join(session.installDir, QUEUE_DIR_NAME), { + rmSync(path.join(input.installDir, QUEUE_DIR_NAME), { recursive: true, force: true, }); @@ -1119,6 +1168,8 @@ export async function runOrchestrator( } } + if (fatal) return failed(fatal); + renderQueue(); const summary = store.summary(); @@ -1172,7 +1223,7 @@ export async function runOrchestrator( ', ', )}.\n\nPlease try again, approving all permissions on the PostHog authorization screen. If it still fails, report it to: ${WIZARD_CONTACT_EMAIL}` : `The wizard was unable to set up PostHog: ${whatFailed}.\n\nPlease report this to: ${WIZARD_CONTACT_EMAIL}`; - await wizardAbort({ + return failed({ code: ErrorCodes.AgentOrchestratorTasksFailed, message, error: new WizardError( @@ -1193,7 +1244,7 @@ export async function runOrchestrator( // usable model response (e.g. the gateway returned empty completions). // "0/0 completed" is a dead run, not a success with an empty denominator. if (summary.total === 0) { - await wizardAbort({ + return failed({ code: ErrorCodes.AgentOrchestratorHollowRun, message: `The wizard was unable to set up PostHog: the planning step produced no work, so nothing ran.\n\nPlease try again — and if it happens again, report it to: ${WIZARD_CONTACT_EMAIL}`, error: new WizardError( @@ -1219,19 +1270,20 @@ export async function runOrchestrator( } steps completed${ stepNotes.length > 0 ? ` (${stepNotes.join(', ')})` : '' }.`; - getUI().setOutroData({ + const outro = { kind: OutroKind.Success, message, body: conflict ? `⚠ Build conflict: ${conflict}\nFull details are in the setup report.` : undefined, docsUrl: 'https://posthog.com/docs/ai-engineering/ai-wizard', - nextSteps: config.buildOutroNextSteps?.( - session, + nextSteps: config.hooks?.buildOutroNextSteps?.( boot.credentials, completedSeededTypes(store, seededTasks), ), - }); - getUI().outro(message); + }; + emit({ kind: 'completion', outro }); + emit({ kind: 'lifecycle', phase: 'completed', message }); await analytics.shutdown('success'); + return { outcome: 'success', outro, snapshot: PENDING_SNAPSHOT }; } diff --git a/src/lib/agent/runner/shared/ask.ts b/src/lib/agent/runner/shared/ask.ts new file mode 100644 index 000000000..b109f053b --- /dev/null +++ b/src/lib/agent/runner/shared/ask.ts @@ -0,0 +1,42 @@ +/** + * The ask bridge over an injected answerer. + * + * `createWizardAskBridge` already owns request ids, the timeout race, the + * `__cancelled__` sentinel and the analytics; it only ever needed a + * `showQuestion` and a `cancelQuestion`. Here those come from + * `AgentInteraction` instead of `getUI()`. With no answerer there is no bridge, + * so `wizard_ask` reports its existing "not available" error rather than + * hanging on a question nobody can see. + */ + +import { + createWizardAskBridge, + type WizardAskBridge, +} from '@lib/wizard-ask-bridge'; +import type { AgentInteraction } from '@lib/agent/progress'; + +export function createAskBridge( + interaction: AgentInteraction | undefined, + signal: AbortSignal, + options: { + getSource: () => string; + richLinks: boolean; + timeoutMs?: number; + /** Runs before each question is shown (the orchestrator's bell and metric). */ + beforeShow?: () => void; + }, +): WizardAskBridge | undefined { + const ask = interaction?.ask; + if (!ask) return undefined; + + return createWizardAskBridge({ + getSource: options.getSource, + showQuestion: (question) => { + options.beforeShow?.(); + return ask(question, { signal }); + }, + cancelQuestion: interaction?.cancelAsk, + richLinks: options.richLinks, + timeoutMs: options.timeoutMs, + }); +} diff --git a/src/lib/agent/runner/shared/bootstrap.ts b/src/lib/agent/runner/shared/bootstrap.ts index dc12a2b60..1b776a87c 100644 --- a/src/lib/agent/runner/shared/bootstrap.ts +++ b/src/lib/agent/runner/shared/bootstrap.ts @@ -1,46 +1,22 @@ /** - * Shared bootstrap for the runner pipeline. + * Shared preparation for the runner pipeline. * - * Runs before the fork into the linear or orchestrator arm: logging, health - * check, settings conflicts, OAuth and credentials, feature flags, variant - * metadata, and MCP url. Sets `session.credentials`, role, and user as side - * effects. Returns the values the arms still need. + * Runs before the fork into the linear or orchestrator arm: logging targets, + * the gateway mint and the scan-triage classifier built on it. Everything the + * caller must decide first — health gates, settings conflicts, authentication, + * the AI opt-in gate, post-auth gates, feature flags, run tags, token refresh — + * arrives already resolved in `RunConfig` and `RunInput`. */ -import type { WizardSession } from '@lib/wizard-session'; import { analytics } from '@utils/analytics'; -import { getUI } from '@ui'; -import { authenticate, refreshAccessTokenIfNeeded } from './authenticate'; -import { maybeStampAiSdkDetected } from '@lib/programs/posthog-integration/detect'; import { createTriageLLMProvider } from '@lib/agent/triage-provider'; import { gatewayAuth } from '@lib/gateway-session'; -import { resolveHarness } from '../switchboard'; -import { buildRunTags } from '@lib/agent/agent-interface'; -import { - checkAllSettingsConflicts, - backupAndFixClaudeSettings, - classifySettingsConflicts, -} from '@lib/agent/claude-settings'; -import { - evaluateWizardReadiness, - WizardReadiness, - SIGNUP_WIZARD_READINESS_CONFIG, - getBlockingServiceKeys, - SERVICE_LABELS, -} from '@lib/health-checks/readiness'; import { enableDebugLogs, logToFile, initLogFile } from '@utils/debug'; -import { wizardAbort } from '@utils/wizard-abort'; -import { ErrorCodes } from '@lib/errors'; -import { isNonInteractiveEnvironment } from '@utils/environment'; -import { CallType, getSkillsBaseUrl, IS_DEV } from '@lib/constants'; +import { CallType, IS_DEV } from '@lib/constants'; import { VERSION } from '@lib/version'; import { mcpUrlFor } from '@lib/host-resolution'; import type { WizardRunOptions } from '@utils/types'; -import { - postAuthGateSteps, - type ProgramConfig, -} from '@lib/programs/program-step'; -import type { ProgramRun, BootstrapResult } from './types'; +import type { BootstrapResult, RunConfig, RunFlags, RunInput } from './types'; // ── Helpers ────────────────────────────────────────────────────────── @@ -51,7 +27,7 @@ import type { ProgramRun, BootstrapResult } from './types'; * the program's `disallowedTools` so the SDK rejects calls outright. * Extracted so the policy can be unit-tested directly. * - * `session.e2eAsk` is the one escape hatch. The e2e harness runs a `ci` + * `e2eAsk` is the one escape hatch. The e2e harness runs a `ci` * session, but it does have an answerer — the driver loop answers each * `wizard_ask` batch from the program's e2e profile. Without the flag the * agent-in-the-loop layer (the ask bridge in both sequence arms, and the @@ -61,254 +37,68 @@ import type { ProgramRun, BootstrapResult } from './types'; * populates it, so plain `--ci` and `--signup` runs behave exactly as before. */ export function shouldDisableAsk( - session: Pick, + flags: Pick, ): boolean { - return (session.ci || session.signup) && !session.e2eAsk; + return (flags.ci || flags.signup) && !flags.e2eAsk; } -export function sessionToOptions(session: WizardSession): WizardRunOptions { +/** The option bag the agent interface and the middleware read. */ +export function runOptions(input: RunInput): WizardRunOptions { return { - installDir: session.installDir, - debug: session.debug, - signup: session.signup, - ci: session.ci, - benchmark: session.benchmark, - projectId: session.projectId, - apiKey: session.apiKey, - yaraReport: session.yaraReport, + installDir: input.installDir, + debug: input.flags.debug, + signup: input.flags.signup, + ci: input.flags.ci, + benchmark: input.flags.benchmark, + projectId: input.host.projectId, + apiKey: input.host.apiKey, + yaraReport: input.flags.yaraReport, }; } -// ── Bootstrap ───────────────────────────────────────────────────────── +// ── Prepare ─────────────────────────────────────────────────────────── /** - * Shared setup for both arms: logging, health check, settings conflicts, OAuth - * and credentials, then the feature flags, variant metadata, and MCP url. Sets - * `session.credentials`, role, and user as a side effect. Returns the values the - * arms still need. + * Shared setup for both arms: logging targets, then the gateway mint and the + * triage classifier. Throws when the mint is refused, so the run fails before + * any agent starts — the caller maps that the way it maps any unexpected error. */ -export async function bootstrapProgram( - session: WizardSession, - config: ProgramRun, - programConfig: ProgramConfig, +export async function prepareRun( + config: RunConfig, + input: RunInput, ): Promise { + const { run } = config; + // 1. Init logging + debug initLogFile(); - session.skillId = config.skillId ?? config.integrationLabel; logToFile( - `[agent-runner] START ${config.integrationLabel} build=${analytics.build}` + - `${session.ci ? ' (non-interactive)' : ''}`, + `[agent-runner] START ${run.integrationLabel} build=${analytics.build}` + + `${input.flags.ci ? ' (non-interactive)' : ''}`, ); - if (session.debug) { + if (input.flags.debug) { enableDebugLogs(); } - const skillsBaseUrl = getSkillsBaseUrl(); + const { skillsBaseUrl } = config; // Where this run actually points. The three services switch independently, // so otherwise "why did it use prod skills?" means reading three call sites. logToFile( `[agent-runner] targets build=${VERSION}${IS_DEV ? '/dev' : ''} ` + `skills=${skillsBaseUrl} ` + - `mcp=${mcpUrlFor(session.localMcp)} ` + - `posthog=${session.baseUrl ?? 'region-resolved'}`, - ); - - // 2. Health check (guarded — skip if TUI already ran it). Only - // programs that declare a health-check screen get pre-flight checks; - // for everything else the checks never fire and never block. - const hasHealthCheckScreen = programConfig.steps.some( - (s) => s.screenId === 'health-check', - ); - if (session.readinessResult) { - logToFile( - `[agent-runner] readiness pre-computed by TUI: decision=${session.readinessResult.decision}` + - `${ - session.outageDismissed ? ' (outage dismissed by user)' : '' - } — skipping re-check`, - ); - } - if (hasHealthCheckScreen && !session.readinessResult) { - logToFile('[agent-runner] evaluating wizard readiness'); - const readinessConfig = session.signup - ? SIGNUP_WIZARD_READINESS_CONFIG - : undefined; - const readiness = await evaluateWizardReadiness(readinessConfig); - logToFile(`[agent-runner] readiness=${readiness.decision}`); - if (readiness.decision === WizardReadiness.No) { - const blockingKeys = getBlockingServiceKeys( - readiness.health, - readinessConfig, - ); - const blockingLabels = blockingKeys.map( - (k) => `${SERVICE_LABELS[k]} (${readiness.health[k].status})`, - ); - logToFile(`[agent-runner] blocked by: ${blockingLabels.join(', ')}`); - - await getUI().showBlockingOutage(readiness); - - // The TUI lets the user continue past an outage; non-interactive runs - // (CI) do the same automatically — the degraded services are reported - // above, but we proceed rather than aborting on a transient upstream blip. - if (!isNonInteractiveEnvironment()) { - await wizardAbort({ - code: ErrorCodes.EnvServiceOutage, - message: - 'Cannot start — external services are down:\n' + - blockingLabels.map((l) => ` - ${l}`).join('\n') + - '\n\nPlease try again later.', - }); - } - } else if (readiness.decision === WizardReadiness.YesWithWarnings) { - getUI().setReadinessWarnings(readiness); - } - } - - // 3. Settings conflicts - const settingsConflicts = checkAllSettingsConflicts(session.installDir); - logToFile( - `[agent-runner] settings conflicts: ${ - settingsConflicts.length > 0 - ? settingsConflicts - .map((c) => `${c.source}(${c.keys.join(',')})`) - .join('; ') - : 'none' - }`, + `mcp=${mcpUrlFor(input.flags.localMcp)} ` + + `posthog=${input.host.baseUrl ?? 'region-resolved'}`, ); - if (settingsConflicts.length > 0) { - for (const conflict of settingsConflicts) { - const level = conflict.source === 'managed' ? 'org' : conflict.source; - analytics.wizardCapture('settings conflict detected', { - level, - keys: conflict.keys, - }); - } - - const { autoFix, failClosed, warnOnly } = - classifySettingsConflicts(settingsConflicts); - - // User-global and project-local files are already neutralized — the agent - // runs with settingSources:['project'], so the SDK never reads them. Record - // it and move on; don't make the user act on a setting that can't bite. - for (const conflict of warnOnly) { - logToFile( - `[agent-runner] settings conflict in ${conflict.source} (${conflict.path}) ` + - `neutralized by settingSources:['project'] — not blocking`, - ); - analytics.wizardCapture('settings conflict neutralized', { - level: conflict.source, - keys: conflict.keys, - }); - } - - // Writable project settings.json — the SDK *does* read it, but we can back - // it up and remove it (restored at outro). Neutralize without prompting. - let unfixable = failClosed; - if (autoFix.length > 0) { - const fixed = backupAndFixClaudeSettings(session.installDir); - if (fixed) { - logToFile('[agent-runner] auto-neutralized writable settings conflict'); - analytics.wizardCapture('settings conflict auto-neutralized', { - keys: autoFix.flatMap((c) => c.keys), - }); - } else { - // Couldn't remove it — don't run into the redirect; fail closed instead. - logToFile( - '[agent-runner] could not back up writable settings conflict — failing closed', - ); - unfixable = [...failClosed, ...autoFix]; - } - } - - // What we cannot neutralize (org-managed, always read by the SDK; or a - // writable file we failed to back up) must be fixed by the user. Fail - // closed: the screen names the file + keys and exits. - if (unfixable.length > 0) { - if (isNonInteractiveEnvironment()) { - await wizardAbort({ - code: ErrorCodes.SettingsUnfixableConflict, - message: - 'Cannot start — a Claude settings file redirects the agent away ' + - 'from the PostHog gateway and cannot be neutralized automatically:\n' + - unfixable - .map((c) => ` - ${c.source} (${c.path}): ${c.keys.join(', ')}`) - .join('\n') + - '\n\nRemove the conflicting keys and re-run the wizard.', - }); - } - await getUI().showSettingsOverride(unfixable, () => - backupAndFixClaudeSettings(session.installDir), - ); - logToFile('[agent-runner] settings override resolved'); - } - } - - analytics.wizardCapture('agent started', { - integration: config.integrationLabel, - program_id: programConfig.id, - skill_id: config.skillId ?? null, - }); - - // 4. Authenticate — idempotent within a run (see authenticate()). A second - // agent run in the same invocation (self-driving's integration phase) reuses - // the first login; it does not launch another OAuth. authenticate() also - // identifies the user and sets analytics groups. - await authenticate(session, programConfig.id); - maybeStampAiSdkDetected(session); - const project = session.apiProject; - - // 4.5. AI opt-in enforcement. Parks here while AiOptInRequiredScreen is - // up if the org hasn't approved third-party AI — BEFORE the skill - // install and agent start, so no source leaves the machine. The screen - // alone is cosmetic; this await is the actual gate. Resolves - // immediately when the program declared requiresAi: false or in CI. - // In bootstrapProgram so both the linear and orchestrator arms gate. - logToFile('[agent-runner] checking AI opt-in gate'); - await getUI().waitForAiOptIn(); - logToFile('[agent-runner] AI opt-in gate cleared'); - - // Park for any interactive step the user must complete AFTER authenticating - // but BEFORE the agent runs — e.g. the source-maps project picker, which - // needs credentials to scan and writes its choice to frameworkContext that - // the run prompt reads. Generic: await every gated step between auth and run. - for (const step of postAuthGateSteps(programConfig.steps)) { - logToFile(`[agent-runner] awaiting post-auth gate: ${step.id}`); - await getUI().waitForGate(step.id); - logToFile(`[agent-runner] post-auth gate cleared: ${step.id}`); - } - - // Feature flags. Both arms need these, and the fork decision reads the flags. - // This map is PostHog-side only — CLI `--harness` / `--sequence` precedence - // lives at the resolution sites (`runner/index.ts` for sequence, - // `resolveHarness` for harness), not here. - const wizardFlags = await analytics.getAllFlagsForWizard(); - const wizardFlagPayloads = analytics.getWizardFlagPayloads(); - - // Gateway trace tags for this run. The runner stamps its variant onto this - // after the fork (see runProgram), so the value reflects which arm ran. - const wizardMetadata = buildRunTags({ - programId: programConfig.id, - integration: config.integrationLabel, - runId: analytics.runId, - build: analytics.build, - skillId: config.skillId, - }); - - // The agent can't swap tokens mid-run, so freshness is measured after every park above, right before the mint. - await refreshAccessTokenIfNeeded(session); - - // Credentials (incl. the resolved host family and its MCP url) live on - // `session.credentials`; narrow once at this boundary — `authenticate` above - // set them — so downstream readers get a non-null type without asserting. - const credentials = session.credentials!; + const { credentials } = input; + const { wizardFlags, wizardFlagPayloads, wizardMetadata, programId } = config; // Mint now so a refusal fails the boot before any agent starts. Later // readers re-resolve through the cache, which re-mints past the refresh // point. const currentGatewayAuth = () => - gatewayAuth(credentials.host, credentials.accessToken, programConfig.id); + gatewayAuth(credentials.host, credentials.accessToken, programId); await currentGatewayAuth(); return { @@ -316,33 +106,24 @@ export async function bootstrapProgram( credentials, // Carried so per-task sessions re-resolve against the same program the boot // minted for, rather than digging it back out of the metadata bag. - programId: programConfig.id, + programId, wizardFlags, wizardFlagPayloads, wizardMetadata, - project, - // Resolved once, here: the only place holding both the switchboard inputs + project: input.project, + // Resolved once, here: the only place holding both the run-level harness // and the gateway auth. Every skill install downstream reads it off boot. - triageProvider: createTriageLLMProvider( - async () => { - const auth = await currentGatewayAuth(); - return { - baseURL: auth.gatewayUrl, - authToken: auth.token, - teamId: auth.teamId, - // `call_type` splits scan spend out of the program's agent cost, - // the same tag the in-run triage provider carries. - wizardMetadata: { ...wizardMetadata, call_type: CallType.yaraTriage }, - wizardFlags, - }; - }, - resolveHarness({ - program: programConfig.id, - flags: wizardFlags, - flagPayloads: wizardFlagPayloads, - cliHarness: session.harness, - cliModel: session.model, - }).harness, - ), + triageProvider: createTriageLLMProvider(async () => { + const auth = await currentGatewayAuth(); + return { + baseURL: auth.gatewayUrl, + authToken: auth.token, + teamId: auth.teamId, + // `call_type` splits scan spend out of the program's agent cost, + // the same tag the in-run triage provider carries. + wizardMetadata: { ...wizardMetadata, call_type: CallType.yaraTriage }, + wizardFlags, + }; + }, config.binding.harness), }; } diff --git a/src/lib/agent/runner/shared/errors.ts b/src/lib/agent/runner/shared/errors.ts index 376cb45d4..c012b59a8 100644 --- a/src/lib/agent/runner/shared/errors.ts +++ b/src/lib/agent/runner/shared/errors.ts @@ -4,14 +4,14 @@ import type { InstallSkillResult } from '@lib/wizard-tools'; import { skillErrorCode } from '@lib/errors'; -import { wizardAbort, WizardError } from '@utils/wizard-abort'; +import { WizardError } from '@utils/wizard-abort'; +import type { AgentFailure } from './types'; -export async function abortOnInstallFailure( +/** The failure a skill install error decides. The caller reports and exits. */ +export function installFailure( integrationLabel: string, - result: InstallSkillResult, -): Promise { - if (result.kind === 'ok') return; - + result: Exclude, +): AgentFailure { const code = skillErrorCode(result) ?? undefined; const message = (() => { @@ -25,7 +25,7 @@ export async function abortOnInstallFailure( } })(); - await wizardAbort({ + return { message, code, error: new WizardError( @@ -40,5 +40,5 @@ export async function abortOnInstallFailure( }, code, ), - }); + }; } diff --git a/src/lib/agent/runner/shared/progress-collector.ts b/src/lib/agent/runner/shared/progress-collector.ts new file mode 100644 index 000000000..ee10ff7d8 --- /dev/null +++ b/src/lib/agent/runner/shared/progress-collector.ts @@ -0,0 +1,117 @@ +/** + * The agent's own record of what it reported. + * + * `runAgent` accumulates its final `RunSnapshot` here, independently of any + * observer, so a missing or throwing `onProgress` never makes the result + * incomplete. The emitter wraps the caller's callback: it applies the event + * here first, then hands a copy to the observer, and logs rather than + * propagates anything the observer throws. + */ + +import { logToFile } from '@utils/debug'; +import type { + AgentProgress, + ProgressEmitter, + SpinnerHandle, +} from '@lib/agent/progress'; +import type { RunSnapshot } from './types'; + +export interface ProgressCollector { + emit: ProgressEmitter; + snapshot(): RunSnapshot; +} + +export function createProgressCollector( + onProgress?: (event: AgentProgress) => void, +): ProgressCollector { + const snapshot: RunSnapshot = { + tasks: [], + statusMessages: [], + usage: { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + }, + }; + + const apply = (event: AgentProgress): void => { + switch (event.kind) { + case 'tasks': + snapshot.tasks = event.tasks.map((t) => ({ ...t })); + break; + case 'status': + snapshot.statusMessages.push(event.message); + break; + case 'stage': + snapshot.stage = event.stage; + break; + case 'url': + if (event.which === 'dashboard') snapshot.dashboardUrl = event.url; + else snapshot.notebookUrl = event.url; + break; + case 'usage': + snapshot.usage.inputTokens += event.delta.inputTokens; + snapshot.usage.outputTokens += event.delta.outputTokens; + snapshot.usage.cacheReadTokens += event.delta.cacheReadTokens; + snapshot.usage.cacheCreationTokens += event.delta.cacheCreationTokens; + break; + case 'finalCost': + snapshot.finalCostUsd = event.usd; + break; + default: + break; + } + }; + + const emit: ProgressEmitter = (event) => { + apply(event); + if (!onProgress) return; + try { + onProgress(event); + } catch (error) { + // A broken projection is the host's problem, not the run's. Say so in + // the log and carry on; the snapshot above is the source of truth. + logToFile( + `[agent] progress observer threw on ${event.kind}:`, + error instanceof Error ? error.message : error, + ); + } + }; + + return { + emit, + snapshot: () => ({ + ...snapshot, + tasks: snapshot.tasks.map((t) => ({ ...t })), + statusMessages: [...snapshot.statusMessages], + usage: { ...snapshot.usage }, + }), + }; +} + +/** The run spinner as a progress emitter. One per run, like `getUI().spinner()`. */ +export function createEmitSpinner(emit: ProgressEmitter): SpinnerHandle { + return { + start: (message) => emit({ kind: 'spinner', action: 'start', message }), + stop: (message) => emit({ kind: 'spinner', action: 'stop', message }), + message: (message) => emit({ kind: 'spinner', action: 'message', message }), + }; +} + +/** `WizardUI.log` as a progress emitter. */ +export function createEmitLog(emit: ProgressEmitter): { + info(message: string): void; + warn(message: string): void; + error(message: string): void; + success(message: string): void; + step(message: string): void; +} { + return { + info: (message) => emit({ kind: 'log', level: 'info', message }), + warn: (message) => emit({ kind: 'log', level: 'warn', message }), + error: (message) => emit({ kind: 'log', level: 'error', message }), + success: (message) => emit({ kind: 'log', level: 'success', message }), + step: (message) => emit({ kind: 'log', level: 'step', message }), + }; +} diff --git a/src/lib/agent/runner/shared/types.ts b/src/lib/agent/runner/shared/types.ts index 4f4292f83..388695020 100644 --- a/src/lib/agent/runner/shared/types.ts +++ b/src/lib/agent/runner/shared/types.ts @@ -1,16 +1,31 @@ /** - * Shared types for the runner pipeline. + * The agent's run contracts. + * + * `runAgent(config, input, options)` takes resolved execution data and an + * invocation snapshot, reports through `options.onProgress`, asks through + * `options.interaction`, and returns a `RunResult`. Nothing here names a UI, + * a store, a session or a program registry: the caller resolves those and + * hands over plain data. `src/lib/programs/run-agent-legacy.ts` is the caller + * that rebuilds today's session-driven behavior on top of this contract. */ import type { - Credentials, AdditionalFeature, + CloudRegion, + Credentials, + OutroData, + TaskNotice, WizardSession, } from '@lib/wizard-session'; import type { PromptContext } from '@lib/agent/agent-prompt'; import type { PackageManagerDetector } from '@lib/detection/package-manager'; -import type { ApiProject } from '@lib/api'; +import type { ApiProject, ApiUser } from '@lib/api'; +import type { Harness, Integration, Sequence } from '@lib/constants'; +import type { ErrorCode } from '@lib/errors'; import type { LLMProvider } from '@posthog/warlock'; +import type { AgentInteraction, ProgressEmitter } from '@lib/agent/progress'; +import type { EffortLevel } from '../switchboard/models'; +import type { SwitchboardCtx } from '../switchboard'; export type { PromptContext, Credentials }; @@ -23,7 +38,7 @@ export interface AbortCase { message: string; body: string; docsUrl?: string; - errorCode?: import('@lib/errors').ErrorCode; + errorCode?: ErrorCode; } /** @@ -32,6 +47,10 @@ export interface AbortCase { * Every program provides one of these — either as a static object * or via a function that builds one from the session. The runner * assembles the final prompt from `prompt` + `skillId`. + * + * The three session-taking hooks at the end are the caller's: the agent never + * calls them. `run-agent-legacy.ts` binds them and hands the agent + * `RunConfig.hooks` instead. */ export interface ProgramRun { /** Analytics label (e.g. 'revenue-analytics-setup', 'nextjs') */ @@ -116,15 +135,132 @@ export interface ProgramRun { resolveStepKey?: (stepName: string | undefined) => string | undefined; } +/** A task the caller queues itself before the orchestrator's planner runs. */ +export interface SeedTaskEntry { + type: string; + label?: string; + inputs?: Record; + /** Shown before the run starts, letting the user decline the task. */ + notice?: TaskNotice; +} + +/** + * Completion hooks the caller binds for the agent. Each receives the run's + * resolved credentials, exactly what the linear and orchestrator sequences + * passed alongside the session before. + */ +export interface RunHooks { + /** Runs after the agent completes, before the outro (linear only). */ + postRun?: (credentials: Credentials) => Promise; + /** Custom outro data (linear only). Omit for the default outro. */ + buildOutroData?: (credentials: Credentials) => OutroData | undefined; + /** Outro bullets for the orchestrated sequence. */ + buildOutroNextSteps?: ( + credentials: Credentials, + completedSeededTypes: readonly string[], + ) => { heading: string; items: string[] } | undefined; +} + +/** The run-level routing decision the caller made. */ +export interface ResolvedBinding { + sequence: Sequence; + harness: Harness; + /** Gateway model id. */ + model: string; + /** Reasoning-effort override. Absent → the model's table default. */ + thinkingLevel?: EffortLevel; +} + +/** + * Resolved execution data for one agent run. The caller has already decided + * which program this is, how it is routed and which flags apply; the agent + * treats every label as opaque. + */ +export interface RunConfig { + /** Program id: gateway spend pin, analytics label, commandments axis. */ + programId: string; + /** The program's run definition. Its session-taking hooks are the caller's, see `hooks`. */ + run: ProgramRun; + /** A composed sub-run skips the terminal outro and the analytics shutdown. */ + composed: boolean; + /** Run-level sequence, harness and model. */ + binding: ResolvedBinding; + /** + * The inputs the run-level binding was resolved from. The orchestrator + * re-resolves the harness per task role from these; nothing else reads them. + */ + switchboard: SwitchboardCtx; + /** Primary skills origin (context-mill dev or GitHub Releases). */ + skillsBaseUrl: string; + /** Feature flag key → variant, evaluated before the run. */ + wizardFlags: Record; + /** Flag payloads from the same snapshot. */ + wizardFlagPayloads: Record; + /** Gateway trace tags for this run, already stamped with sequence and harness. */ + wizardMetadata: Record; + /** Extra tools added on top of BASE_ALLOWED_TOOLS for this run. */ + allowedTools?: readonly string[]; + /** Tools removed from BASE_ALLOWED_TOOLS for this run. */ + disallowedTools?: readonly string[]; + /** Context-mill flow the orchestrator loads. Defaults to `programId`. */ + agentFlow?: string; + /** Tasks to queue before the orchestrator's planner runs. */ + seedTasks?: () => SeedTaskEntry[]; + /** Completion hooks, bound by the caller. */ + hooks?: RunHooks; +} + +/** Invocation flags the agent reads. */ +export interface RunFlags { + ci: boolean; + signup: boolean; + debug: boolean; + /** Harness-only: keep the ask bridge in a `ci` run that has an answerer. */ + e2eAsk: boolean; + localMcp: boolean; + captureAio: boolean; + benchmark: boolean; + yaraReport: boolean; +} + +/** + * The invocation snapshot for one agent run. Taken once by the caller; the + * agent never refreshes these from a higher layer. + */ +export interface RunInput { + installDir: string; + /** Resolved credentials, including the host family and its MCP url. */ + credentials: Credentials; + /** Project payload resolved at authentication, for prompt context. */ + project: ApiProject | null; + /** User payload resolved at authentication, for the AI opt-in prompt line. */ + apiUser: ApiUser | null; + /** The skill this run is for: the run's skill id, else its integration label. */ + skillId?: string; + /** Detected framework, when the caller has one. */ + integration?: Integration | null; + /** Docs page for the detected framework, for the orchestrator's preflight message. */ + frameworkDocsUrl?: string; + flags: RunFlags; + /** Where PostHog is, as the CLI was told. */ + host: { + baseUrl?: string; + region?: CloudRegion; + email?: string; + /** `--project-id`, for the auth-error classifier. */ + projectId?: number; + /** `--api-key`, for the auth-error classifier. */ + apiKey?: string; + }; +} + /** - * Result of the shared bootstrap, consumed by both the linear and the - * orchestrator arm. `bootstrapProgram` runs `authenticate` before returning, so - * `credentials` is guaranteed non-null here — the single narrowing point owns - * the invariant, and downstream readers get a properly non-null type for free. + * The values `prepareRun` resolves from `RunConfig` + `RunInput` and hands to + * both sequences: the gateway mint and the scan-triage classifier built on it. */ export interface BootstrapResult { skillsBaseUrl: string; - /** Auth outputs (incl. the resolved host family and its MCP url), narrowed at the boundary. */ + /** Resolved credentials (incl. the host family and its MCP url). */ credentials: Credentials; /** Program this run is, and the node its gateway spend pins to. */ programId: string; @@ -137,3 +273,87 @@ export interface BootstrapResult { /** Scan-triage classifier on this run's harness. Undefined → skill scans fail closed. */ triageProvider: LLMProvider | undefined; } + +/** + * A decided failure. The same fields `wizardAbort` takes, so the legacy + * adapter passes it through untouched and the exit sequence, codes and + * messages stay exactly what they were. + */ +export interface AgentFailure { + message?: string; + /** Structured error data. Renders via `outroError` instead of `outro`. */ + outroData?: OutroData; + error?: Error; + exitCode?: number; + code?: ErrorCode; + detail?: Record; +} + +export type RunOutcome = + /** The run finished; `outro` holds what to show. */ + | 'success' + /** The agent chose to stop (`[ABORT]`); `failure` holds the rendered case. */ + | 'aborted' + /** A coded failure the agent decided; `failure` holds it. */ + | 'failed' + /** `options.signal` aborted the run. */ + | 'cancelled' + /** + * Something threw that the agent did not decide (a refused gateway mint, an + * SDK crash). `failure.error` is the original error and `failure.code` its + * classification, so a caller can report it or rethrow it as it did before. + */ + | 'crashed'; + +/** Totals of every `usage` event the run emitted. */ +export interface TokenUsageTotals { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; +} + +/** What the agent reported, accumulated independently of any observer. */ +export interface RunSnapshot { + tasks: import('@lib/agent/progress').TaskSnapshot[]; + statusMessages: string[]; + stage?: string; + usage: TokenUsageTotals; + finalCostUsd?: number; + dashboardUrl?: string; + notebookUrl?: string; +} + +/** + * `runAgent` never rejects: every ending is one of these, and every ending + * that is not `success` carries a `failure` the caller can act on. + */ +export interface RunResult { + outcome: RunOutcome; + /** The skill this run installed or was for. */ + skillId?: string; + /** Success outro. Absent for a composed sub-run, which has no terminal outro. */ + outro?: OutroData; + /** Present whenever `outcome` is not `success`. */ + failure?: AgentFailure; + snapshot: RunSnapshot; +} + +export interface RunAgentOptions { + /** Receives every progress event in emission order. Never awaited. */ + onProgress?: (event: import('@lib/agent/progress').AgentProgress) => void; + /** Answers the agent's questions. Absent → no ask bridge, notices declined. */ + interaction?: AgentInteraction; + /** Cancels the run at its next phase boundary and any pending question. */ + signal?: AbortSignal; +} + +/** What a sequence receives: the contracts plus the prepared run. */ +export interface SequenceContext { + config: RunConfig; + input: RunInput; + boot: BootstrapResult; + emit: ProgressEmitter; + interaction: AgentInteraction | undefined; + signal: AbortSignal; +} diff --git a/src/lib/agent/runner/switchboard/sequence.ts b/src/lib/agent/runner/switchboard/sequence.ts index e39e228e8..521eda57b 100644 --- a/src/lib/agent/runner/switchboard/sequence.ts +++ b/src/lib/agent/runner/switchboard/sequence.ts @@ -12,9 +12,7 @@ import { resolveFlagSequence, } from './flags'; import { getHarness, resolveHarness } from './harness'; -import type { WizardSession } from '@lib/wizard-session'; -import type { ProgramConfig } from '@lib/programs/program-step'; -import type { ProgramRun, BootstrapResult } from '../shared/types'; +import type { RunResult, SequenceContext } from '../shared/types'; import { runLinearProgram } from '../sequence/linear'; import { runOrchestrator } from '../sequence/orchestrator/orchestrator-runner'; import { @@ -29,26 +27,18 @@ import { export interface SequenceRunner { readonly name: Sequence; - run( - session: WizardSession, - config: ProgramRun, - programConfig: ProgramConfig, - boot: BootstrapResult, - /** Composed sub-run (integration inside self-driving); linear-only. */ - composed: boolean, - ): Promise; + /** Run one program to a decided result. Unexpected errors propagate. */ + run(ctx: SequenceContext): Promise; } export const SEQUENCE_OPTIONS: Partial> = { [Sequence.linear]: { name: Sequence.linear, - run: (session, config, programConfig, boot, composed) => - runLinearProgram(session, config, programConfig, boot, composed), + run: (ctx) => runLinearProgram(ctx), }, [Sequence.orchestrator]: { name: Sequence.orchestrator, - run: (session, config, programConfig, boot, _composed) => - runOrchestrator(session, config, programConfig, boot), + run: (ctx) => runOrchestrator(ctx), }, }; diff --git a/src/lib/detection/__tests__/project-scope.test.ts b/src/lib/detection/__tests__/project-scope.test.ts index 46aedaf04..18f9173dc 100644 --- a/src/lib/detection/__tests__/project-scope.test.ts +++ b/src/lib/detection/__tests__/project-scope.test.ts @@ -10,12 +10,12 @@ import { AGENTIC_DETECTION_TIMEOUT_MS, WIZARD_BASIC_INTEGRATION_AGENTIC_DETECTION_FLAG_KEY, } from '@lib/constants'; -import { authenticate } from '@lib/agent/runner/shared/authenticate'; +import { authenticate } from '@lib/programs/authenticate'; import { buildSession } from '@lib/wizard-session'; import { analytics } from '@utils/analytics'; // Mock only the two network edges of scopeInstallDirToProject; everything else runs real. -vi.mock('@lib/agent/runner/shared/authenticate', () => ({ +vi.mock('@lib/programs/authenticate', () => ({ authenticate: vi.fn().mockResolvedValue(undefined), })); vi.mock('@lib/detection/agentic', async (importOriginal) => ({ diff --git a/src/lib/detection/project-scope.ts b/src/lib/detection/project-scope.ts index 73279b075..da4ffaf0a 100644 --- a/src/lib/detection/project-scope.ts +++ b/src/lib/detection/project-scope.ts @@ -8,7 +8,7 @@ import { type DetectEvent, type DetectTarget, } from './agentic.js'; -import { authenticate } from '@lib/agent/runner/shared/authenticate'; +import { authenticate } from '@lib/programs/authenticate'; import { FRAMEWORK_REGISTRY } from '@lib/registry'; import { AGENTIC_DETECTION_TIMEOUT_MS, diff --git a/src/lib/agent/runner/shared/__tests__/refresh-access-token-if-needed.test.ts b/src/lib/programs/__tests__/refresh-access-token-if-needed.test.ts similarity index 100% rename from src/lib/agent/runner/shared/__tests__/refresh-access-token-if-needed.test.ts rename to src/lib/programs/__tests__/refresh-access-token-if-needed.test.ts diff --git a/src/lib/agent/runner/shared/authenticate.ts b/src/lib/programs/authenticate.ts similarity index 98% rename from src/lib/agent/runner/shared/authenticate.ts rename to src/lib/programs/authenticate.ts index 8644a328c..1c55453dd 100644 --- a/src/lib/agent/runner/shared/authenticate.ts +++ b/src/lib/programs/authenticate.ts @@ -11,7 +11,7 @@ */ import type { Credentials, WizardSession } from '@lib/wizard-session'; -import type { ProgramId } from '@lib/programs/program-registry'; +import type { ProgramId } from './program-registry'; import { getOrAskForProjectData } from '@utils/setup-utils'; import { refreshAccessToken } from '@utils/oauth'; import { OAuthError } from '@utils/oauth-errors'; diff --git a/src/lib/programs/posthog-integration/index.ts b/src/lib/programs/posthog-integration/index.ts index c3ac0b913..d495b5374 100644 --- a/src/lib/programs/posthog-integration/index.ts +++ b/src/lib/programs/posthog-integration/index.ts @@ -1,5 +1,6 @@ import type { ProgramConfig, ProgramStep } from '@lib/programs/program-step'; -import { runAgent, type ProgramRun } from '@lib/agent/agent-runner'; +import { runAgent } from '@lib/programs/run-agent-legacy'; +import type { ProgramRun } from '@lib/agent/agent-runner'; import { WIZARD_TOOL_NAMES } from '@lib/wizard-tools'; import type { WizardSession } from '@lib/wizard-session'; import { mayReportScanResults, OutroKind, RunPhase } from '@lib/wizard-session'; @@ -21,7 +22,7 @@ import { requestDeepLink } from '@utils/provisioning'; import { openTrackedLink, withUtm } from '@utils/links'; import type { HostResolution } from '@lib/host-resolution'; import { getDetectedWarehouseSources } from '@lib/programs/warehouse-source/detect'; -import { shouldDisableAsk } from '@lib/agent/runner/shared/bootstrap'; +import { shouldDisableAsk } from '@lib/agent/agent-runner'; import { POSTHOG_INTEGRATION_PROGRAM } from './steps.js'; import { getContentBlocks } from './content/index.js'; import { buildCodingAgentPrompt } from './handoff.js'; diff --git a/src/lib/programs/run-agent-legacy.ts b/src/lib/programs/run-agent-legacy.ts new file mode 100644 index 000000000..3aac92693 --- /dev/null +++ b/src/lib/programs/run-agent-legacy.ts @@ -0,0 +1,530 @@ +/** + * The session-driven agent runner every existing caller uses. + * + * `runAgent(programConfig, session)` rebuilds today's behavior on top of the + * functional `runAgent(config, input, options)` in `@lib/agent/runner`: it + * runs the gates the TUI owns (health, settings, AI opt-in, post-auth steps), + * authenticates, resolves the program's binding, builds the agent's inputs + * from the session, maps every progress event back onto `getUI()` one call + * per event, answers the agent's questions through `getUI()`, and applies the + * result — `wizardAbort` for a decided failure, nothing more for success. + * + * This is the only file that knows about `getUI()`, the session and + * `wizardAbort` on the agent's behalf. Programs replace it in Release B. + */ + +import type { WizardSession } from '@lib/wizard-session'; +import { analytics } from '@utils/analytics'; +import { getUI, type WizardUI } from '@ui'; +import type { SpinnerHandle } from '@lib/agent/progress'; +import type { AgentInteraction, AgentProgress } from '@lib/agent/progress'; +import { + runAgent as runAgentFunctional, + resolveBinding, + type ProgramRun, + type RunConfig, + type RunInput, + type SwitchboardCtx, +} from '@lib/agent/runner'; +import type { ProgramBinding } from '@lib/agent/runner/switchboard'; +import { buildRunTags } from '@lib/agent/agent-interface'; +import { + backupAndFixClaudeSettings, + checkAllSettingsConflicts, + classifySettingsConflicts, + restoreClaudeSettings, +} from '@lib/agent/claude-settings'; +import { flushScanReport } from '@lib/yara-hooks'; +import { + evaluateWizardReadiness, + WizardReadiness, + SIGNUP_WIZARD_READINESS_CONFIG, + getBlockingServiceKeys, + SERVICE_LABELS, +} from '@lib/health-checks/readiness'; +import { enableDebugLogs, logToFile, initLogFile } from '@utils/debug'; +import { registerCleanup, wizardAbort } from '@utils/wizard-abort'; +import { ErrorCodes } from '@lib/errors'; +import { isNonInteractiveEnvironment } from '@utils/environment'; +import { + getSkillsBaseUrl, + Sequence, + WIZARD_ORCHESTRATOR_FLAG_KEY, + WIZARD_SELF_DRIVING_USE_PI_HARNESS_FLAG_KEY, + type Integration, +} from '@lib/constants'; +import { FRAMEWORK_REGISTRY } from '@lib/registry'; +import { postAuthGateSteps, type ProgramConfig } from './program-step'; +import { authenticate, refreshAccessTokenIfNeeded } from './authenticate'; +import { maybeStampAiSdkDetected } from './posthog-integration/detect'; +import { startAuditLedgerWatcher } from './audit/ledger-watcher'; + +/** + * Resolve a ProgramConfig's agent run definition and execute the pipeline. + * Entry point for the runners and for composed run steps. + */ +export async function runAgent( + programConfig: ProgramConfig, + session: WizardSession, + options: { composed?: boolean } = {}, +): Promise { + if (!programConfig.run) { + throw new Error(`Program "${programConfig.id}" has no run configuration.`); + } + + // Before `run()` resolves: an audit seeds the ledger from inside its recipe, + // and a watcher started later would ignore that write as pre-existing. + const ledger = programConfig.auditLedgerFile + ? startAuditLedgerWatcher(session.installDir, programConfig.auditLedgerFile) + : null; + if (ledger) registerCleanup(() => ledger.stop()); + + try { + const runDef = + typeof programConfig.run === 'function' + ? await programConfig.run(session) + : programConfig.run; + + await runProgram(session, runDef, programConfig, options.composed ?? false); + } finally { + ledger?.stop(); + } +} + +/** + * Gates → authenticate → flags → binding → the functional run → apply result. + * Every step happens in the order it did inside the agent's bootstrap. + */ +async function runProgram( + session: WizardSession, + run: ProgramRun, + programConfig: ProgramConfig, + composed: boolean, +): Promise { + // 1. Init logging + debug + initLogFile(); + session.skillId = run.skillId ?? run.integrationLabel; + logToFile( + `[agent-runner] START ${run.integrationLabel} build=${analytics.build}` + + `${session.ci ? ' (non-interactive)' : ''}`, + ); + if (session.debug) { + enableDebugLogs(); + } + + // 2. Health check (guarded — skip if TUI already ran it). Only + // programs that declare a health-check screen get pre-flight checks; + // for everything else the checks never fire and never block. + await runHealthGate(session, programConfig); + + // 3. Settings conflicts + await runSettingsGate(session); + + analytics.wizardCapture('agent started', { + integration: run.integrationLabel, + program_id: programConfig.id, + skill_id: run.skillId ?? null, + }); + + // 4. Authenticate — idempotent within a run (see authenticate()). A second + // agent run in the same invocation (self-driving's integration phase) reuses + // the first login; it does not launch another OAuth. authenticate() also + // identifies the user and sets analytics groups. + await authenticate(session, programConfig.id); + maybeStampAiSdkDetected(session); + + // 4.5. AI opt-in enforcement. Parks here while AiOptInRequiredScreen is + // up if the org hasn't approved third-party AI — BEFORE the skill + // install and agent start, so no source leaves the machine. The screen + // alone is cosmetic; this await is the actual gate. Resolves + // immediately when the program declared requiresAi: false or in CI. + logToFile('[agent-runner] checking AI opt-in gate'); + await getUI().waitForAiOptIn(); + logToFile('[agent-runner] AI opt-in gate cleared'); + + // Park for any interactive step the user must complete AFTER authenticating + // but BEFORE the agent runs — e.g. the source-maps project picker, which + // needs credentials to scan and writes its choice to frameworkContext that + // the run prompt reads. Generic: await every gated step between auth and run. + for (const step of postAuthGateSteps(programConfig.steps)) { + logToFile(`[agent-runner] awaiting post-auth gate: ${step.id}`); + await getUI().waitForGate(step.id); + logToFile(`[agent-runner] post-auth gate cleared: ${step.id}`); + } + + // Feature flags. Both arms need these, and the routing decision reads them. + const wizardFlags = await analytics.getAllFlagsForWizard(); + const wizardFlagPayloads = analytics.getWizardFlagPayloads(); + + // Gateway trace tags for this run; the binding below stamps its axes on. + const wizardMetadata = buildRunTags({ + programId: programConfig.id, + integration: run.integrationLabel, + runId: analytics.runId, + build: analytics.build, + skillId: run.skillId, + }); + + // The agent can't swap tokens mid-run, so freshness is measured after every + // park above, right before the agent mints. + await refreshAccessTokenIfNeeded(session); + + // Credentials (incl. the resolved host family and its MCP url) live on + // `session.credentials`; narrow once at this boundary — `authenticate` above + // set them — so downstream readers get a non-null type without asserting. + const credentials = session.credentials!; + + // Resolve which sequence and harness will run a program (CLI → PostHog flag → + // per-program binding → default), tag both axes onto analytics, and hand the + // binding to the agent for dispatch. + const switchboard: SwitchboardCtx = { + program: programConfig.id, + composed, + flags: wizardFlags, + flagPayloads: wizardFlagPayloads, + cliHarness: session.harness, + cliSequence: session.sequence, + cliModel: session.model, + }; + const binding = resolveBinding(switchboard); + analytics.setTag('sequence', binding.sequence); + analytics.setTag('harness', binding.harness); + wizardMetadata.SEQUENCE = binding.sequence; + wizardMetadata.HARNESS = binding.harness; + captureSwitchboardDecision(switchboard, binding); + + const ui = getUI(); + + // Cleanup coverage for the abort/cancel path: `wizardAbort` runs the + // registered cleanups, and the agent's own `finally` covers completion. + // flushScanReport is idempotent, so the overlap is a harmless no-op. + registerCleanup(() => flushScanReport({ yaraReport: session.yaraReport })); + + // Linear settings restoration fires on entry to the outro screen, so it + // is registered before the run can reach that screen. Same owner, same + // timing as before; the abort path still restores through the cleanup + // `backupAndFixClaudeSettings` registered. + if (binding.sequence === Sequence.linear) { + ui.onEnterScreen('outro', () => restoreClaudeSettings(session.installDir)); + } + + const framework = session.integration ?? session.skillId ?? undefined; + const config: RunConfig = { + programId: programConfig.id, + run, + composed, + binding, + switchboard, + skillsBaseUrl: getSkillsBaseUrl(), + wizardFlags, + wizardFlagPayloads, + wizardMetadata, + allowedTools: programConfig.allowedTools, + disallowedTools: programConfig.disallowedTools, + agentFlow: programConfig.agentFlow, + seedTasks: programConfig.seedTasks + ? () => programConfig.seedTasks!(session) + : undefined, + hooks: { + postRun: run.postRun + ? (creds) => run.postRun!(session, creds) + : undefined, + buildOutroData: run.buildOutroData + ? (creds) => run.buildOutroData!(session, creds) ?? undefined + : undefined, + buildOutroNextSteps: run.buildOutroNextSteps + ? (creds, completed) => + run.buildOutroNextSteps!(session, creds, completed) + : undefined, + }, + }; + const input: RunInput = { + installDir: session.installDir, + credentials, + project: session.apiProject, + apiUser: session.apiUser, + skillId: session.skillId ?? undefined, + integration: session.integration, + frameworkDocsUrl: framework + ? FRAMEWORK_REGISTRY[framework as Integration]?.metadata.docsUrl + : undefined, + flags: { + ci: session.ci, + signup: session.signup, + debug: session.debug, + e2eAsk: session.e2eAsk, + localMcp: session.localMcp, + captureAio: session.captureAio, + benchmark: session.benchmark, + yaraReport: session.yaraReport, + }, + host: { + baseUrl: session.baseUrl, + region: session.region, + email: session.email, + projectId: session.projectId, + apiKey: session.apiKey, + }, + }; + + const result = await runAgentFunctional(config, input, { + onProgress: createUiReducer(ui), + interaction: uiInteraction(ui), + }); + + // Success already rendered through the reducer (outro data, outro line) and + // shut analytics down. A decided failure exits exactly as it always has. An + // error the agent did not decide used to propagate out of this call, and the + // runners handle it (mint-failure screen, classifyRunFailure): keep throwing + // the original error for them. + if (result.outcome === 'crashed') { + throw result.failure?.error ?? new Error(result.failure?.message); + } + if (result.outcome !== 'success') { + await wizardAbort(result.failure); + } +} + +// ── Gates ───────────────────────────────────────────────────────────── + +async function runHealthGate( + session: WizardSession, + programConfig: ProgramConfig, +): Promise { + const hasHealthCheckScreen = programConfig.steps.some( + (s) => s.screenId === 'health-check', + ); + if (session.readinessResult) { + logToFile( + `[agent-runner] readiness pre-computed by TUI: decision=${session.readinessResult.decision}` + + `${ + session.outageDismissed ? ' (outage dismissed by user)' : '' + } — skipping re-check`, + ); + } + if (!hasHealthCheckScreen || session.readinessResult) return; + + logToFile('[agent-runner] evaluating wizard readiness'); + const readinessConfig = session.signup + ? SIGNUP_WIZARD_READINESS_CONFIG + : undefined; + const readiness = await evaluateWizardReadiness(readinessConfig); + logToFile(`[agent-runner] readiness=${readiness.decision}`); + if (readiness.decision === WizardReadiness.No) { + const blockingKeys = getBlockingServiceKeys( + readiness.health, + readinessConfig, + ); + const blockingLabels = blockingKeys.map( + (k) => `${SERVICE_LABELS[k]} (${readiness.health[k].status})`, + ); + logToFile(`[agent-runner] blocked by: ${blockingLabels.join(', ')}`); + + await getUI().showBlockingOutage(readiness); + + // The TUI lets the user continue past an outage; non-interactive runs + // (CI) do the same automatically — the degraded services are reported + // above, but we proceed rather than aborting on a transient upstream blip. + if (!isNonInteractiveEnvironment()) { + await wizardAbort({ + code: ErrorCodes.EnvServiceOutage, + message: + 'Cannot start — external services are down:\n' + + blockingLabels.map((l) => ` - ${l}`).join('\n') + + '\n\nPlease try again later.', + }); + } + } else if (readiness.decision === WizardReadiness.YesWithWarnings) { + getUI().setReadinessWarnings(readiness); + } +} + +async function runSettingsGate(session: WizardSession): Promise { + const settingsConflicts = checkAllSettingsConflicts(session.installDir); + logToFile( + `[agent-runner] settings conflicts: ${ + settingsConflicts.length > 0 + ? settingsConflicts + .map((c) => `${c.source}(${c.keys.join(',')})`) + .join('; ') + : 'none' + }`, + ); + if (settingsConflicts.length === 0) return; + + for (const conflict of settingsConflicts) { + const level = conflict.source === 'managed' ? 'org' : conflict.source; + analytics.wizardCapture('settings conflict detected', { + level, + keys: conflict.keys, + }); + } + + const { autoFix, failClosed, warnOnly } = + classifySettingsConflicts(settingsConflicts); + + // User-global and project-local files are already neutralized — the agent + // runs with settingSources:['project'], so the SDK never reads them. Record + // it and move on; don't make the user act on a setting that can't bite. + for (const conflict of warnOnly) { + logToFile( + `[agent-runner] settings conflict in ${conflict.source} (${conflict.path}) ` + + `neutralized by settingSources:['project'] — not blocking`, + ); + analytics.wizardCapture('settings conflict neutralized', { + level: conflict.source, + keys: conflict.keys, + }); + } + + // Writable project settings.json — the SDK *does* read it, but we can back + // it up and remove it (restored at outro). Neutralize without prompting. + let unfixable = failClosed; + if (autoFix.length > 0) { + const fixed = backupAndFixClaudeSettings(session.installDir); + if (fixed) { + logToFile('[agent-runner] auto-neutralized writable settings conflict'); + analytics.wizardCapture('settings conflict auto-neutralized', { + keys: autoFix.flatMap((c) => c.keys), + }); + } else { + // Couldn't remove it — don't run into the redirect; fail closed instead. + logToFile( + '[agent-runner] could not back up writable settings conflict — failing closed', + ); + unfixable = [...failClosed, ...autoFix]; + } + } + + // What we cannot neutralize (org-managed, always read by the SDK; or a + // writable file we failed to back up) must be fixed by the user. Fail + // closed: the screen names the file + keys and exits. + if (unfixable.length > 0) { + if (isNonInteractiveEnvironment()) { + await wizardAbort({ + code: ErrorCodes.SettingsUnfixableConflict, + message: + 'Cannot start — a Claude settings file redirects the agent away ' + + 'from the PostHog gateway and cannot be neutralized automatically:\n' + + unfixable + .map((c) => ` - ${c.source} (${c.path}): ${c.keys.join(', ')}`) + .join('\n') + + '\n\nRemove the conflicting keys and re-run the wizard.', + }); + } + await getUI().showSettingsOverride(unfixable, () => + backupAndFixClaudeSettings(session.installDir), + ); + logToFile('[agent-runner] settings override resolved'); + } +} + +// ── Switchboard telemetry ───────────────────────────────────────────── + +/** + * One event + one log line per run: what entered the switchboard, which + * precedence rung decided each axis, and the final pick. + */ +function captureSwitchboardDecision( + ctx: SwitchboardCtx, + binding: ProgramBinding, +): void { + const trace = ctx.trace ?? {}; + // Unpinned orchestrator runs choose a model per task from the context-mill agent prompts; the orchestrator logs that map once the prompts load. + const perTaskModel = + binding.sequence === Sequence.orchestrator && trace.model === 'binding'; + const model = perTaskModel ? 'chosen-per-task' : binding.model; + const modelSource = perTaskModel ? 'agent-prompts' : trace.model; + analytics.wizardCapture('switchboard resolved', { + program: ctx.program, + flag_self_driving_use_pi_harness: + ctx.flags[WIZARD_SELF_DRIVING_USE_PI_HARNESS_FLAG_KEY], + flag_self_driving_pi_payload: JSON.stringify( + ctx.flagPayloads?.[WIZARD_SELF_DRIVING_USE_PI_HARNESS_FLAG_KEY] ?? null, + ), + flag_orchestrator: ctx.flags[WIZARD_ORCHESTRATOR_FLAG_KEY], + cli_harness: ctx.cliHarness, + cli_sequence: ctx.cliSequence, + cli_model: ctx.cliModel, + harness_source: trace.harness, + model_source: modelSource, + sequence_source: trace.sequence, + harness: binding.harness, + model, + thinking_level: binding.thinkingLevel, + sequence: binding.sequence, + }); + logToFile( + `[switchboard] decision: program=${ctx.program}` + + ` in(orchestrator=${ctx.flags[WIZARD_ORCHESTRATOR_FLAG_KEY] ?? '-'},` + + ` cli=${ctx.cliHarness ?? '-'}/${ctx.cliSequence ?? '-'}/${ + ctx.cliModel ?? '-' + })` + + ` → harness=${binding.harness} (${trace.harness ?? '?'})` + + ` model=${model} (${modelSource ?? '?'})` + + ` sequence=${binding.sequence} (${trace.sequence ?? '?'})`, + ); +} + +// ── Progress → WizardUI, one call per event ─────────────────────────── + +/** + * The inverse of the agent's former `getUI()` calls: one event, one + * `WizardUI` method, synchronous, in emission order. Because each case maps + * back to exactly the call the agent used to make, the frame and flow goldens + * hold without regeneration. + */ +export function createUiReducer(ui: WizardUI): (event: AgentProgress) => void { + let spinner: SpinnerHandle | undefined; + return (event) => { + switch (event.kind) { + case 'lifecycle': + if (event.phase === 'started') ui.startRun(); + else ui.outro(event.message); + break; + case 'spinner': { + const handle = (spinner ??= ui.spinner()); + handle[event.action](event.message); + break; + } + case 'log': + ui.log[event.level](event.message); + break; + case 'status': + ui.pushStatus(event.message); + break; + case 'tasks': + ui.syncTodos(event.tasks); + break; + case 'stage': + ui.setStage(event.stage); + break; + case 'url': + if (event.which === 'dashboard') ui.setDashboardUrl(event.url); + else ui.setNotebookUrl(event.url); + break; + case 'usage': + ui.addTokenUsage(event.delta); + break; + case 'finalCost': + ui.setFinalTokenCostUsd(event.usd); + break; + case 'authError': + ui.showAuthError(event.detail); + break; + case 'completion': + ui.setOutroData(event.outro); + break; + } + }; +} + +/** The agent's questions, answered wherever `getUI()` answers them today. */ +export function uiInteraction(ui: WizardUI): AgentInteraction { + return { + ask: (question) => ui.requestQuestion(question), + cancelAsk: () => ui.cancelPendingQuestion(), + taskNotice: (notice) => ui.showTaskNotice(notice), + cancelTaskNotice: () => ui.cancelTaskNotice(), + }; +} diff --git a/src/lib/runners/__tests__/mint-recovery.test.ts b/src/lib/runners/__tests__/mint-recovery.test.ts index 24f9b5d83..076bf9a20 100644 --- a/src/lib/runners/__tests__/mint-recovery.test.ts +++ b/src/lib/runners/__tests__/mint-recovery.test.ts @@ -1,6 +1,6 @@ import { vi, it, expect, afterEach } from 'vitest'; import { runWizard } from '../run-wizard'; -import { runAgent } from '@lib/agent/agent-runner'; +import { runAgent } from '@lib/programs/run-agent-legacy'; import { startTUI } from '@ui/tui/start-tui'; import { WizardStore } from '@ui/tui/store'; import { InkUI } from '@ui/tui/ink-ui'; @@ -10,7 +10,7 @@ import { ScreenId } from '@ui/tui/router'; import { HostResolution } from '@lib/host-resolution'; import { analytics } from '@utils/analytics'; -vi.mock('@lib/agent/agent-runner', () => ({ runAgent: vi.fn() })); +vi.mock('@lib/programs/run-agent-legacy', () => ({ runAgent: vi.fn() })); vi.mock('@ui/tui/start-tui', () => ({ startTUI: vi.fn() })); vi.mock('@lib/local-dev', async (original) => ({ ...(await original()), diff --git a/src/lib/runners/run-non-interactive.ts b/src/lib/runners/run-non-interactive.ts index 7230c3ec0..a4fd1f037 100644 --- a/src/lib/runners/run-non-interactive.ts +++ b/src/lib/runners/run-non-interactive.ts @@ -335,7 +335,7 @@ export function runNonInteractive( } } - const { runAgent } = await import('@lib/agent/agent-runner'); + const { runAgent } = await import('@lib/programs/run-agent-legacy'); await runAgent(config, session); await settleStream(RunPhase.Completed); } catch (error) { diff --git a/src/lib/runners/run-wizard.ts b/src/lib/runners/run-wizard.ts index 1ee2e7120..a0e0879ff 100644 --- a/src/lib/runners/run-wizard.ts +++ b/src/lib/runners/run-wizard.ts @@ -1,7 +1,7 @@ import { VERSION } from '@lib/version'; import { logToFile, getLogFilePath } from '@utils/debug'; -import { runAgent } from '@lib/agent/agent-runner'; -import { authenticate } from '@lib/agent/runner/shared/authenticate'; +import { runAgent } from '@lib/programs/run-agent-legacy'; +import { authenticate } from '@lib/programs/authenticate'; import { getProgramConfig } from '@lib/programs/program-registry'; import { getAuditChecks } from '@lib/programs/audit/types'; import { maybeStampAiSdkDetected } from '@lib/programs/posthog-integration/detect'; diff --git a/vitest.config.ts b/vitest.config.ts index 7985ed29d..0a0de520d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -52,7 +52,6 @@ const HARNESS_TESTS = [ `e2e-harness/${TESTS}`, 'e2e-harness/**/*.{test,spec}.{js,jsx,ts,tsx}', ]; -const ARCH_TESTS = ['src/__tests__/architecture/**/*.{ts,tsx}']; const EXCLUDE = [ '**/node_modules/**', '**/dist/**', @@ -105,13 +104,12 @@ export default defineConfig({ project('tui', TUI_TESTS), project('cli', CLI_TESTS), project('harness', HARNESS_TESTS), - project('architecture', ARCH_TESTS), project( 'legacy', // The second glob keeps the pre-split behavior: a test file outside // a __tests__ directory still runs, here, rather than nowhere. [`src/**/${TESTS}`, 'src/**/*.{test,spec}.{js,jsx,ts,tsx}'], - [...AGENT_TESTS, ...TUI_TESTS, ...CLI_TESTS, ...ARCH_TESTS], + [...AGENT_TESTS, ...TUI_TESTS, ...CLI_TESTS], ), ], coverage: { From ee39d2c3587349dca699ede77eeb269e4bdbba39 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Mon, 21 Sep 2026 18:15:47 -0400 Subject: [PATCH 02/10] chore(test): keep the architecture test, record the adapter edges Generated-By: PostHog Desktop Task-Id: d14e92bb-6ee1-49b5-8502-39cb80079589 --- package.json | 1 + .../architecture/import-boundaries.test.ts | 403 ++++++++++++++++++ .../architecture/known-violations.json | 80 ++++ vitest.config.ts | 4 +- 4 files changed, 487 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/architecture/import-boundaries.test.ts create mode 100644 src/__tests__/architecture/known-violations.json diff --git a/package.json b/package.json index 4a2b82474..4df515d69 100644 --- a/package.json +++ b/package.json @@ -137,6 +137,7 @@ "test:tui": "vitest run --project tui", "test:cli": "vitest run --project cli", "test:harness": "vitest run --project harness", + "test:arch": "vitest run --project architecture", "test:goldens:update": "vitest run -u", "test:coverage": "pnpm build && vitest run --coverage", "test:e2e": "pnpm build && ./e2e-tests/run.sh", diff --git a/src/__tests__/architecture/import-boundaries.test.ts b/src/__tests__/architecture/import-boundaries.test.ts new file mode 100644 index 000000000..8220182ae --- /dev/null +++ b/src/__tests__/architecture/import-boundaries.test.ts @@ -0,0 +1,403 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; + +export type Surface = 'env' | 'legacy' | 'agent' | 'tui' | 'cli'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(HERE, '../../..'); + +const SURFACE_RULES: ReadonlyArray boolean]> = + [ + ['env', (p) => p === 'src/env.ts'], + [ + 'agent', + (p) => + p.startsWith('src/lib/agent/') || + p.startsWith('src/lib/middleware/') || + p === 'src/lib/gateway-session.ts' || + p === 'src/lib/yara-hooks.ts' || + p === 'src/lib/yara-policy.ts' || + p === 'src/lib/wizard-tools/mcp.ts', + ], + [ + 'tui', + (p) => + p.startsWith('src/ui/tui/') || + p === 'src/ui/logging-ui.ts' || + p === 'src/ui/headless-ui.ts' || + p === 'src/commands/factories/family-picker.tsx' || + /^src\/lib\/programs\/[^/]+\/content\//.test(p) || + /^src\/lib\/programs\/[^/]+\/tips\.ts$/.test(p), + ], + [ + 'cli', + (p) => + p === 'bin.ts' || + p === 'src/wizard.ts' || + p === 'src/telemetry.ts' || + p.startsWith('src/commands/') || + p.startsWith('src/lib/runners/'), + ], + ]; + +export function classifySurface(relPath: string): Surface { + const p = relPath.split(path.sep).join('/'); + for (const [surface, matches] of SURFACE_RULES) { + if (matches(p)) return surface; + } + return 'legacy'; +} + +export const ALLOWED_IMPORTS: Record = { + env: [], + legacy: ['env', 'legacy'], + agent: ['env', 'legacy', 'agent'], + tui: ['env', 'legacy', 'tui'], + cli: ['env', 'legacy', 'agent', 'tui', 'cli'], +}; + +const TUI_ONLY_PACKAGES = ['ink', 'react', '@inkjs/ui', 'ink-testing-library']; + +const SKIP_DIRS = new Set([ + '__tests__', + '__mocks__', + '__fixtures__', + '__snapshots__', + 'node_modules', + 'dist', + 'coverage', +]); + +const SPECIFIER_PATTERNS = [ + /import\s+(?:type\s+)?[^'"]*?from\s*['"]([^'"]+)['"]/g, + /import\s*['"]([^'"]+)['"]/g, + /export\s+(?:type\s+)?[^'"]*?from\s*['"]([^'"]+)['"]/g, + /import\(\s*['"]([^'"]+)['"]\s*\)/g, + /require\(\s*['"]([^'"]+)['"]\s*\)/g, +]; + +const REGEX_PRECEDERS = new Set([ + '\n', + '(', + ')', + ',', + '=', + ':', + '[', + '!', + '&', + '|', + '?', + '{', + '}', + ';', + '+', + '-', + '*', + '%', + '^', + '~', + '<', + '>', +]); + +function endOfString(source: string, start: number, quote: string): number { + let i = start + 1; + while (i < source.length) { + if (source[i] === '\\') { + i += 2; + continue; + } + if (source[i] === quote) return i + 1; + i++; + } + return source.length; +} + +function endOfRegex(source: string, start: number): number { + let i = start + 1; + let inClass = false; + while (i < source.length) { + const c = source[i]; + if (c === '\\') { + i += 2; + continue; + } + if (c === '\n') return start + 1; + if (c === '[') inClass = true; + else if (c === ']') inClass = false; + else if (c === '/' && !inClass) return i + 1; + i++; + } + return source.length; +} + +function stripComments(source: string): string { + let out = ''; + let prev = '\n'; + let i = 0; + while (i < source.length) { + const c = source[i]; + const next = source[i + 1]; + if (c === '/' && next === '/') { + while (i < source.length && source[i] !== '\n') i++; + continue; + } + if (c === '/' && next === '*') { + i += 2; + while (i < source.length && !(source[i] === '*' && source[i + 1] === '/')) + i++; + i += 2; + out += ' '; + continue; + } + if (c === '"' || c === "'" || c === '`') { + const end = endOfString(source, i, c); + out += source.slice(i, end); + prev = c; + i = end; + continue; + } + if (c === '/' && REGEX_PRECEDERS.has(prev)) { + const end = endOfRegex(source, i); + out += source.slice(i, end); + prev = '/'; + i = end; + continue; + } + out += c; + if (c === '\n' || !/\s/.test(c)) prev = c; + i++; + } + return out; +} + +function toRepoRelative(abs: string): string { + return path.relative(REPO_ROOT, abs).split(path.sep).join('/'); +} + +function isFile(abs: string): boolean { + return fs.statSync(abs, { throwIfNoEntry: false })?.isFile() ?? false; +} + +function collectFiles(absDir: string, into: string[]): void { + for (const entry of fs.readdirSync(absDir, { withFileTypes: true })) { + const abs = path.join(absDir, entry.name); + if (entry.isDirectory()) { + if (!SKIP_DIRS.has(entry.name)) collectFiles(abs, into); + continue; + } + if (!entry.isFile()) continue; + if (!/\.tsx?$/.test(entry.name)) continue; + if (/\.d\.ts$/.test(entry.name) || /\.test\.tsx?$/.test(entry.name)) + continue; + into.push(toRepoRelative(abs)); + } +} + +function loadAliases(): ReadonlyArray { + const tsconfig = JSON.parse( + fs.readFileSync(path.join(REPO_ROOT, 'tsconfig.build.json'), 'utf8'), + ) as { compilerOptions?: { paths?: Record } }; + return Object.entries(tsconfig.compilerOptions?.paths ?? {}).map( + ([pattern, targets]) => [pattern, targets[0]] as const, + ); +} + +function aliasTarget( + spec: string, + aliases: ReadonlyArray, +): string | null { + for (const [pattern, target] of aliases) { + if (pattern.endsWith('*')) { + const prefix = pattern.slice(0, -1); + if (spec.startsWith(prefix)) { + return path.resolve( + REPO_ROOT, + target.slice(0, -1) + spec.slice(prefix.length), + ); + } + } else if (spec === pattern) { + return path.resolve(REPO_ROOT, target); + } + } + return null; +} + +function probe(base: string): string | null { + const candidates: string[] = []; + if (base.endsWith('.js')) { + const stem = base.slice(0, -3); + candidates.push(`${stem}.ts`, `${stem}.tsx`); + } + candidates.push( + `${base}.ts`, + `${base}.tsx`, + path.join(base, 'index.ts'), + path.join(base, 'index.tsx'), + base, + ); + return candidates.find(isFile) ?? null; +} + +function specifiersIn(text: string): string[] { + const found = new Set(); + for (const pattern of SPECIFIER_PATTERNS) { + pattern.lastIndex = 0; + let match = pattern.exec(text); + while (match !== null) { + found.add(match[1]); + match = pattern.exec(text); + } + } + return [...found]; +} + +type Analysis = { + files: string[]; + edges: string[]; + violations: ReadonlyArray<{ key: string; rule: string }>; + unresolved: string[]; +}; + +function analyze(): Analysis { + const files: string[] = ['bin.ts']; + collectFiles(path.join(REPO_ROOT, 'src'), files); + files.sort(); + + const aliases = loadAliases(); + const edges = new Set(); + const violations = new Map(); + const unresolved = new Set(); + + for (const file of files) { + const text = stripComments( + fs.readFileSync(path.join(REPO_ROOT, file), 'utf8'), + ); + const from = classifySurface(file); + const allowed = ALLOWED_IMPORTS[from]; + + for (const spec of specifiersIn(text)) { + const base = spec.startsWith('.') + ? path.resolve(REPO_ROOT, path.dirname(file), spec) + : aliasTarget(spec, aliases); + + if (base === null) { + const tuiOnly = + TUI_ONLY_PACKAGES.includes(spec) || spec.startsWith('react/'); + if (tuiOnly && from !== 'tui') { + violations.set(`${file} -> pkg:${spec}`, 'ink-outside-tui'); + } + continue; + } + + const resolved = probe(base); + if (resolved === null) { + unresolved.add(`${file} -> ${spec}`); + continue; + } + + const target = toRepoRelative(resolved); + const key = `${file} -> ${target}`; + edges.add(key); + + const to = classifySurface(target); + if (!allowed.includes(to)) violations.set(key, `matrix:${from}->${to}`); + } + } + + return { + files, + edges: [...edges].sort(), + violations: [...violations] + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([key, rule]) => ({ key, rule })), + unresolved: [...unresolved].sort(), + }; +} + +const analysis = analyze(); + +const known = ( + JSON.parse( + fs.readFileSync(path.join(HERE, 'known-violations.json'), 'utf8'), + ) as { violations: string[] } +).violations; + +if (process.env.PRINT_VIOLATIONS) { + const byRule = new Map(); + const byImporter = new Map(); + for (const { key, rule } of analysis.violations) { + byRule.set(rule, (byRule.get(rule) ?? 0) + 1); + const file = key.split(' -> ')[0]; + byImporter.set(file, (byImporter.get(file) ?? 0) + 1); + } + const lines = [ + `files scanned: ${analysis.files.length}`, + `edges: ${analysis.edges.length}`, + `violations: ${analysis.violations.length}`, + `unresolved: ${analysis.unresolved.length}`, + ...analysis.unresolved.map((u) => ` unresolved: ${u}`), + ...[...byRule] + .sort((a, b) => b[1] - a[1]) + .map(([rule, count]) => ` ${rule}: ${count}`), + 'top importers:', + ...[...byImporter] + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([file, count]) => ` ${file}: ${count}`), + ]; + process.stderr.write(`${lines.join('\n')}\n`); + process.stderr.write( + `${JSON.stringify( + { violations: analysis.violations.map((v) => v.key) }, + null, + 2, + )}\n`, + ); +} + +describe('import boundaries', () => { + it('resolves every internal specifier', () => { + expect(analysis.unresolved).toEqual([]); + }); + + it('introduces no violation outside known-violations.json', () => { + const knownSet = new Set(known); + const added = analysis.violations + .filter(({ key }) => !knownSet.has(key)) + .map(({ key, rule }) => `${key} [${rule}]`); + expect(added).toEqual([]); + }); + + it('keeps known-violations.json free of stale entries', () => { + const current = new Set(analysis.violations.map((v) => v.key)); + const stale = known.filter((key) => !current.has(key)); + expect( + stale, + 'stale entries, delete them from known-violations.json', + ).toEqual([]); + }); +}); + +describe('surface classification', () => { + it('maps representative paths to their surface', () => { + expect(classifySurface('src/env.ts')).toBe('env'); + expect(classifySurface('src/utils/analytics.ts')).toBe('legacy'); + expect(classifySurface('src/lib/agent/agent-runner.ts')).toBe('agent'); + expect(classifySurface('src/ui/tui/App.tsx')).toBe('tui'); + expect(classifySurface('bin.ts')).toBe('cli'); + expect(classifySurface('src/lib/wizard-tools/mcp.ts')).toBe('agent'); + expect(classifySurface('src/lib/wizard-tools/tools.ts')).toBe('legacy'); + expect(classifySurface('src/commands/factories/family-picker.tsx')).toBe( + 'tui', + ); + expect( + classifySurface('src/lib/programs/posthog-integration/content/index.tsx'), + ).toBe('tui'); + expect( + classifySurface('src/lib/programs/posthog-integration/index.ts'), + ).toBe('legacy'); + }); +}); diff --git a/src/__tests__/architecture/known-violations.json b/src/__tests__/architecture/known-violations.json new file mode 100644 index 000000000..73e5f68e9 --- /dev/null +++ b/src/__tests__/architecture/known-violations.json @@ -0,0 +1,80 @@ +{ + "violations": [ + "src/commands/factories/family-picker.tsx -> src/commands/command.ts", + "src/env.ts -> src/lib/headless-mode.ts", + "src/lib/agent/mcp-prompt-streaming.ts -> src/ui/tui/services/mcp-suggested-prompts-services.ts", + "src/lib/detection/agentic.ts -> src/lib/agent/agent-interface.ts", + "src/lib/errors/agent-map.ts -> src/lib/agent/signals.ts", + "src/lib/programs/agent-skill/index.ts -> src/lib/agent/agent-runner.ts", + "src/lib/programs/agent-skill/index.ts -> src/lib/programs/agent-skill/content/index.tsx", + "src/lib/programs/ai-observability/index.ts -> src/lib/programs/agent-skill/content/index.tsx", + "src/lib/programs/audit/detect.ts -> src/lib/agent/agent-runner.ts", + "src/lib/programs/audit/index.ts -> src/lib/agent/agent-runner.ts", + "src/lib/programs/dispatch-family.ts -> src/commands/command.ts", + "src/lib/programs/dispatch-family.ts -> src/commands/factories/shared.ts", + "src/lib/programs/error-tracking-upload-source-maps/detect.ts -> src/lib/agent/agent-runner.ts", + "src/lib/programs/error-tracking-upload-source-maps/index.ts -> src/lib/agent/agent-runner.ts", + "src/lib/programs/error-tracking-upload-source-maps/index.ts -> src/lib/programs/error-tracking-upload-source-maps/content/index.tsx", + "src/lib/programs/error-tracking-upload-source-maps/prompt.ts -> src/lib/agent/agent-interface.ts", + "src/lib/programs/error-tracking/index.ts -> src/lib/agent/runner/shared/types.ts", + "src/lib/programs/error-tracking/index.ts -> src/lib/programs/error-tracking/content/index.tsx", + "src/lib/programs/error-tracking/index.ts -> src/lib/programs/error-tracking/content/tips.ts", + "src/lib/programs/events-audit/index.ts -> src/lib/agent/agent-runner.ts", + "src/lib/programs/mcp-analytics/index.ts -> src/lib/agent/agent-runner.ts", + "src/lib/programs/metrics/index.ts -> src/lib/programs/agent-skill/content/index.tsx", + "src/lib/programs/migration/index.ts -> src/lib/agent/agent-runner.ts", + "src/lib/programs/migration/index.ts -> src/lib/programs/migration/content/index.tsx", + "src/lib/programs/posthog-integration/index.ts -> src/lib/agent/agent-interface.ts", + "src/lib/programs/posthog-integration/index.ts -> src/lib/agent/agent-runner.ts", + "src/lib/programs/posthog-integration/index.ts -> src/lib/programs/posthog-integration/content/index.tsx", + "src/lib/programs/program-registry.ts -> src/lib/programs/agent-skill/content/index.tsx", + "src/lib/programs/program-step.ts -> src/lib/agent/agent-runner.ts", + "src/lib/programs/program-step.ts -> src/ui/tui/components/TipsCard.tsx", + "src/lib/programs/program-step.ts -> src/ui/tui/primitives/index.ts", + "src/lib/programs/program-step.ts -> src/ui/tui/store.ts", + "src/lib/programs/replay-vision/index.ts -> src/lib/agent/agent-runner.ts", + "src/lib/programs/revenue-analytics/detect.ts -> src/lib/agent/agent-runner.ts", + "src/lib/programs/revenue-analytics/index.ts -> src/lib/programs/revenue-analytics/content/index.tsx", + "src/lib/programs/run-agent-legacy.ts -> src/lib/agent/agent-interface.ts", + "src/lib/programs/run-agent-legacy.ts -> src/lib/agent/claude-settings.ts", + "src/lib/programs/run-agent-legacy.ts -> src/lib/agent/progress.ts", + "src/lib/programs/run-agent-legacy.ts -> src/lib/agent/runner/index.ts", + "src/lib/programs/run-agent-legacy.ts -> src/lib/agent/runner/switchboard/index.ts", + "src/lib/programs/run-agent-legacy.ts -> src/lib/yara-hooks.ts", + "src/lib/programs/self-driving/detect.ts -> src/lib/agent/agent-runner.ts", + "src/lib/programs/self-driving/index.ts -> src/lib/agent/agent-runner.ts", + "src/lib/programs/self-driving/index.ts -> src/lib/programs/self-driving/content/index.tsx", + "src/lib/programs/self-driving/index.ts -> src/lib/programs/self-driving/content/pricing.ts", + "src/lib/programs/self-driving/index.ts -> src/lib/programs/self-driving/content/tips.ts", + "src/lib/programs/self-driving/prompt.ts -> src/lib/agent/agent-interface.ts", + "src/lib/programs/self-driving/prompt.ts -> src/lib/agent/agent-runner.ts", + "src/lib/programs/warehouse-source/detect.ts -> src/lib/agent/agent-runner.ts", + "src/lib/programs/warehouse-source/index.ts -> src/lib/agent/agent-runner.ts", + "src/lib/programs/warehouse-source/index.ts -> src/lib/programs/warehouse-source/content/index.tsx", + "src/lib/programs/web-analytics-doctor/detect.ts -> src/lib/agent/agent-runner.ts", + "src/lib/task-stream/event-plan-watcher.ts -> src/ui/tui/store.ts", + "src/lib/task-stream/task-stream-push.ts -> src/ui/tui/store.ts", + "src/lib/wizard-session.ts -> src/lib/agent/claude-settings.ts", + "src/lib/wizard-tools/index.ts -> src/lib/wizard-tools/mcp.ts", + "src/lib/wizard-tools/tools.ts -> src/lib/yara-hooks.ts", + "src/steps/add-mcp-server-to-clients/index.ts -> src/telemetry.ts", + "src/steps/add-or-update-environment-variables.ts -> src/telemetry.ts", + "src/steps/run-prettier.ts -> src/telemetry.ts", + "src/steps/upload-environment-variables/index.ts -> src/telemetry.ts", + "src/ui/index.ts -> src/ui/logging-ui.ts", + "src/ui/logging-ui.ts -> src/lib/agent/claude-settings.ts", + "src/ui/tui/components/PhaseVisuals.tsx -> src/lib/agent/agent-phase.ts", + "src/ui/tui/components/TokenCostHud.tsx -> src/lib/agent/token-pricing.ts", + "src/ui/tui/exit-line.ts -> src/lib/agent/token-pricing.ts", + "src/ui/tui/ink-ui.ts -> src/lib/agent/claude-settings.ts", + "src/ui/tui/playground/demos/RunScreenDemo.tsx -> src/lib/agent/agent-phase.ts", + "src/ui/tui/screens/ManagedSettingsScreen.tsx -> src/lib/agent/claude-settings.ts", + "src/ui/tui/services/mcp-suggested-prompts-services.ts -> src/lib/agent/mcp-prompt-streaming.ts", + "src/ui/tui/store.ts -> src/lib/agent/claude-settings.ts", + "src/ui/tui/store.ts -> src/lib/agent/token-pricing.ts", + "src/ui/wizard-ui.ts -> src/lib/agent/claude-settings.ts", + "src/utils/package-manager.ts -> src/telemetry.ts", + "src/utils/setup-utils.ts -> src/telemetry.ts", + "src/utils/wizard-abort.ts -> src/ui/logging-ui.ts" + ] +} diff --git a/vitest.config.ts b/vitest.config.ts index 0a0de520d..7985ed29d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -52,6 +52,7 @@ const HARNESS_TESTS = [ `e2e-harness/${TESTS}`, 'e2e-harness/**/*.{test,spec}.{js,jsx,ts,tsx}', ]; +const ARCH_TESTS = ['src/__tests__/architecture/**/*.{ts,tsx}']; const EXCLUDE = [ '**/node_modules/**', '**/dist/**', @@ -104,12 +105,13 @@ export default defineConfig({ project('tui', TUI_TESTS), project('cli', CLI_TESTS), project('harness', HARNESS_TESTS), + project('architecture', ARCH_TESTS), project( 'legacy', // The second glob keeps the pre-split behavior: a test file outside // a __tests__ directory still runs, here, rather than nowhere. [`src/**/${TESTS}`, 'src/**/*.{test,spec}.{js,jsx,ts,tsx}'], - [...AGENT_TESTS, ...TUI_TESTS, ...CLI_TESTS], + [...AGENT_TESTS, ...TUI_TESTS, ...CLI_TESTS, ...ARCH_TESTS], ), ], coverage: { From f7fe0fa59e3d6961d334aa37130372ce286263c2 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Mon, 21 Sep 2026 18:20:30 -0400 Subject: [PATCH 03/10] chore(agent): drop the authenticate move and the README rewrite from the cut Generated-By: PostHog Desktop Task-Id: d14e92bb-6ee1-49b5-8502-39cb80079589 --- scripts/tui-host.no-jest.ts | 2 +- .../architecture/known-violations.json | 2 + src/lib/agent/runner/README.md | 37 +++++-------------- .../refresh-access-token-if-needed.test.ts | 0 .../runner/shared}/authenticate.ts | 2 +- .../detection/__tests__/project-scope.test.ts | 4 +- src/lib/detection/project-scope.ts | 2 +- src/lib/programs/run-agent-legacy.ts | 9 +++-- src/lib/runners/run-wizard.ts | 2 +- 9 files changed, 23 insertions(+), 37 deletions(-) rename src/lib/{programs => agent/runner/shared}/__tests__/refresh-access-token-if-needed.test.ts (100%) rename src/lib/{programs => agent/runner/shared}/authenticate.ts (98%) diff --git a/scripts/tui-host.no-jest.ts b/scripts/tui-host.no-jest.ts index ead31e380..a6ceb1ce8 100644 --- a/scripts/tui-host.no-jest.ts +++ b/scripts/tui-host.no-jest.ts @@ -30,7 +30,7 @@ import { configureGatewayFromCIEnvironment } from '@lib/gateway-session'; import { runAgent } from '@lib/programs/run-agent-legacy'; import { TaskStreamPush, createFileDestination } from '@lib/task-stream/index'; import { getAuditChecks } from '@lib/programs/audit/types'; -import { authenticate } from '@lib/programs/authenticate'; +import { authenticate } from '@lib/agent/runner/shared/authenticate'; import { getOrAskForProjectData } from '@utils/setup-utils'; import { logToFile } from '@utils/debug'; import { join } from 'path'; diff --git a/src/__tests__/architecture/known-violations.json b/src/__tests__/architecture/known-violations.json index 73e5f68e9..a2f9049bc 100644 --- a/src/__tests__/architecture/known-violations.json +++ b/src/__tests__/architecture/known-violations.json @@ -4,6 +4,7 @@ "src/env.ts -> src/lib/headless-mode.ts", "src/lib/agent/mcp-prompt-streaming.ts -> src/ui/tui/services/mcp-suggested-prompts-services.ts", "src/lib/detection/agentic.ts -> src/lib/agent/agent-interface.ts", + "src/lib/detection/project-scope.ts -> src/lib/agent/runner/shared/authenticate.ts", "src/lib/errors/agent-map.ts -> src/lib/agent/signals.ts", "src/lib/programs/agent-skill/index.ts -> src/lib/agent/agent-runner.ts", "src/lib/programs/agent-skill/index.ts -> src/lib/programs/agent-skill/content/index.tsx", @@ -39,6 +40,7 @@ "src/lib/programs/run-agent-legacy.ts -> src/lib/agent/claude-settings.ts", "src/lib/programs/run-agent-legacy.ts -> src/lib/agent/progress.ts", "src/lib/programs/run-agent-legacy.ts -> src/lib/agent/runner/index.ts", + "src/lib/programs/run-agent-legacy.ts -> src/lib/agent/runner/shared/authenticate.ts", "src/lib/programs/run-agent-legacy.ts -> src/lib/agent/runner/switchboard/index.ts", "src/lib/programs/run-agent-legacy.ts -> src/lib/yara-hooks.ts", "src/lib/programs/self-driving/detect.ts -> src/lib/agent/agent-runner.ts", diff --git a/src/lib/agent/runner/README.md b/src/lib/agent/runner/README.md index b9f9bde83..a96dfaf12 100644 --- a/src/lib/agent/runner/README.md +++ b/src/lib/agent/runner/README.md @@ -40,30 +40,12 @@ for the coordinated change checklist. Five layers, each with its own job. Nothing crosses layers unless it has to. **The entry point** (`index.ts`) is the front door: - -```ts -runAgent(config: RunConfig, input: RunInput, options?: { - onProgress?: (event: AgentProgress) => void; - interaction?: AgentInteraction; - signal?: AbortSignal; -}): Promise -``` - -`RunConfig` is resolved execution data: the program id, its run definition, the -binding the caller resolved, flags, run tags and tool lists. `RunInput` is the -invocation snapshot: directory, credentials, project, skill. The agent reports -through `onProgress` (one event per former UI call, copied data, never awaited) -and asks through `interaction` (an optional answerer for `wizard_ask` and task -notices). It returns a `RunResult` — outcome, outro, a failure with the same -fields `wizardAbort` takes, and a snapshot of what it reported. It never -renders, never reads a session, never exits the process and never rejects: a -decided failure is `aborted` or `failed`, an error the agent did not decide is -`crashed` with the original error attached. Types live in `shared/types.ts` and -`../progress.ts`. - -Everything the caller decides first — health and settings gates, OAuth, the AI -opt-in gate, post-auth gates, feature flags, token refresh, the binding — lives -in `src/lib/programs/run-agent-legacy.ts`, which also maps progress back onto +`runAgent(config, input, {onProgress?, interaction?, signal?}) → RunResult`. It +takes resolved execution data and an invocation snapshot (`shared/types.ts`), +reports through `onProgress` and asks through `interaction` (`../progress.ts`), +and returns every ending as a result. It never renders, reads a session or +exits. The gates, OAuth, flags and binding lookup that used to run here live in +`src/lib/programs/run-agent-legacy.ts`, which also maps progress back onto `getUI()` for today's runners. **Prepare** (`shared/bootstrap.ts`) is the on-ramp inside the agent: logging @@ -74,8 +56,7 @@ out to be linear or orchestrator, anthropic or pi, the setup is the same. fetched flags + any CLI overrides, it returns a `ProgramBinding` — which query shape (sequence), which agent SDK (harness), which model. Two independent middleware chains, one per axis, apply precedence rules (CLI > flag > program -config > default). The caller resolves the run-level binding; the orchestrator -re-resolves the harness per task role from the same inputs. +config > default). This is the only layer that makes routing decisions. **Sequences** (`sequence/`) are LLM query shapes. Once the switchboard has picked one, that sequence takes over the run and owns _how the LLM's work is @@ -115,5 +96,5 @@ Each layer is replaceable. 4. Harness drives each conversation through its SDK, using the bound model, on the PostHog LLM gateway. 5. The scan report flushes; `runAgent` returns a `RunResult`. -6. The caller applies it: an outro is already reported, a decided failure goes - to `wizardAbort`, a crash is rethrown for the runner's own handling. +6. The caller applies it: a decided failure goes to `wizardAbort`, a crash is + rethrown for the runner's own handling. diff --git a/src/lib/programs/__tests__/refresh-access-token-if-needed.test.ts b/src/lib/agent/runner/shared/__tests__/refresh-access-token-if-needed.test.ts similarity index 100% rename from src/lib/programs/__tests__/refresh-access-token-if-needed.test.ts rename to src/lib/agent/runner/shared/__tests__/refresh-access-token-if-needed.test.ts diff --git a/src/lib/programs/authenticate.ts b/src/lib/agent/runner/shared/authenticate.ts similarity index 98% rename from src/lib/programs/authenticate.ts rename to src/lib/agent/runner/shared/authenticate.ts index 1c55453dd..8644a328c 100644 --- a/src/lib/programs/authenticate.ts +++ b/src/lib/agent/runner/shared/authenticate.ts @@ -11,7 +11,7 @@ */ import type { Credentials, WizardSession } from '@lib/wizard-session'; -import type { ProgramId } from './program-registry'; +import type { ProgramId } from '@lib/programs/program-registry'; import { getOrAskForProjectData } from '@utils/setup-utils'; import { refreshAccessToken } from '@utils/oauth'; import { OAuthError } from '@utils/oauth-errors'; diff --git a/src/lib/detection/__tests__/project-scope.test.ts b/src/lib/detection/__tests__/project-scope.test.ts index 18f9173dc..46aedaf04 100644 --- a/src/lib/detection/__tests__/project-scope.test.ts +++ b/src/lib/detection/__tests__/project-scope.test.ts @@ -10,12 +10,12 @@ import { AGENTIC_DETECTION_TIMEOUT_MS, WIZARD_BASIC_INTEGRATION_AGENTIC_DETECTION_FLAG_KEY, } from '@lib/constants'; -import { authenticate } from '@lib/programs/authenticate'; +import { authenticate } from '@lib/agent/runner/shared/authenticate'; import { buildSession } from '@lib/wizard-session'; import { analytics } from '@utils/analytics'; // Mock only the two network edges of scopeInstallDirToProject; everything else runs real. -vi.mock('@lib/programs/authenticate', () => ({ +vi.mock('@lib/agent/runner/shared/authenticate', () => ({ authenticate: vi.fn().mockResolvedValue(undefined), })); vi.mock('@lib/detection/agentic', async (importOriginal) => ({ diff --git a/src/lib/detection/project-scope.ts b/src/lib/detection/project-scope.ts index da4ffaf0a..73279b075 100644 --- a/src/lib/detection/project-scope.ts +++ b/src/lib/detection/project-scope.ts @@ -8,7 +8,7 @@ import { type DetectEvent, type DetectTarget, } from './agentic.js'; -import { authenticate } from '@lib/programs/authenticate'; +import { authenticate } from '@lib/agent/runner/shared/authenticate'; import { FRAMEWORK_REGISTRY } from '@lib/registry'; import { AGENTIC_DETECTION_TIMEOUT_MS, diff --git a/src/lib/programs/run-agent-legacy.ts b/src/lib/programs/run-agent-legacy.ts index 3aac92693..4d4c53612 100644 --- a/src/lib/programs/run-agent-legacy.ts +++ b/src/lib/programs/run-agent-legacy.ts @@ -55,7 +55,10 @@ import { } from '@lib/constants'; import { FRAMEWORK_REGISTRY } from '@lib/registry'; import { postAuthGateSteps, type ProgramConfig } from './program-step'; -import { authenticate, refreshAccessTokenIfNeeded } from './authenticate'; +import { + authenticate, + refreshAccessTokenIfNeeded, +} from '@lib/agent/runner/shared/authenticate'; import { maybeStampAiSdkDetected } from './posthog-integration/detect'; import { startAuditLedgerWatcher } from './audit/ledger-watcher'; @@ -474,7 +477,7 @@ function captureSwitchboardDecision( * back to exactly the call the agent used to make, the frame and flow goldens * hold without regeneration. */ -export function createUiReducer(ui: WizardUI): (event: AgentProgress) => void { +function createUiReducer(ui: WizardUI): (event: AgentProgress) => void { let spinner: SpinnerHandle | undefined; return (event) => { switch (event.kind) { @@ -520,7 +523,7 @@ export function createUiReducer(ui: WizardUI): (event: AgentProgress) => void { } /** The agent's questions, answered wherever `getUI()` answers them today. */ -export function uiInteraction(ui: WizardUI): AgentInteraction { +function uiInteraction(ui: WizardUI): AgentInteraction { return { ask: (question) => ui.requestQuestion(question), cancelAsk: () => ui.cancelPendingQuestion(), diff --git a/src/lib/runners/run-wizard.ts b/src/lib/runners/run-wizard.ts index a0e0879ff..77384e6f2 100644 --- a/src/lib/runners/run-wizard.ts +++ b/src/lib/runners/run-wizard.ts @@ -1,7 +1,7 @@ import { VERSION } from '@lib/version'; import { logToFile, getLogFilePath } from '@utils/debug'; import { runAgent } from '@lib/programs/run-agent-legacy'; -import { authenticate } from '@lib/programs/authenticate'; +import { authenticate } from '@lib/agent/runner/shared/authenticate'; import { getProgramConfig } from '@lib/programs/program-registry'; import { getAuditChecks } from '@lib/programs/audit/types'; import { maybeStampAiSdkDetected } from '@lib/programs/posthog-integration/detect'; From b0a4c1e9deef2e2d85049d4c1d171003e1e244c4 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Mon, 21 Sep 2026 18:55:33 -0400 Subject: [PATCH 04/10] fix(agent): preserve runner boundary parity Restore detection progress, isolate progress observers, and preserve completion and cleanup ordering. Type run outcomes and share status history handling; cover harness, sequence, and adapter boundaries. Generated-By: PostHog Desktop Task-Id: d14e92bb-6ee1-49b5-8502-39cb80079589 --- scripts/tui-host.no-jest.ts | 6 +- .../architecture/known-violations.json | 2 +- src/__tests__/cli.test.ts | 2 +- src/__tests__/provision-cli.test.ts | 2 +- .../__tests__/progress-collector.test.ts | 39 +++ .../__tests__/run-agent-standalone.test.ts | 232 ++++++++++++++++-- src/lib/agent/agent-runner.ts | 2 +- src/lib/agent/progress.ts | 13 +- src/lib/agent/runner/README.md | 10 +- src/lib/agent/runner/index.ts | 11 +- src/lib/agent/runner/sequence/linear.ts | 55 +---- .../orchestrator/__tests__/executor.test.ts | 44 ++++ .../__tests__/task-notice-timeout.test.ts | 40 +-- .../runner/sequence/orchestrator/executor.ts | 30 ++- .../orchestrator/orchestrator-runner.ts | 119 ++++----- src/lib/agent/runner/shared/ask.ts | 3 +- src/lib/agent/runner/shared/bootstrap.ts | 16 +- src/lib/agent/runner/shared/errors.ts | 7 +- .../agent/runner/shared/progress-collector.ts | 8 +- src/lib/agent/runner/shared/types.ts | 56 ++--- src/lib/agent/runner/switchboard/sequence.ts | 4 +- .../__tests__/agentic-progress.test.ts | 80 ++++++ src/lib/detection/agentic.ts | 4 +- .../__tests__/run-agent-legacy.test.ts | 227 +++++++++++++++++ src/lib/programs/posthog-integration/index.ts | 4 +- src/lib/programs/run-agent-legacy.ts | 89 +------ .../runners/__tests__/mint-recovery.test.ts | 6 +- src/lib/runners/run-non-interactive.ts | 8 +- src/lib/runners/run-wizard.ts | 6 +- src/lib/status-history.ts | 9 + src/ui/__tests__/agent-progress.test.ts | 129 ++++++++++ src/ui/agent-progress.ts | 71 ++++++ src/ui/tui/constants.ts | 2 +- src/ui/tui/store.ts | 20 +- 34 files changed, 1007 insertions(+), 349 deletions(-) create mode 100644 src/lib/agent/__tests__/progress-collector.test.ts create mode 100644 src/lib/detection/__tests__/agentic-progress.test.ts create mode 100644 src/lib/programs/__tests__/run-agent-legacy.test.ts create mode 100644 src/lib/status-history.ts create mode 100644 src/ui/__tests__/agent-progress.test.ts create mode 100644 src/ui/agent-progress.ts diff --git a/scripts/tui-host.no-jest.ts b/scripts/tui-host.no-jest.ts index a6ceb1ce8..d20bfc8cb 100644 --- a/scripts/tui-host.no-jest.ts +++ b/scripts/tui-host.no-jest.ts @@ -27,7 +27,7 @@ import type { Harness, Sequence } from '@lib/constants'; import { buildSession } from '@lib/wizard-session'; import { initLocalDev } from '@lib/local-dev'; import { configureGatewayFromCIEnvironment } from '@lib/gateway-session'; -import { runAgent } from '@lib/programs/run-agent-legacy'; +import { runProgramAgent } from '@lib/programs/run-agent-legacy'; import { TaskStreamPush, createFileDestination } from '@lib/task-stream/index'; import { getAuditChecks } from '@lib/programs/audit/types'; import { authenticate } from '@lib/agent/runner/shared/authenticate'; @@ -334,13 +334,13 @@ async function main() { await step.run(await runSessionFor(step)); store.completeRunStep(step.id); } else if (step.screenId === 'run') { - await runAgent(programConfig, await runSessionFor(step)); + await runProgramAgent(programConfig, await runSessionFor(step)); } else if (step.isComplete) { await store.waitUntil(step.isComplete); } } } else { - await runAgent(programConfig, store.session); + await runProgramAgent(programConfig, store.session); } }; diff --git a/src/__tests__/architecture/known-violations.json b/src/__tests__/architecture/known-violations.json index a2f9049bc..d83527e0a 100644 --- a/src/__tests__/architecture/known-violations.json +++ b/src/__tests__/architecture/known-violations.json @@ -38,7 +38,6 @@ "src/lib/programs/revenue-analytics/index.ts -> src/lib/programs/revenue-analytics/content/index.tsx", "src/lib/programs/run-agent-legacy.ts -> src/lib/agent/agent-interface.ts", "src/lib/programs/run-agent-legacy.ts -> src/lib/agent/claude-settings.ts", - "src/lib/programs/run-agent-legacy.ts -> src/lib/agent/progress.ts", "src/lib/programs/run-agent-legacy.ts -> src/lib/agent/runner/index.ts", "src/lib/programs/run-agent-legacy.ts -> src/lib/agent/runner/shared/authenticate.ts", "src/lib/programs/run-agent-legacy.ts -> src/lib/agent/runner/switchboard/index.ts", @@ -63,6 +62,7 @@ "src/steps/add-or-update-environment-variables.ts -> src/telemetry.ts", "src/steps/run-prettier.ts -> src/telemetry.ts", "src/steps/upload-environment-variables/index.ts -> src/telemetry.ts", + "src/ui/agent-progress.ts -> src/lib/agent/progress.ts", "src/ui/index.ts -> src/ui/logging-ui.ts", "src/ui/logging-ui.ts -> src/lib/agent/claude-settings.ts", "src/ui/tui/components/PhaseVisuals.tsx -> src/lib/agent/agent-phase.ts", diff --git a/src/__tests__/cli.test.ts b/src/__tests__/cli.test.ts index e4ebbe951..4633470ee 100644 --- a/src/__tests__/cli.test.ts +++ b/src/__tests__/cli.test.ts @@ -113,7 +113,7 @@ vi.mock('../utils/wizard-abort', async (importOriginal) => ({ wizardAbort: vi.fn(), })); vi.mock('../lib/programs/run-agent-legacy', () => ({ - runAgent: vi.fn().mockResolvedValue(undefined), + runProgramAgent: vi.fn().mockResolvedValue(undefined), })); describe('CLI argument parsing', () => { diff --git a/src/__tests__/provision-cli.test.ts b/src/__tests__/provision-cli.test.ts index 10b6539b2..95d547bd0 100644 --- a/src/__tests__/provision-cli.test.ts +++ b/src/__tests__/provision-cli.test.ts @@ -64,7 +64,7 @@ vi.mock('../utils/wizard-abort', async (importOriginal) => ({ wizardAbort: vi.fn(), })); vi.mock('../lib/programs/run-agent-legacy', () => ({ - runAgent: vi.fn().mockResolvedValue(undefined), + runProgramAgent: vi.fn().mockResolvedValue(undefined), })); import { provisionCommand } from '../commands/provision'; diff --git a/src/lib/agent/__tests__/progress-collector.test.ts b/src/lib/agent/__tests__/progress-collector.test.ts new file mode 100644 index 000000000..061292550 --- /dev/null +++ b/src/lib/agent/__tests__/progress-collector.test.ts @@ -0,0 +1,39 @@ +import { OutroKind } from '@lib/wizard-session'; +import { createProgressCollector } from '../runner/shared/progress-collector'; +import { MAX_STATUS_MESSAGES } from '@lib/status-history'; + +it('retains a bounded FIFO of statuses and drops consecutive duplicates', () => { + const observer = vi.fn(); + const collector = createProgressCollector(observer); + for (let i = 0; i < MAX_STATUS_MESSAGES + 3; i++) { + collector.emit({ kind: 'status', message: `status ${i}` }); + collector.emit({ kind: 'status', message: `status ${i}` }); + } + const statuses = collector.snapshot().statusMessages; + expect(statuses).toHaveLength(MAX_STATUS_MESSAGES); + expect(statuses[0]).toBe('status 3'); + expect(statuses.at(-1)).toBe(`status ${MAX_STATUS_MESSAGES + 2}`); + expect(observer).toHaveBeenCalledTimes((MAX_STATUS_MESSAGES + 3) * 2); + statuses.push('external mutation'); + expect(collector.snapshot().statusMessages).toHaveLength(MAX_STATUS_MESSAGES); +}); + +it('isolates nested completion data from a mutating observer', () => { + const outro = { + kind: OutroKind.Success, + message: 'Done', + nextSteps: { heading: 'Next', items: ['Keep the report'] }, + }; + const collector = createProgressCollector((event) => { + if (event.kind === 'completion') { + event.outro.message = 'corrupted'; + event.outro.nextSteps?.items.push('corrupted'); + } + }); + collector.emit({ kind: 'completion', outro }); + expect(outro).toEqual({ + kind: OutroKind.Success, + message: 'Done', + nextSteps: { heading: 'Next', items: ['Keep the report'] }, + }); +}); diff --git a/src/lib/agent/__tests__/run-agent-standalone.test.ts b/src/lib/agent/__tests__/run-agent-standalone.test.ts index 9a08c7569..13f8bc458 100644 --- a/src/lib/agent/__tests__/run-agent-standalone.test.ts +++ b/src/lib/agent/__tests__/run-agent-standalone.test.ts @@ -9,15 +9,17 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { Harness, Sequence } from '@lib/constants'; +import { Harness, Sequence, DEFAULT_AGENT_MODEL } from '@lib/constants'; import { HostResolution } from '@lib/host-resolution'; import { OutroKind, type PendingQuestion } from '@lib/wizard-session'; import { AGENT_ERROR_CODE, ErrorCodes } from '@lib/errors'; import { AgentErrorType } from '@lib/agent/signals'; +import type { AgentFailure } from '@lib/agent/runner/shared/types'; import type { AgentProgress } from '@lib/agent/progress'; import type { AgentHarness, BackendRunInputs, + TaskRunInputs, } from '@lib/agent/runner/harness/types'; vi.mock('@ui', () => ({ @@ -29,6 +31,10 @@ vi.mock('@ui', () => ({ }, })); vi.mock('@utils/debug'); +vi.mock('@lib/yara-hooks', async (original) => ({ + ...(await original()), + flushScanReport: vi.fn(), +})); vi.mock('@utils/analytics', () => ({ analytics: { build: 'test', @@ -56,11 +62,33 @@ const harnessState = vi.hoisted(() => ({ result: {} as { error?: string; message?: string; failure?: unknown }, throws: undefined as Error | undefined, lastInputs: undefined as unknown, + tasks: [] as TaskRunInputs[], + selected: [] as Harness[], + taskFailure: undefined as AgentFailure | undefined, + seedFailure: undefined as AgentFailure | undefined, askQuestions: undefined as PendingQuestion['questions'] | undefined, })); vi.mock('@lib/agent/runner/switchboard/harness', () => { const fake: AgentHarness = { name: Harness.pi, + runTask(inputs: TaskRunInputs) { + harnessState.tasks.push(inputs); + const { store, currentTaskId } = inputs.orchestrator; + if (!currentTaskId) { + if (harnessState.seedFailure) + return Promise.resolve({ failure: harnessState.seedFailure }); + store.enqueue({ type: 'install' }); + } else if (harnessState.taskFailure) { + return Promise.resolve({ failure: harnessState.taskFailure }); + } else { + store.complete(currentTaskId, { + goals: 'install', + did: 'installed', + forNextAgent: 'done', + }); + } + return Promise.resolve({}); + }, async run(inputs: BackendRunInputs) { harnessState.lastInputs = inputs; const { emit, spinner } = inputs; @@ -72,6 +100,9 @@ vi.mock('@lib/agent/runner/switchboard/harness', () => { tasks: [{ content: 'Install', status: 'completed' }], }); emit({ kind: 'url', which: 'dashboard', url: 'https://d/1' }); + emit({ kind: 'url', which: 'notebook', url: 'https://n/1' }); + emit({ kind: 'stage', stage: 'Configure' }); + emit({ kind: 'finalCost', usd: 0.25 }); emit({ kind: 'usage', delta: { @@ -99,14 +130,55 @@ vi.mock('@lib/agent/runner/switchboard/harness', () => { }; return { HARNESS_OPTIONS: { [Harness.pi]: fake }, - getHarness: () => fake, - resolveHarness: () => ({ harness: Harness.pi, model: 'm' }), + getHarness: (name: Harness) => { + harnessState.selected.push(name); + return { ...fake, name }; + }, + resolveHarness: (ctx: { cliHarness?: Harness }) => ({ + harness: ctx.cliHarness ?? Harness.pi, + model: DEFAULT_AGENT_MODEL, + }), + }; +}); + +vi.mock('@lib/agent/agent-prompt-loader', async (original) => { + const actual = await original< + typeof import('@lib/agent/agent-prompt-loader') + >(); + return { + ...actual, + loadAgentRegistry: vi.fn(() => + Promise.resolve( + actual.buildRegistry( + [ + actual.parseAgentPrompt( + '---\ntype: seed\nseed: true\n---\nPlan work', + 'seed', + 'test-program', + ), + actual.parseAgentPrompt( + '---\ntype: install\n---\nInstall it', + 'install', + 'test-program', + ), + ], + 'test-program', + ), + ), + ), }; }); +vi.mock('@lib/wizard-tools', async (original) => ({ + ...(await original()), + fetchSkillMenu: vi.fn().mockResolvedValue({ categories: {} }), +})); import { runAgent } from '@lib/agent/runner'; import type { RunConfig, RunInput } from '@lib/agent/runner'; import { analytics } from '@utils/analytics'; +import { initLogFile } from '@utils/debug'; +import { flushScanReport } from '@lib/yara-hooks'; +import { QUEUE_DIR_NAME } from '../runner/sequence/orchestrator/queue'; let tmp: string; @@ -166,15 +238,152 @@ const input = (over: Partial = {}): RunInput => ({ beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'run-agent-standalone-')); harnessState.result = {}; + harnessState.tasks = []; + harnessState.selected = []; + harnessState.taskFailure = undefined; + harnessState.seedFailure = undefined; harnessState.throws = undefined; harnessState.lastInputs = undefined; harnessState.askQuestions = undefined; vi.mocked(analytics.shutdown).mockClear(); + vi.mocked(initLogFile).mockClear(); + vi.mocked(flushScanReport).mockClear(); }); afterEach(() => fs.rmSync(tmp, { recursive: true, force: true })); describe('runAgent standalone', () => { + it.each([Harness.pi, Harness.anthropic])( + 'dispatches %s through both sequence arms', + async (harness) => { + for (const sequence of [Sequence.linear, Sequence.orchestrator]) { + const result = await runAgent( + config({ + binding: { harness, sequence, model: DEFAULT_AGENT_MODEL }, + switchboard: { + program: 'test-program', + flags: {}, + cliHarness: harness, + }, + }), + input(), + ); + expect(result.outcome).toBe('success'); + expect(harnessState.selected.at(-1)).toBe(harness); + if (sequence === Sequence.linear) { + expect(harnessState.tasks).toHaveLength(0); + } else { + expect(harnessState.tasks).toHaveLength(2); + expect( + harnessState.tasks[1].orchestrator.currentTaskId, + ).toBeDefined(); + expect(result.snapshot.tasks).toEqual([ + { content: 'install', activeForm: 'install', status: 'completed' }, + ]); + } + } + expect(analytics.shutdown).toHaveBeenCalledTimes(2); + expect(analytics.shutdown).toHaveBeenCalledWith('success'); + }, + ); + + it('cleans up when the seed fails before the drain starts', async () => { + const failure = { message: 'Authentication failed (401)' }; + harnessState.seedFailure = failure; + const result = await runAgent( + config({ + binding: { + harness: Harness.anthropic, + sequence: Sequence.orchestrator, + model: DEFAULT_AGENT_MODEL, + }, + switchboard: { + program: 'test-program', + flags: {}, + cliHarness: Harness.anthropic, + }, + }), + input(), + ); + expect(result.outcome).toBe('failed'); + expect(result.failure).toBe(failure); + expect(harnessState.tasks).toHaveLength(1); + expect(fs.existsSync(path.join(tmp, QUEUE_DIR_NAME))).toBe(false); + }); + + it('returns an anthropic orchestrator task failure and cleans up the queue', async () => { + const failure = { + code: ErrorCodes.AgentAbort, + message: 'Authentication failed (401)', + }; + harnessState.taskFailure = failure; + const result = await runAgent( + config({ + binding: { + harness: Harness.anthropic, + sequence: Sequence.orchestrator, + model: DEFAULT_AGENT_MODEL, + }, + switchboard: { + program: 'test-program', + flags: {}, + cliHarness: Harness.anthropic, + }, + }), + input(), + ); + expect(result.outcome).toBe('failed'); + expect(result.failure).toBe(failure); + expect(harnessState.tasks).toHaveLength(2); + expect(fs.existsSync(path.join(tmp, QUEUE_DIR_NAME))).toBe(false); + }); + + it.each([Sequence.linear, Sequence.orchestrator])( + 'preserves %s completion, shutdown and scan-flush ordering', + async (sequence) => { + const order: string[] = []; + vi.mocked(analytics.shutdown).mockImplementationOnce(() => { + order.push('shutdown'); + return Promise.resolve(); + }); + vi.mocked(flushScanReport).mockImplementationOnce(() => { + order.push('scan-flush'); + }); + const result = await runAgent( + config({ + binding: { + harness: Harness.pi, + sequence, + model: DEFAULT_AGENT_MODEL, + }, + }), + input(), + { + onProgress: (event) => { + if (event.kind === 'completion') { + order.push( + fs.existsSync(path.join(tmp, QUEUE_DIR_NAME)) + ? 'queue-present' + : 'queue-clean', + ); + order.push('completion'); + } + if (event.kind === 'lifecycle' && event.phase === 'completed') + order.push('outro'); + }, + }, + ); + expect(result.outcome).toBe('success'); + expect(order).toEqual([ + 'queue-clean', + 'completion', + 'outro', + 'shutdown', + 'scan-flush', + ]); + }, + ); + it('runs to success with an observer and an answerer', async () => { const events: AgentProgress[] = []; const ask = vi.fn(() => Promise.resolve({ q1: 'yes' })); @@ -217,6 +426,9 @@ describe('runAgent standalone', () => { // The agent's own snapshot, independent of the observer. expect(result.snapshot.dashboardUrl).toBe('https://d/1'); + expect(result.snapshot.notebookUrl).toBe('https://n/1'); + expect(result.snapshot.stage).toBe('Configure'); + expect(result.snapshot.finalCostUsd).toBe(0.25); expect(result.snapshot.tasks).toEqual([ { content: 'Install', status: 'completed' }, ]); @@ -227,7 +439,7 @@ describe('runAgent standalone', () => { cacheReadTokens: 0, cacheCreationTokens: 0, }); - expect(analytics.shutdown).toHaveBeenCalledWith('success'); + expect(analytics.shutdown).toHaveBeenCalledExactlyOnceWith('success'); }); it('runs to a complete result with no options at all', async () => { @@ -373,16 +585,4 @@ describe('runAgent standalone', () => { // What was reported before the crash survives in the snapshot. expect(result.snapshot.statusMessages).toContain('Installing the SDK'); }); - - it('is cancelled by a signal that is already aborted', async () => { - const controller = new AbortController(); - controller.abort(); - - const result = await runAgent(config(), input(), { - signal: controller.signal, - }); - - expect(result.outcome).toBe('cancelled'); - expect(harnessState.lastInputs).toBeUndefined(); - }); }); diff --git a/src/lib/agent/agent-runner.ts b/src/lib/agent/agent-runner.ts index 3adf72fd9..2850afcc1 100644 --- a/src/lib/agent/agent-runner.ts +++ b/src/lib/agent/agent-runner.ts @@ -1,7 +1,7 @@ /** * Re-export shim. The runner has been split into agent/runner/. * Import from there directly; this shim keeps existing importers working. - * The session-driven `runAgent(programConfig, session)` lives in + * The session-driven `runProgramAgent(programConfig, session)` lives in * `src/lib/programs/run-agent-legacy.ts`. */ diff --git a/src/lib/agent/progress.ts b/src/lib/agent/progress.ts index a85820dd3..8be0d52f2 100644 --- a/src/lib/agent/progress.ts +++ b/src/lib/agent/progress.ts @@ -81,20 +81,13 @@ export type ProgressEmitter = (event: AgentProgress) => void; export interface AgentInteraction { /** * Open a question and resolve with the answers. The bridge that calls this - * owns the timeout, the `__cancelled__` sentinel and the analytics; `signal` - * aborts when the run is cancelled so the host can dismiss its overlay. + * owns the timeout, the `__cancelled__` sentinel and the analytics. */ - ask?: ( - question: PendingQuestion, - context: { signal: AbortSignal }, - ) => Promise; + ask?: (question: PendingQuestion) => Promise; /** Dismiss the in-flight question as cancelled (timeouts call this). */ cancelAsk?: () => void; /** Offer an optional step and resolve with whether to keep it. */ - taskNotice?: ( - notice: TaskNotice, - context: { signal: AbortSignal }, - ) => Promise; + taskNotice?: (notice: TaskNotice) => Promise; /** Dismiss an in-flight task notice as declined (timeouts call this). */ cancelTaskNotice?: () => void; } diff --git a/src/lib/agent/runner/README.md b/src/lib/agent/runner/README.md index a96dfaf12..5f43656cb 100644 --- a/src/lib/agent/runner/README.md +++ b/src/lib/agent/runner/README.md @@ -40,11 +40,11 @@ for the coordinated change checklist. Five layers, each with its own job. Nothing crosses layers unless it has to. **The entry point** (`index.ts`) is the front door: -`runAgent(config, input, {onProgress?, interaction?, signal?}) → RunResult`. It -takes resolved execution data and an invocation snapshot (`shared/types.ts`), -reports through `onProgress` and asks through `interaction` (`../progress.ts`), -and returns every ending as a result. It never renders, reads a session or -exits. The gates, OAuth, flags and binding lookup that used to run here live in +`runAgent(config, input, {onProgress?, interaction?}) → RunResult`. It takes +resolved execution data and an invocation snapshot (`shared/types.ts`), reports +through `onProgress` and asks through `interaction` (`../progress.ts`), and +returns every ending as a result. It never renders, reads a session or exits. +The gates, OAuth, flags and binding lookup that used to run here live in `src/lib/programs/run-agent-legacy.ts`, which also maps progress back onto `getUI()` for today's runners. diff --git a/src/lib/agent/runner/index.ts b/src/lib/agent/runner/index.ts index 6c60112aa..10ea0bdf8 100644 --- a/src/lib/agent/runner/index.ts +++ b/src/lib/agent/runner/index.ts @@ -5,7 +5,7 @@ * pipeline to a decided result. `config` is resolved execution data (which * program, how it is routed, which flags apply); `input` is the invocation * snapshot (directory, credentials, project); `options` carries an optional - * progress observer, an optional answerer and an optional cancel signal. + * progress observer, an optional answerer. * * The pipeline prepares the run (logging targets, gateway mint, scan triage), * then forks. The `orchestrator` variant routes to the task-queue runner. @@ -16,13 +16,15 @@ * exits the process and never rejects. A decided failure comes back in * `RunResult.failure` with the same fields `wizardAbort` takes; an error the * agent did not decide (a refused mint, an SDK crash) comes back as - * `outcome: 'crashed'` with the original error attached, so a caller can keep + * `outcome: RunOutcome.Crashed` with the original error attached, so a caller can keep * handling it the way it always did. The legacy adapter in * `src/lib/programs/run-agent-legacy.ts` rebuilds today's session-driven * behavior on top of this call for every existing caller. */ import { Sequence } from '@lib/constants'; +import { RunOutcome } from './shared/types'; +export { RunOutcome } from './shared/types'; import { classifyRunFailure } from '@lib/errors'; import { logToFile } from '@utils/debug'; import type { @@ -49,7 +51,6 @@ export type { RunFlags, RunHooks, RunInput, - RunOutcome, RunResult, RunSnapshot, SeedTaskEntry, @@ -77,7 +78,6 @@ export async function runAgent( ): Promise { const collector = createProgressCollector(options.onProgress); const { emit } = collector; - const signal = options.signal ?? new AbortController().signal; const log = (message: string) => emit({ kind: 'log', level: 'info', message }); @@ -101,7 +101,6 @@ export async function runAgent( boot, emit, interaction: options.interaction, - signal, }); return { ...result, @@ -114,7 +113,7 @@ export async function runAgent( const failure = classifyRunFailure(error); logToFile('[agent-runner] run crashed:', error); return { - outcome: 'crashed', + outcome: RunOutcome.Crashed, skillId: input.skillId, failure: { code: failure.code, diff --git a/src/lib/agent/runner/sequence/linear.ts b/src/lib/agent/runner/sequence/linear.ts index 38aed52f1..cbdc169dc 100644 --- a/src/lib/agent/runner/sequence/linear.ts +++ b/src/lib/agent/runner/sequence/linear.ts @@ -20,56 +20,25 @@ import { analytics } from '../../../../utils/analytics'; import { formatYaraAbortMessage } from '../../../yara-hooks'; import { installSkillById } from '../../../wizard-tools'; import { assemblePrompt } from '../../agent-prompt'; -import type { - AgentFailure, - RunResult, - RunSnapshot, - SequenceContext, -} from '../shared/types'; -import { installFailure } from '../shared/errors'; +import type { SequenceResult, SequenceContext } from '../shared/types'; +import { failed, installFailure } from '../shared/errors'; +import { RunOutcome } from '../shared/types'; import { shouldDisableAsk, runOptions } from '../shared/bootstrap'; import { createEmitSpinner } from '../shared/progress-collector'; import { createAskBridge } from '../shared/ask'; import { getHarness } from '../switchboard'; -/** The snapshot the sequence returns; `runAgent` fills it from its collector. */ -const PENDING_SNAPSHOT: RunSnapshot = { - tasks: [], - statusMessages: [], - usage: { - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheCreationTokens: 0, - }, -}; - -const failed = (failure: AgentFailure): RunResult => ({ - outcome: 'failed', - failure, - snapshot: PENDING_SNAPSHOT, -}); - -const cancelled = (): RunResult => ({ - outcome: 'cancelled', - failure: { message: 'Wizard setup cancelled.' }, - snapshot: PENDING_SNAPSHOT, -}); - export async function runLinearProgram({ config, input, boot, emit, interaction, - signal, -}: SequenceContext): Promise { +}: SequenceContext): Promise { const { run, composed } = config; const { skillsBaseUrl, credentials, project } = boot; const { projectApiKey, host, projectId } = credentials; - if (signal.aborted) return cancelled(); - // 5. Skill install (if skillId provided) let skillPath: string | undefined; if (run.skillId) { @@ -87,8 +56,6 @@ export async function runLinearProgram({ logToFile(`[agent-runner] skill installed at ${skillPath}`); } - if (signal.aborted) return cancelled(); - // 6. Initialize agent const spinner = createEmitSpinner(emit); @@ -102,7 +69,7 @@ export async function runLinearProgram({ shouldDisableAsk(input.flags) && process.env.WIZARD_ASK_AUTODRIVE !== '1'; const ask = askDisabled ? undefined - : createAskBridge(interaction, signal, { + : createAskBridge(interaction, { getSource: () => input.skillId ?? run.integrationLabel, richLinks: run.richLinks ?? false, timeoutMs: run.askTimeoutMs, @@ -180,7 +147,7 @@ export async function runLinearProgram({ matched: matched?.message ?? null, }); return { - outcome: 'aborted', + outcome: RunOutcome.Aborted, failure: { outroData, code: abortCode, @@ -194,7 +161,6 @@ export async function runLinearProgram({ abortCode, ), }, - snapshot: PENDING_SNAPSHOT, }; } @@ -325,10 +291,9 @@ export async function runLinearProgram({ await config.hooks.postRun(credentials); } - // A composed sub-run (integration inside self-driving) skips the terminal - // outro + analytics shutdown so the shared client survives the host's run. + // A composed sub-run leaves the terminal outro to its host. if (composed) { - return { outcome: 'success', snapshot: PENDING_SNAPSHOT }; + return { outcome: RunOutcome.Success }; } // 11. Outro @@ -349,8 +314,6 @@ export async function runLinearProgram({ emit({ kind: 'lifecycle', phase: 'completed', message: run.successMessage }); - // 12. Analytics shutdown await analytics.shutdown('success'); - - return { outcome: 'success', outro: outroData, snapshot: PENDING_SNAPSHOT }; + return { outcome: RunOutcome.Success, outro: outroData }; } diff --git a/src/lib/agent/runner/sequence/orchestrator/__tests__/executor.test.ts b/src/lib/agent/runner/sequence/orchestrator/__tests__/executor.test.ts index f83794ad8..c76f75e86 100644 --- a/src/lib/agent/runner/sequence/orchestrator/__tests__/executor.test.ts +++ b/src/lib/agent/runner/sequence/orchestrator/__tests__/executor.test.ts @@ -9,6 +9,7 @@ import { } from '@lib/agent/runner/sequence/orchestrator/queue'; import { drainQueue, + RunTaskFatal, type RunTask, } from '@lib/agent/runner/sequence/orchestrator/executor'; @@ -39,6 +40,49 @@ describe('drainQueue', () => { return Promise.resolve(); }; + it('waits for live siblings after a fatal error and starts no dependents', async () => { + const fatal = new RunTaskFatal({ message: 'Authentication failed' }); + let release!: () => void; + const blocked = new Promise((resolve) => { + release = resolve; + }); + q.enqueue({ type: 'fatal' }); + const sibling = q.enqueue({ type: 'sibling' }); + q.enqueue({ type: 'dependent', dependsOn: [sibling.id] }); + const started: string[] = []; + const drain = drainQueue(q, async (task) => { + started.push(task.type); + if (task.type === 'fatal') throw fatal; + await blocked; + q.complete(task.id, HANDOFF); + }); + let settled = false; + const result = drain.catch((error: unknown) => { + settled = true; + return error; + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(settled).toBe(false); + expect(started).toEqual(['fatal', 'sibling']); + release(); + expect(await result).toBe(fatal); + expect(q.get(sibling.id)?.status).toBe(TaskStatus.Done); + expect(started).toEqual(['fatal', 'sibling']); + }); + + it('preserves a fatal failure when a sibling completes in the same turn', async () => { + q.enqueue({ type: 'success' }); + q.enqueue({ type: 'fatal' }); + const fatal = new RunTaskFatal({ message: 'Authentication failed' }); + await expect( + drainQueue(q, (task) => { + if (task.type === 'fatal') return Promise.reject(fatal); + q.complete(task.id, HANDOFF); + return Promise.resolve(); + }), + ).rejects.toBe(fatal); + }); + it('runs a single task to done and drains', async () => { const a = q.enqueue({ type: 'install' }); await drainQueue(q, completing, { maxStarts: 50 }); diff --git a/src/lib/agent/runner/sequence/orchestrator/__tests__/task-notice-timeout.test.ts b/src/lib/agent/runner/sequence/orchestrator/__tests__/task-notice-timeout.test.ts index 40c6642f8..79056bd33 100644 --- a/src/lib/agent/runner/sequence/orchestrator/__tests__/task-notice-timeout.test.ts +++ b/src/lib/agent/runner/sequence/orchestrator/__tests__/task-notice-timeout.test.ts @@ -70,7 +70,7 @@ describe('task notice timeout', () => { // Nobody presses anything. showTaskNotice.mockReturnValue(new Promise(() => undefined)); - const promise = offerSeededTask(NOTICE, 1000, interaction); + const promise = offerSeededTask(NOTICE, { timeoutMs: 1000, interaction }); vi.advanceTimersByTime(1000); await expect(promise).resolves.toEqual({ keep: false, timedOut: true }); @@ -86,7 +86,10 @@ describe('task notice timeout', () => { try { showTaskNotice.mockResolvedValue(true); - const result = await offerSeededTask(NOTICE, 1000, interaction); + const result = await offerSeededTask(NOTICE, { + timeoutMs: 1000, + interaction, + }); expect(result).toEqual({ keep: true, timedOut: false }); vi.advanceTimersByTime(5000); @@ -105,12 +108,12 @@ describe('task notice timeout', () => { // Both decline, but only one of them means "the user was not there" — // the run reports them differently, and now skips them differently too. - await expect(offerSeededTask(NOTICE, 1000, interaction)).resolves.toEqual( - { - keep: false, - timedOut: false, - }, - ); + await expect( + offerSeededTask(NOTICE, { timeoutMs: 1000, interaction }), + ).resolves.toEqual({ + keep: false, + timedOut: false, + }); expect(cancelTaskNotice).not.toHaveBeenCalled(); } finally { vi.useRealTimers(); @@ -170,7 +173,7 @@ describe('askSeededConsent', () => { showTaskNotice.mockResolvedValue(true); await expect( - askSeededConsent('warehouse', NOTICE, 1000, interaction), + askSeededConsent('warehouse', NOTICE, { timeoutMs: 1000, interaction }), ).resolves.toEqual({ keep: true, timedOut: false, @@ -182,7 +185,7 @@ describe('askSeededConsent', () => { showTaskNotice.mockResolvedValue(false); await expect( - askSeededConsent('warehouse', NOTICE, 1000, interaction), + askSeededConsent('warehouse', NOTICE, { timeoutMs: 1000, interaction }), ).resolves.toEqual({ keep: false, timedOut: false, @@ -195,7 +198,10 @@ describe('askSeededConsent', () => { try { showTaskNotice.mockReturnValue(new Promise(() => undefined)); - const promise = askSeededConsent('warehouse', NOTICE, 1000, interaction); + const promise = askSeededConsent('warehouse', NOTICE, { + timeoutMs: 1000, + interaction, + }); vi.advanceTimersByTime(1000); await expect(promise).resolves.toEqual({ @@ -211,7 +217,10 @@ describe('askSeededConsent', () => { it('reports the answer on one event per offer', async () => { showTaskNotice.mockResolvedValue(true); - await askSeededConsent('warehouse', NOTICE, 1000, interaction); + await askSeededConsent('warehouse', NOTICE, { + timeoutMs: 1000, + interaction, + }); expect(wizardCapture).toHaveBeenCalledTimes(1); expect(wizardCapture).toHaveBeenCalledWith( @@ -226,7 +235,7 @@ describe('askSeededConsent', () => { showTaskNotice.mockRejectedValue(new Error('UI blew up')); await expect( - askSeededConsent('warehouse', NOTICE, 1000, interaction), + askSeededConsent('warehouse', NOTICE, { timeoutMs: 1000, interaction }), ).resolves.toEqual({ keep: false, timedOut: false, @@ -259,7 +268,10 @@ describe('offering notices from the seed loop', () => { []; for (const entry of entries) { answers.push( - await askSeededConsent(entry.type, NOTICE, 60_000, interaction), + await askSeededConsent(entry.type, NOTICE, { + timeoutMs: 60_000, + interaction, + }), ); } return answers; diff --git a/src/lib/agent/runner/sequence/orchestrator/executor.ts b/src/lib/agent/runner/sequence/orchestrator/executor.ts index e17e8c269..e70e5723c 100644 --- a/src/lib/agent/runner/sequence/orchestrator/executor.ts +++ b/src/lib/agent/runner/sequence/orchestrator/executor.ts @@ -112,19 +112,25 @@ export async function drainQueue( ): Promise { const running = new Map>(); let starts = 0; + let failure: { error: unknown } | undefined; - for (;;) { - for (const task of store.nextRunnable()) { - if (++starts > opts.maxStarts) break; - // runOne marks the task running synchronously, so the next - // nextRunnable() call no longer offers it. - const p = runOne(store, runTask, task).finally(() => - running.delete(task.id), - ); - running.set(task.id, p); + try { + for (;;) { + if (failure) throw failure.error; + for (const task of store.nextRunnable()) { + if (++starts > opts.maxStarts) break; + const p = runOne(store, runTask, task) + .catch((error: unknown) => { + failure ??= { error }; + }) + .finally(() => running.delete(task.id)); + running.set(task.id, p); + } + if (running.size === 0) break; + await Promise.race(running.values()); } - if (running.size === 0) break; - // Wake on the first finish; it may have unblocked dependents or requeued. - await Promise.race(running.values()); + } finally { + // No queue or skill cleanup may run while a sibling still uses them. + await Promise.allSettled(running.values()); } } diff --git a/src/lib/agent/runner/sequence/orchestrator/orchestrator-runner.ts b/src/lib/agent/runner/sequence/orchestrator/orchestrator-runner.ts index da9b0d4e2..43c41c137 100644 --- a/src/lib/agent/runner/sequence/orchestrator/orchestrator-runner.ts +++ b/src/lib/agent/runner/sequence/orchestrator/orchestrator-runner.ts @@ -10,6 +10,8 @@ * task resolve to a prompt fetched at startup into the registry. The wizard side * stays product-ignorant: it is the queue, the executor, and the loader. */ +import { failed } from '../../shared/errors'; +import { RunOutcome } from '../../shared/types'; import { randomUUID } from 'crypto'; import { cpSync, @@ -36,8 +38,7 @@ import { ErrorCodes } from '@lib/errors'; import type { AgentInteraction } from '@lib/agent/progress'; import type { AgentFailure, - RunResult, - RunSnapshot, + SequenceResult, SequenceContext, } from '../../shared/types'; import { createEmitSpinner } from '../../shared/progress-collector'; @@ -205,6 +206,11 @@ function resolveReferenceSkillId( */ export const TASK_NOTICE_TIMEOUT_MS = 5 * 60 * 1000; +interface SeededTaskOptions { + timeoutMs?: number; + interaction?: AgentInteraction; +} + /** * Offer an optional step, defaulting to declining it if nobody answers. * @@ -215,9 +221,7 @@ export const TASK_NOTICE_TIMEOUT_MS = 5 * 60 * 1000; */ export async function offerSeededTask( notice: TaskNotice, - timeoutMs: number = TASK_NOTICE_TIMEOUT_MS, - interaction?: AgentInteraction, - signal: AbortSignal = new AbortController().signal, + { timeoutMs = TASK_NOTICE_TIMEOUT_MS, interaction }: SeededTaskOptions = {}, ): Promise<{ keep: boolean; timedOut: boolean }> { // No one to show the notice to: a step nobody can answer for must not run. // The same answer a non-interactive host gives today. @@ -236,7 +240,7 @@ export async function offerSeededTask( }, timeoutMs); }); try { - const keep = await Promise.race([taskNotice(notice, { signal }), timeout]); + const keep = await Promise.race([taskNotice(notice), timeout]); return { keep, timedOut }; } finally { if (timer) clearTimeout(timer); @@ -337,16 +341,9 @@ export function skipDeclinedSeededTasks( export async function askSeededConsent( type: string, notice: TaskNotice, - timeoutMs?: number, - interaction?: AgentInteraction, - signal?: AbortSignal, + options: SeededTaskOptions = {}, ): Promise { - const consent = await offerSeededTask( - notice, - timeoutMs, - interaction, - signal, - ).then( + const consent = await offerSeededTask(notice, options).then( (answer): SeededConsent => ({ ...answer, errored: false }), (err: unknown): SeededConsent => { logToFile( @@ -435,44 +432,40 @@ export function displayOrder( .map((entry) => entry.task); } -/** The snapshot the sequence returns; `runAgent` fills it from its collector. */ -const PENDING_SNAPSHOT: RunSnapshot = { - tasks: [], - statusMessages: [], - usage: { - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheCreationTokens: 0, - }, -}; - -const failed = (failure: AgentFailure): RunResult => ({ - outcome: 'failed', - failure, - snapshot: PENDING_SNAPSHOT, -}); +export async function runOrchestrator( + context: SequenceContext, +): Promise { + let cleaned = false; + const cleanupQueue = (): void => { + if (cleaned) return; + cleaned = true; + try { + rmSync(path.join(context.input.installDir, QUEUE_DIR_NAME), { + recursive: true, + force: true, + }); + } catch (error) { + analytics.captureException( + error instanceof Error ? error : new Error(String(error)), + { step: 'orchestrator_cache_cleanup' }, + ); + } + }; + try { + return await executeOrchestrator(context, cleanupQueue); + } finally { + cleanupQueue(); + } +} -export async function runOrchestrator({ - config, - input, - boot, - emit, - interaction, - signal, -}: SequenceContext): Promise { +async function executeOrchestrator( + { config, input, boot, emit, interaction }: SequenceContext, + cleanupQueue: () => void, +): Promise { const runId = randomUUID(); const { run } = config; const programId = config.programId; - if (signal.aborted) { - return { - outcome: 'cancelled', - failure: { message: 'Wizard setup cancelled.' }, - snapshot: PENDING_SNAPSHOT, - }; - } - // Switchboard context — reused for every per-role harness resolution below. // The caller resolved the run-level binding from it; per-task roles overlay // `binding.contextMillOverride[role]` on the same inputs. @@ -790,13 +783,7 @@ export async function runOrchestrator({ // not, which is why the offer lives here and not there. seededConsent.set( task.id, - await askSeededConsent( - seeded.type, - seeded.notice, - undefined, - interaction, - signal, - ), + await askSeededConsent(seeded.type, seeded.notice, { interaction }), ); } logToFile(`[orchestrator] runner-seeded task ${seeded.type}`); @@ -830,7 +817,7 @@ export async function runOrchestrator({ // Absent in CI and signup, where nobody can answer. const askBridge = shouldDisableAsk(input.flags) ? undefined - : createAskBridge(interaction, signal, { + : createAskBridge(interaction, { getSource: () => input.skillId ?? programId, beforeShow: () => { // How late the first ask lands is the measure of this run shape: it @@ -878,8 +865,7 @@ export async function runOrchestrator({ requestRemark: false, analyticsProperties: { task_type: 'seed', harness: seedPick.harness }, }); - // A decided failure (a 401 the harness already reported) ends the run here, - // before anything is queued, exactly where the harness used to exit. + // A decided seed failure ends the run and releases its queue artifacts. if (seedResult.failure) return failed(seedResult.failure); if (seedResult.error) { logToFile( @@ -1140,20 +1126,7 @@ export async function runOrchestrator({ { step: 'orchestrator_reference_promote' }, ); } - // Success or failure, no run artifact outlives the run — wipe the whole - // cache folder (queue, handoffs, reference example, installed task - // instructions). The .DELETE-ME.md inside is the fallback if we don't. - try { - rmSync(path.join(input.installDir, QUEUE_DIR_NAME), { - recursive: true, - force: true, - }); - } catch (err) { - analytics.captureException( - err instanceof Error ? err : new Error(String(err)), - { step: 'orchestrator_cache_cleanup' }, - ); - } + cleanupQueue(); try { sweepRunInstalledSkills( claudeSkillsDir, @@ -1285,5 +1258,5 @@ export async function runOrchestrator({ emit({ kind: 'completion', outro }); emit({ kind: 'lifecycle', phase: 'completed', message }); await analytics.shutdown('success'); - return { outcome: 'success', outro, snapshot: PENDING_SNAPSHOT }; + return { outcome: RunOutcome.Success, outro }; } diff --git a/src/lib/agent/runner/shared/ask.ts b/src/lib/agent/runner/shared/ask.ts index b109f053b..b7c6c1050 100644 --- a/src/lib/agent/runner/shared/ask.ts +++ b/src/lib/agent/runner/shared/ask.ts @@ -17,7 +17,6 @@ import type { AgentInteraction } from '@lib/agent/progress'; export function createAskBridge( interaction: AgentInteraction | undefined, - signal: AbortSignal, options: { getSource: () => string; richLinks: boolean; @@ -33,7 +32,7 @@ export function createAskBridge( getSource: options.getSource, showQuestion: (question) => { options.beforeShow?.(); - return ask(question, { signal }); + return ask(question); }, cancelQuestion: interaction?.cancelAsk, richLinks: options.richLinks, diff --git a/src/lib/agent/runner/shared/bootstrap.ts b/src/lib/agent/runner/shared/bootstrap.ts index 1b776a87c..7384c5f1a 100644 --- a/src/lib/agent/runner/shared/bootstrap.ts +++ b/src/lib/agent/runner/shared/bootstrap.ts @@ -8,10 +8,9 @@ * arrives already resolved in `RunConfig` and `RunInput`. */ -import { analytics } from '@utils/analytics'; import { createTriageLLMProvider } from '@lib/agent/triage-provider'; import { gatewayAuth } from '@lib/gateway-session'; -import { enableDebugLogs, logToFile, initLogFile } from '@utils/debug'; +import { logToFile } from '@utils/debug'; import { CallType, IS_DEV } from '@lib/constants'; import { VERSION } from '@lib/version'; import { mcpUrlFor } from '@lib/host-resolution'; @@ -67,19 +66,6 @@ export async function prepareRun( config: RunConfig, input: RunInput, ): Promise { - const { run } = config; - - // 1. Init logging + debug - initLogFile(); - logToFile( - `[agent-runner] START ${run.integrationLabel} build=${analytics.build}` + - `${input.flags.ci ? ' (non-interactive)' : ''}`, - ); - - if (input.flags.debug) { - enableDebugLogs(); - } - const { skillsBaseUrl } = config; // Where this run actually points. The three services switch independently, diff --git a/src/lib/agent/runner/shared/errors.ts b/src/lib/agent/runner/shared/errors.ts index c012b59a8..8578d2a0c 100644 --- a/src/lib/agent/runner/shared/errors.ts +++ b/src/lib/agent/runner/shared/errors.ts @@ -5,7 +5,12 @@ import type { InstallSkillResult } from '@lib/wizard-tools'; import { skillErrorCode } from '@lib/errors'; import { WizardError } from '@utils/wizard-abort'; -import type { AgentFailure } from './types'; +import { RunOutcome, type AgentFailure, type SequenceResult } from './types'; + +export const failed = (failure: AgentFailure): SequenceResult => ({ + outcome: RunOutcome.Failed, + failure, +}); /** The failure a skill install error decides. The caller reports and exits. */ export function installFailure( diff --git a/src/lib/agent/runner/shared/progress-collector.ts b/src/lib/agent/runner/shared/progress-collector.ts index ee10ff7d8..6f493dbc3 100644 --- a/src/lib/agent/runner/shared/progress-collector.ts +++ b/src/lib/agent/runner/shared/progress-collector.ts @@ -9,6 +9,7 @@ */ import { logToFile } from '@utils/debug'; +import { appendStatus } from '@lib/status-history'; import type { AgentProgress, ProgressEmitter, @@ -41,7 +42,10 @@ export function createProgressCollector( snapshot.tasks = event.tasks.map((t) => ({ ...t })); break; case 'status': - snapshot.statusMessages.push(event.message); + snapshot.statusMessages = appendStatus( + snapshot.statusMessages, + event.message, + ); break; case 'stage': snapshot.stage = event.stage; @@ -68,7 +72,7 @@ export function createProgressCollector( apply(event); if (!onProgress) return; try { - onProgress(event); + onProgress(structuredClone(event)); } catch (error) { // A broken projection is the host's problem, not the run's. Say so in // the log and carry on; the snapshot above is the source of truth. diff --git a/src/lib/agent/runner/shared/types.ts b/src/lib/agent/runner/shared/types.ts index 388695020..20961435b 100644 --- a/src/lib/agent/runner/shared/types.ts +++ b/src/lib/agent/runner/shared/types.ts @@ -181,7 +181,7 @@ export interface RunConfig { programId: string; /** The program's run definition. Its session-taking hooks are the caller's, see `hooks`. */ run: ProgramRun; - /** A composed sub-run skips the terminal outro and the analytics shutdown. */ + /** A composed sub-run leaves the terminal outro to its host. */ composed: boolean; /** Run-level sequence, harness and model. */ binding: ResolvedBinding; @@ -289,21 +289,12 @@ export interface AgentFailure { detail?: Record; } -export type RunOutcome = - /** The run finished; `outro` holds what to show. */ - | 'success' - /** The agent chose to stop (`[ABORT]`); `failure` holds the rendered case. */ - | 'aborted' - /** A coded failure the agent decided; `failure` holds it. */ - | 'failed' - /** `options.signal` aborted the run. */ - | 'cancelled' - /** - * Something threw that the agent did not decide (a refused gateway mint, an - * SDK crash). `failure.error` is the original error and `failure.code` its - * classification, so a caller can report it or rethrow it as it did before. - */ - | 'crashed'; +export enum RunOutcome { + Success = 'success', + Aborted = 'aborted', + Failed = 'failed', + Crashed = 'crashed', +} /** Totals of every `usage` event the run emitted. */ export interface TokenUsageTotals { @@ -324,28 +315,34 @@ export interface RunSnapshot { notebookUrl?: string; } -/** - * `runAgent` never rejects: every ending is one of these, and every ending - * that is not `success` carries a `failure` the caller can act on. - */ -export interface RunResult { - outcome: RunOutcome; +/** A sequence decides an outcome; the dispatcher owns its snapshot. */ +export type SequenceResult = + | { outcome: RunOutcome.Success; outro?: OutroData; failure?: never } + | { + outcome: RunOutcome.Aborted | RunOutcome.Failed; + failure: AgentFailure; + outro?: never; + }; + +/** Every non-success result carries a failure; crashes preserve the original error. */ +export type RunResult = ( + | SequenceResult + | { + outcome: RunOutcome.Crashed; + failure: AgentFailure & { error: Error }; + outro?: never; + } +) & { /** The skill this run installed or was for. */ skillId?: string; - /** Success outro. Absent for a composed sub-run, which has no terminal outro. */ - outro?: OutroData; - /** Present whenever `outcome` is not `success`. */ - failure?: AgentFailure; snapshot: RunSnapshot; -} +}; export interface RunAgentOptions { /** Receives every progress event in emission order. Never awaited. */ onProgress?: (event: import('@lib/agent/progress').AgentProgress) => void; /** Answers the agent's questions. Absent → no ask bridge, notices declined. */ interaction?: AgentInteraction; - /** Cancels the run at its next phase boundary and any pending question. */ - signal?: AbortSignal; } /** What a sequence receives: the contracts plus the prepared run. */ @@ -355,5 +352,4 @@ export interface SequenceContext { boot: BootstrapResult; emit: ProgressEmitter; interaction: AgentInteraction | undefined; - signal: AbortSignal; } diff --git a/src/lib/agent/runner/switchboard/sequence.ts b/src/lib/agent/runner/switchboard/sequence.ts index 521eda57b..3d2730405 100644 --- a/src/lib/agent/runner/switchboard/sequence.ts +++ b/src/lib/agent/runner/switchboard/sequence.ts @@ -12,7 +12,7 @@ import { resolveFlagSequence, } from './flags'; import { getHarness, resolveHarness } from './harness'; -import type { RunResult, SequenceContext } from '../shared/types'; +import type { SequenceResult, SequenceContext } from '../shared/types'; import { runLinearProgram } from '../sequence/linear'; import { runOrchestrator } from '../sequence/orchestrator/orchestrator-runner'; import { @@ -28,7 +28,7 @@ import { export interface SequenceRunner { readonly name: Sequence; /** Run one program to a decided result. Unexpected errors propagate. */ - run(ctx: SequenceContext): Promise; + run(ctx: SequenceContext): Promise; } export const SEQUENCE_OPTIONS: Partial> = { diff --git a/src/lib/detection/__tests__/agentic-progress.test.ts b/src/lib/detection/__tests__/agentic-progress.test.ts new file mode 100644 index 000000000..387bb24fc --- /dev/null +++ b/src/lib/detection/__tests__/agentic-progress.test.ts @@ -0,0 +1,80 @@ +import { detectProjectsWithAgent } from '../agentic'; +import { + initializeAgent, + runAgent as executeAgent, +} from '@lib/agent/agent-interface'; +import { buildSession } from '@lib/wizard-session'; +import { HostResolution } from '@lib/host-resolution'; +import { getUI } from '@ui'; + +vi.mock('@utils/debug'); +vi.mock('@ui', () => ({ getUI: () => ui })); +const ui = vi.hoisted(() => ({ + addTokenUsage: vi.fn(), + setStage: vi.fn(), + pushStatus: vi.fn(), + log: { error: vi.fn() }, +})); +vi.mock('@lib/agent/agent-interface', async (original) => ({ + ...(await original()), + initializeAgent: vi.fn(), + runAgent: vi.fn(), +})); + +it('keeps initialization and execution progress visible during detection', async () => { + const delta = { + inputTokens: 5, + outputTokens: 2, + cacheReadTokens: 0, + cacheCreationTokens: 0, + cacheCreation5m: 0, + cacheCreation1h: 0, + }; + vi.mocked(initializeAgent).mockImplementation((config) => { + config.emit?.({ + kind: 'log', + level: 'error', + message: 'Initialization diagnostic', + }); + return Promise.resolve({ emit: config.emit } as Awaited< + ReturnType + >); + }); + vi.mocked(executeAgent).mockImplementation( + (config, _prompt, _options, _spinner, _messages, middleware) => { + config.emit?.({ kind: 'usage', delta }); + config.emit?.({ kind: 'stage', stage: 'Scanning' }); + config.emit?.({ kind: 'status', message: 'Found a project' }); + config.emit?.({ + kind: 'log', + level: 'error', + message: 'Execution diagnostic', + }); + middleware?.onMessage({ + type: 'result', + result: + '{"projects":[{"path":".","targetId":"node","framework":"Node.js"}]}', + }); + return Promise.resolve({}); + }, + ); + const session = buildSession({ installDir: '/tmp/detection-test' }); + session.credentials = { + accessToken: 'test', + projectApiKey: 'phc_test', + projectId: 1, + host: HostResolution.fromApiHost('https://us.posthog.com'), + }; + const report = await detectProjectsWithAgent(session, { + programId: 'posthog-integration', + targets: [{ id: 'node', name: 'Node.js' }], + }); + expect(report.projects[0].targetId).toBe('node'); + expect(getUI().addTokenUsage).toHaveBeenCalledWith(delta); + expect(ui.setStage).toHaveBeenCalledWith('Scanning'); + expect(ui.pushStatus).toHaveBeenCalledWith('Found a project'); + expect(ui.log.error.mock.calls).toEqual([ + ['Initialization diagnostic'], + ['Execution diagnostic'], + ]); +}); diff --git a/src/lib/detection/agentic.ts b/src/lib/detection/agentic.ts index aac8d282a..c4106e99c 100644 --- a/src/lib/detection/agentic.ts +++ b/src/lib/detection/agentic.ts @@ -25,7 +25,8 @@ import { 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'; -import type { SpinnerHandle } from '@ui'; +import { getUI, type SpinnerHandle } from '@ui'; +import { createUiReducer } from '@ui/agent-progress'; /** A category the agent classifies each project into (id the agent returns). */ export type DetectTarget = { id: string; name: string }; @@ -360,6 +361,7 @@ export async function detectProjectsWithAgent( const agent = await initializeAgent( { + emit: createUiReducer(getUI()), workingDirectory: cwd, posthogMcpUrl: host.mcpUrl, posthogApiKey: accessToken, diff --git a/src/lib/programs/__tests__/run-agent-legacy.test.ts b/src/lib/programs/__tests__/run-agent-legacy.test.ts new file mode 100644 index 000000000..6a0b9b1c4 --- /dev/null +++ b/src/lib/programs/__tests__/run-agent-legacy.test.ts @@ -0,0 +1,227 @@ +import { runNonInteractive } from '@lib/runners/run-non-interactive'; +import { authenticate } from '@lib/agent/runner/shared/authenticate'; +import { runProgramAgent } from '../run-agent-legacy'; +import { runAgent, RunOutcome, type RunResult } from '@lib/agent/runner'; +import { Harness, Sequence } from '@lib/constants'; +import { buildSession, OutroKind } from '@lib/wizard-session'; +import { HostResolution } from '@lib/host-resolution'; +import { LoggingUI } from '@ui/logging-ui'; +import { setUI } from '@ui'; +import { analytics } from '@utils/analytics'; +import { initLogFile } from '@utils/debug'; +import { wizardAbort } from '@utils/wizard-abort'; +import type { ProgramConfig } from '../program-step'; + +const streamShutdown = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); +vi.mock('@env', async (original) => ({ + ...(await original()), + IS_PRODUCTION_BUILD: false, +})); +vi.mock('@lib/local-dev', async (original) => ({ + ...(await original()), + checkLocalServices: vi.fn().mockResolvedValue(null), +})); +vi.mock('@utils/environment', async (original) => ({ + ...(await original()), + readEnvironment: () => ({}), +})); +vi.mock('@lib/gateway-session', async (original) => ({ + ...(await original()), + configureGatewayFromCIEnvironment: vi.fn(), +})); +vi.mock('@lib/task-stream/index', () => ({ + TaskStreamPush: class { + attach = vi.fn(); + shutdown = streamShutdown; + }, + PostHogDestination: class {}, + createFileDestination: () => null, +})); +vi.mock('@utils/debug'); +vi.mock('@utils/analytics', () => ({ + analytics: { + build: 'test', + runId: 'run-1', + wizardCapture: vi.fn(), + setTag: vi.fn(), + getAllFlagsForWizard: vi.fn().mockResolvedValue({}), + getWizardFlagPayloads: vi.fn().mockReturnValue({}), + shutdown: vi.fn().mockResolvedValue(undefined), + }, + sessionProperties: () => ({}), +})); +vi.mock('@lib/agent/runner', async (original) => ({ + ...(await original()), + runAgent: vi.fn(), +})); +vi.mock('@lib/agent/runner/shared/authenticate', () => ({ + authenticate: vi.fn().mockResolvedValue(undefined), + refreshAccessTokenIfNeeded: vi.fn().mockResolvedValue(undefined), +})); +vi.mock('@lib/agent/claude-settings', () => ({ + checkAllSettingsConflicts: vi.fn().mockReturnValue([]), + restoreClaudeSettings: vi.fn(), +})); +vi.mock('@utils/wizard-abort', async (original) => ({ + ...(await original()), + registerCleanup: vi.fn(), + wizardAbort: vi.fn().mockResolvedValue(undefined), +})); +vi.mock('../posthog-integration/detect', () => ({ + maybeStampAiSdkDetected: vi.fn(), +})); + +const program = (id: ProgramConfig['id'] = 'metrics'): ProgramConfig => ({ + id, + steps: [], + description: 'Test', + run: { + integrationLabel: 'test', + spinnerMessage: 'Working', + successMessage: 'Done', + estimatedDurationMinutes: 1, + reportFile: 'report.md', + docsUrl: 'https://docs.test', + }, +}); +const snapshot = { + tasks: [], + statusMessages: [], + usage: { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + }, +}; +const session = () => ({ + ...buildSession({ ci: true, installDir: '/tmp/adapter-test' }), + credentials: { + accessToken: 'test', + projectApiKey: 'phc_test', + projectId: 1, + host: HostResolution.fromApiHost('https://us.posthog.com'), + }, +}); + +let logSpy: ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(authenticate).mockImplementation((sess) => { + sess.credentials = session().credentials; + return Promise.resolve(); + }); + setUI(new LoggingUI()); + logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + vi.mocked(runAgent).mockImplementation((_config, _input, options) => { + options?.onProgress?.({ kind: 'status', message: 'Working' }); + options?.onProgress?.({ + kind: 'completion', + outro: { kind: OutroKind.Success, message: 'Done' }, + }); + options?.onProgress?.({ + kind: 'lifecycle', + phase: 'completed', + message: 'Done', + }); + return Promise.resolve({ outcome: RunOutcome.Success, snapshot }); + }); +}); +afterEach(() => logSpy.mockRestore()); + +it.each([ + ['metrics', Harness.pi, Sequence.orchestrator], + ['replay-vision', Harness.anthropic, Sequence.orchestrator], +] as const)( + 'forwards the %s program binding through the real adapter', + async (id, harness, sequence) => { + await runProgramAgent(program(id), session()); + expect(runAgent).toHaveBeenCalledWith( + expect.objectContaining({ + programId: id, + binding: expect.objectContaining({ harness, sequence }), + }), + expect.objectContaining({ flags: expect.objectContaining({ ci: true }) }), + expect.objectContaining({ + onProgress: expect.any(Function), + interaction: expect.any(Object), + }), + ); + expect(logSpy).toHaveBeenCalledWith('◇ Working'); + expect(logSpy).toHaveBeenCalledWith('└ Done'); + expect(initLogFile).toHaveBeenCalledOnce(); + expect(analytics.shutdown).not.toHaveBeenCalled(); + }, +); + +it('clamps a composed program to linear and keeps host analytics alive', async () => { + await runProgramAgent(program(), session(), { composed: true }); + expect(runAgent).toHaveBeenCalledWith( + expect.objectContaining({ + composed: true, + binding: expect.objectContaining({ sequence: Sequence.linear }), + }), + expect.anything(), + expect.anything(), + ); + expect(analytics.shutdown).not.toHaveBeenCalled(); +}); + +it.each([RunOutcome.Aborted, RunOutcome.Failed] as const)( + 'passes a %s result to the existing abort handler', + async (outcome) => { + const failure = { message: 'Failed', exitCode: 2 }; + vi.mocked(runAgent).mockResolvedValue({ outcome, failure, snapshot }); + await runProgramAgent(program(), session()); + expect(wizardAbort).toHaveBeenCalledExactlyOnceWith(failure); + expect(analytics.shutdown).not.toHaveBeenCalled(); + }, +); + +it('rethrows the original crash for the outer runner', async () => { + const error = new Error('mint refused'); + const result: RunResult = { + outcome: RunOutcome.Crashed, + failure: { error }, + snapshot, + }; + vi.mocked(runAgent).mockResolvedValue(result); + await expect(runProgramAgent(program(), session())).rejects.toBe(error); + expect(wizardAbort).not.toHaveBeenCalled(); + expect(analytics.shutdown).not.toHaveBeenCalled(); +}); + +it.each([ + [Harness.pi, Sequence.linear], + [Harness.pi, Sequence.orchestrator], + [Harness.anthropic, Sequence.linear], + [Harness.anthropic, Sequence.orchestrator], +])( + 'runs headless %s/%s through the real CLI adapter', + async (harness, sequence) => { + runNonInteractive( + program(), + { + apiKey: 'phx_test', + projectId: '1', + installDir: '/tmp/adapter-test', + telemetry: false, + harness, + sequence, + }, + 'headless', + ); + await vi.waitFor(() => expect(streamShutdown).toHaveBeenCalledOnce()); + expect(wizardAbort).not.toHaveBeenCalled(); + expect(runAgent).toHaveBeenCalledWith( + expect.objectContaining({ + binding: expect.objectContaining({ harness, sequence }), + }), + expect.objectContaining({ flags: expect.objectContaining({ ci: true }) }), + expect.anything(), + ); + expect(logSpy).toHaveBeenCalledWith('◇ Working'); + expect(logSpy).toHaveBeenCalledWith('└ Done'); + }, +); diff --git a/src/lib/programs/posthog-integration/index.ts b/src/lib/programs/posthog-integration/index.ts index d495b5374..c7ea2560a 100644 --- a/src/lib/programs/posthog-integration/index.ts +++ b/src/lib/programs/posthog-integration/index.ts @@ -1,5 +1,5 @@ import type { ProgramConfig, ProgramStep } from '@lib/programs/program-step'; -import { runAgent } from '@lib/programs/run-agent-legacy'; +import { runProgramAgent } from '@lib/programs/run-agent-legacy'; import type { ProgramRun } from '@lib/agent/agent-runner'; import { WIZARD_TOOL_NAMES } from '@lib/wizard-tools'; import type { WizardSession } from '@lib/wizard-session'; @@ -479,7 +479,7 @@ export const integrationRunStep: ProgramStep = { // composed: runs inside the host program (self-driving), so skip the // integration's terminal outro + analytics shutdown of the shared client. run: (session) => - runAgent(posthogIntegrationConfig, session, { composed: true }), + runProgramAgent(posthogIntegrationConfig, session, { composed: true }), isComplete: (session) => session.runPhase === RunPhase.Completed || session.runPhase === RunPhase.Error, diff --git a/src/lib/programs/run-agent-legacy.ts b/src/lib/programs/run-agent-legacy.ts index 4d4c53612..d8315cb88 100644 --- a/src/lib/programs/run-agent-legacy.ts +++ b/src/lib/programs/run-agent-legacy.ts @@ -1,7 +1,7 @@ /** * The session-driven agent runner every existing caller uses. * - * `runAgent(programConfig, session)` rebuilds today's behavior on top of the + * `runProgramAgent(programConfig, session)` rebuilds today's behavior on top of the * functional `runAgent(config, input, options)` in `@lib/agent/runner`: it * runs the gates the TUI owns (health, settings, AI opt-in, post-auth steps), * authenticates, resolves the program's binding, builds the agent's inputs @@ -15,11 +15,11 @@ import type { WizardSession } from '@lib/wizard-session'; import { analytics } from '@utils/analytics'; -import { getUI, type WizardUI } from '@ui'; -import type { SpinnerHandle } from '@lib/agent/progress'; -import type { AgentInteraction, AgentProgress } from '@lib/agent/progress'; +import { getUI } from '@ui'; +import { createUiReducer, uiInteraction } from '@ui/agent-progress'; import { - runAgent as runAgentFunctional, + runAgent, + RunOutcome, resolveBinding, type ProgramRun, type RunConfig, @@ -66,7 +66,7 @@ import { startAuditLedgerWatcher } from './audit/ledger-watcher'; * Resolve a ProgramConfig's agent run definition and execute the pipeline. * Entry point for the runners and for composed run steps. */ -export async function runAgent( +export async function runProgramAgent( programConfig: ProgramConfig, session: WizardSession, options: { composed?: boolean } = {}, @@ -270,20 +270,16 @@ async function runProgram( }, }; - const result = await runAgentFunctional(config, input, { + const result = await runAgent(config, input, { onProgress: createUiReducer(ui), interaction: uiInteraction(ui), }); - // Success already rendered through the reducer (outro data, outro line) and - // shut analytics down. A decided failure exits exactly as it always has. An - // error the agent did not decide used to propagate out of this call, and the - // runners handle it (mint-failure screen, classifyRunFailure): keep throwing - // the original error for them. - if (result.outcome === 'crashed') { - throw result.failure?.error ?? new Error(result.failure?.message); + // The host owns process exits and rethrowing crashes. + if (result.outcome === RunOutcome.Crashed) { + throw result.failure.error; } - if (result.outcome !== 'success') { + if (result.outcome !== RunOutcome.Success) { await wizardAbort(result.failure); } } @@ -468,66 +464,3 @@ function captureSwitchboardDecision( ` sequence=${binding.sequence} (${trace.sequence ?? '?'})`, ); } - -// ── Progress → WizardUI, one call per event ─────────────────────────── - -/** - * The inverse of the agent's former `getUI()` calls: one event, one - * `WizardUI` method, synchronous, in emission order. Because each case maps - * back to exactly the call the agent used to make, the frame and flow goldens - * hold without regeneration. - */ -function createUiReducer(ui: WizardUI): (event: AgentProgress) => void { - let spinner: SpinnerHandle | undefined; - return (event) => { - switch (event.kind) { - case 'lifecycle': - if (event.phase === 'started') ui.startRun(); - else ui.outro(event.message); - break; - case 'spinner': { - const handle = (spinner ??= ui.spinner()); - handle[event.action](event.message); - break; - } - case 'log': - ui.log[event.level](event.message); - break; - case 'status': - ui.pushStatus(event.message); - break; - case 'tasks': - ui.syncTodos(event.tasks); - break; - case 'stage': - ui.setStage(event.stage); - break; - case 'url': - if (event.which === 'dashboard') ui.setDashboardUrl(event.url); - else ui.setNotebookUrl(event.url); - break; - case 'usage': - ui.addTokenUsage(event.delta); - break; - case 'finalCost': - ui.setFinalTokenCostUsd(event.usd); - break; - case 'authError': - ui.showAuthError(event.detail); - break; - case 'completion': - ui.setOutroData(event.outro); - break; - } - }; -} - -/** The agent's questions, answered wherever `getUI()` answers them today. */ -function uiInteraction(ui: WizardUI): AgentInteraction { - return { - ask: (question) => ui.requestQuestion(question), - cancelAsk: () => ui.cancelPendingQuestion(), - taskNotice: (notice) => ui.showTaskNotice(notice), - cancelTaskNotice: () => ui.cancelTaskNotice(), - }; -} diff --git a/src/lib/runners/__tests__/mint-recovery.test.ts b/src/lib/runners/__tests__/mint-recovery.test.ts index 076bf9a20..9a564640e 100644 --- a/src/lib/runners/__tests__/mint-recovery.test.ts +++ b/src/lib/runners/__tests__/mint-recovery.test.ts @@ -1,6 +1,6 @@ import { vi, it, expect, afterEach } from 'vitest'; import { runWizard } from '../run-wizard'; -import { runAgent } from '@lib/programs/run-agent-legacy'; +import { runProgramAgent } from '@lib/programs/run-agent-legacy'; import { startTUI } from '@ui/tui/start-tui'; import { WizardStore } from '@ui/tui/store'; import { InkUI } from '@ui/tui/ink-ui'; @@ -10,7 +10,7 @@ import { ScreenId } from '@ui/tui/router'; import { HostResolution } from '@lib/host-resolution'; import { analytics } from '@utils/analytics'; -vi.mock('@lib/programs/run-agent-legacy', () => ({ runAgent: vi.fn() })); +vi.mock('@lib/programs/run-agent-legacy', () => ({ runProgramAgent: vi.fn() })); vi.mock('@ui/tui/start-tui', () => ({ startTUI: vi.fn() })); vi.mock('@lib/local-dev', async (original) => ({ ...(await original()), @@ -59,7 +59,7 @@ it.each(['continue', 'exit'] as const)( }); // runWizard installs its own session first; auth then sets credentials, // and the agent dies after that. - vi.mocked(runAgent).mockImplementation(() => { + vi.mocked(runProgramAgent).mockImplementation(() => { store.setCredentials({ accessToken: 'tok', projectApiKey: 'pk', diff --git a/src/lib/runners/run-non-interactive.ts b/src/lib/runners/run-non-interactive.ts index a4fd1f037..10ebfa4ca 100644 --- a/src/lib/runners/run-non-interactive.ts +++ b/src/lib/runners/run-non-interactive.ts @@ -79,7 +79,7 @@ export function validateNonInteractiveOptions( * (`runWizardHeadless`) runs. * * Validates flags, builds a `ci:true` session, runs `config.ciPreRun` (or the - * program's `onReady` hooks by default), executes `runAgent`, and routes any + * program's `onReady` hooks by default), executes `runProgramAgent`, and routes any * failure through `wizardAbort`. `wizardAbort` owns all exits — never add a * raw `process.exit` here. * @@ -335,8 +335,10 @@ export function runNonInteractive( } } - const { runAgent } = await import('@lib/programs/run-agent-legacy'); - await runAgent(config, session); + const { runProgramAgent } = await import( + '@lib/programs/run-agent-legacy' + ); + await runProgramAgent(config, session); await settleStream(RunPhase.Completed); } catch (error) { const errorMessage = diff --git a/src/lib/runners/run-wizard.ts b/src/lib/runners/run-wizard.ts index 77384e6f2..42b257cd5 100644 --- a/src/lib/runners/run-wizard.ts +++ b/src/lib/runners/run-wizard.ts @@ -1,6 +1,6 @@ import { VERSION } from '@lib/version'; import { logToFile, getLogFilePath } from '@utils/debug'; -import { runAgent } from '@lib/programs/run-agent-legacy'; +import { runProgramAgent } from '@lib/programs/run-agent-legacy'; import { authenticate } from '@lib/agent/runner/shared/authenticate'; import { getProgramConfig } from '@lib/programs/program-registry'; import { getAuditChecks } from '@lib/programs/audit/types'; @@ -60,7 +60,7 @@ async function advanceStep( await step.run(await prepareRunSession(step, store.session)); store.completeRunStep(step.id); } else if (step.screenId === 'run') { - await runAgent(config, await prepareRunSession(step, store.session)); + await runProgramAgent(config, await prepareRunSession(step, store.session)); } else if (step.isComplete) { await store.waitUntil(step.isComplete); } @@ -262,7 +262,7 @@ export function runWizard( }); } else { try { - await runAgent(config, activeTui.store.session); + await runProgramAgent(config, activeTui.store.session); } catch (error) { // The run threw before its own error handling rendered an outro. // Show the handoff screen and let the user's agent take over. diff --git a/src/lib/status-history.ts b/src/lib/status-history.ts new file mode 100644 index 000000000..5931026c6 --- /dev/null +++ b/src/lib/status-history.ts @@ -0,0 +1,9 @@ +/** Shared FIFO window for agent snapshots and the terminal status panel. */ +export const MAX_STATUS_MESSAGES = 10; + +export function appendStatus(messages: string[], message: string): string[] { + if (messages.length > 0 && messages[messages.length - 1] === message) { + return messages; + } + return [...messages.slice(-(MAX_STATUS_MESSAGES - 1)), message]; +} diff --git a/src/ui/__tests__/agent-progress.test.ts b/src/ui/__tests__/agent-progress.test.ts new file mode 100644 index 000000000..b6bf8c49e --- /dev/null +++ b/src/ui/__tests__/agent-progress.test.ts @@ -0,0 +1,129 @@ +vi.mock('@ui', () => ({ getUI: vi.fn() })); +vi.mock('@utils/debug'); + +import { createUiReducer, uiInteraction } from '../agent-progress'; +import { LoggingUI } from '../logging-ui'; +import type { AgentProgress } from '@lib/agent/progress'; +import { OutroKind } from '@lib/wizard-session'; + +it('projects every progress event onto the matching UI call, in order', () => { + const ui = new LoggingUI(); + const calls: unknown[][] = []; + const methods = [ + 'startRun', + 'outro', + 'pushStatus', + 'syncTodos', + 'setStage', + 'setDashboardUrl', + 'setNotebookUrl', + 'addTokenUsage', + 'setFinalTokenCostUsd', + 'showAuthError', + 'setOutroData', + ] as const; + for (const method of methods) { + vi.spyOn(ui, method).mockImplementation(((...args: unknown[]) => { + calls.push([method, ...args]); + }) as never); + } + for (const level of ['info', 'warn', 'error', 'success', 'step'] as const) { + vi.spyOn(ui.log, level).mockImplementation((message) => { + calls.push([level, message]); + }); + } + const spinner = vi.spyOn(ui, 'spinner').mockReturnValue({ + start: (message) => { + calls.push(['spinner:start', message]); + }, + message: (message) => { + calls.push(['spinner:message', message]); + }, + stop: (message) => { + calls.push(['spinner:stop', message]); + }, + }); + const tasks = [{ content: 'Install', status: 'completed' }]; + const delta = { + inputTokens: 1, + outputTokens: 2, + cacheReadTokens: 3, + cacheCreationTokens: 4, + cacheCreation5m: 4, + cacheCreation1h: 0, + }; + const detail = { hasSettingsConflict: false, logFilePath: '/tmp/wizard.log' }; + const outro = { kind: OutroKind.Success, message: 'Finished' }; + const events: AgentProgress[] = [ + { kind: 'lifecycle', phase: 'started' }, + { kind: 'spinner', action: 'start', message: 'Starting' }, + { kind: 'spinner', action: 'message', message: 'Working' }, + { kind: 'spinner', action: 'stop' }, + ...(['info', 'warn', 'error', 'success', 'step'] as const).map((level) => ({ + kind: 'log' as const, + level, + message: level, + })), + { kind: 'status', message: 'Configured' }, + { kind: 'tasks', tasks }, + { kind: 'stage', stage: 'Install' }, + { kind: 'url', which: 'dashboard', url: 'https://d/1' }, + { kind: 'url', which: 'notebook', url: 'https://n/1' }, + { kind: 'usage', delta }, + { kind: 'finalCost', usd: 1.25 }, + { kind: 'authError', detail }, + { kind: 'completion', outro }, + { kind: 'lifecycle', phase: 'completed', message: 'Finished' }, + ]; + const reduce = createUiReducer(ui); + expect(spinner).not.toHaveBeenCalled(); + events.forEach(reduce); + expect(spinner).toHaveBeenCalledTimes(1); + expect(calls).toEqual([ + ['startRun'], + ['spinner:start', 'Starting'], + ['spinner:message', 'Working'], + ['spinner:stop', undefined], + ['info', 'info'], + ['warn', 'warn'], + ['error', 'error'], + ['success', 'success'], + ['step', 'step'], + ['pushStatus', 'Configured'], + ['syncTodos', tasks], + ['setStage', 'Install'], + ['setDashboardUrl', 'https://d/1'], + ['setNotebookUrl', 'https://n/1'], + ['addTokenUsage', delta], + ['setFinalTokenCostUsd', 1.25], + ['showAuthError', detail], + ['setOutroData', outro], + ['outro', 'Finished'], + ]); +}); + +it('forwards answers, notices and their dismissals', async () => { + const ui = new LoggingUI(); + const question = { id: 'q', source: 'test', questions: [] }; + const notice = { + title: 'Optional', + body: [], + items: [], + prompt: 'Continue?', + confirmLabel: 'Yes', + cancelLabel: 'No', + }; + const ask = vi.spyOn(ui, 'requestQuestion').mockResolvedValue({ q: 'yes' }); + const cancelAsk = vi.spyOn(ui, 'cancelPendingQuestion'); + const show = vi.spyOn(ui, 'showTaskNotice').mockResolvedValue(true); + const cancelNotice = vi.spyOn(ui, 'cancelTaskNotice'); + const interaction = uiInteraction(ui); + await expect(interaction.ask?.(question)).resolves.toEqual({ q: 'yes' }); + await expect(interaction.taskNotice?.(notice)).resolves.toBe(true); + interaction.cancelAsk?.(); + interaction.cancelTaskNotice?.(); + expect(ask).toHaveBeenCalledWith(question); + expect(show).toHaveBeenCalledWith(notice); + expect(cancelAsk).toHaveBeenCalledOnce(); + expect(cancelNotice).toHaveBeenCalledOnce(); +}); diff --git a/src/ui/agent-progress.ts b/src/ui/agent-progress.ts new file mode 100644 index 000000000..d3e3450cc --- /dev/null +++ b/src/ui/agent-progress.ts @@ -0,0 +1,71 @@ +import type { WizardUI, SpinnerHandle } from './wizard-ui'; +import type { AgentInteraction, AgentProgress } from '@lib/agent/progress'; + +// ── Progress → WizardUI, one call per event ─────────────────────────── + +/** + * The inverse of the agent's former `getUI()` calls: one event, one + * `WizardUI` method, synchronous, in emission order. Because each case maps + * back to exactly the call the agent used to make, the frame and flow goldens + * hold without regeneration. + */ +export function createUiReducer(ui: WizardUI): (event: AgentProgress) => void { + let spinner: SpinnerHandle | undefined; + return (event) => { + switch (event.kind) { + case 'lifecycle': + if (event.phase === 'started') ui.startRun(); + else ui.outro(event.message); + break; + case 'spinner': { + const handle = (spinner ??= ui.spinner()); + handle[event.action](event.message); + break; + } + case 'log': + ui.log[event.level](event.message); + break; + case 'status': + ui.pushStatus(event.message); + break; + case 'tasks': + ui.syncTodos(event.tasks); + break; + case 'stage': + ui.setStage(event.stage); + break; + case 'url': + if (event.which === 'dashboard') ui.setDashboardUrl(event.url); + else ui.setNotebookUrl(event.url); + break; + case 'usage': + ui.addTokenUsage(event.delta); + break; + case 'finalCost': + ui.setFinalTokenCostUsd(event.usd); + break; + case 'authError': + ui.showAuthError(event.detail); + break; + case 'completion': + ui.setOutroData(event.outro); + break; + default: { + const unhandled: never = event; + throw new Error( + `Unhandled agent progress: ${JSON.stringify(unhandled)}`, + ); + } + } + }; +} + +/** The agent's questions, answered wherever `getUI()` answers them today. */ +export function uiInteraction(ui: WizardUI): AgentInteraction { + return { + ask: (question) => ui.requestQuestion(question), + cancelAsk: () => ui.cancelPendingQuestion(), + taskNotice: (notice) => ui.showTaskNotice(notice), + cancelTaskNotice: () => ui.cancelTaskNotice(), + }; +} diff --git a/src/ui/tui/constants.ts b/src/ui/tui/constants.ts index 9c055375c..ae420b5c3 100644 --- a/src/ui/tui/constants.ts +++ b/src/ui/tui/constants.ts @@ -6,4 +6,4 @@ */ export const COLLAPSED_COUNT = 2; -export const EXPANDED_COUNT = 10; +export { MAX_STATUS_MESSAGES as EXPANDED_COUNT } from '@lib/status-history'; diff --git a/src/ui/tui/store.ts b/src/ui/tui/store.ts index 4e98e5d0b..f4214510e 100644 --- a/src/ui/tui/store.ts +++ b/src/ui/tui/store.ts @@ -57,7 +57,7 @@ import type { import { getProgramConfig } from '@lib/programs/program-registry'; import { withAiOptInGate } from '@lib/programs/ai-opt-in-gate'; import { reportWarehouseSourcesDetected } from '@lib/programs/posthog-integration/detect'; -import { EXPANDED_COUNT } from '@ui/tui/constants'; +import { appendStatus } from '@lib/status-history'; import { IS_DEV } from '@lib/constants'; import { computeTokenCostUsd } from '@lib/agent/token-pricing'; @@ -120,13 +120,6 @@ interface GateEntry { resolved: boolean; } -/** - * FIFO cap on retained status lines. The status bar is the only consumer and - * renders at most EXPANDED_COUNT lines, so there is no reason to retain more — - * the cap is tied to the window it feeds. - */ -const MAX_STATUS_MESSAGES = EXPANDED_COUNT; - // Capture blocked skill downloads once per readiness result. function captureHealthCheckBlocked(result: WizardReadinessResult): void { try { @@ -1052,15 +1045,8 @@ export class WizardStore { pushStatus(message: string): void { const msgs = this.$statusMessages.get(); - // Skip consecutive duplicate messages (no allocation on the hot path) - if (msgs.length > 0 && msgs[msgs.length - 1] === message) return; - // Nanostore detects change by reference equality, so a new array is - // required. At the cap, allocate exactly once at the final size (dropping - // the oldest entry) rather than push-then-truncate. - const next = - msgs.length >= MAX_STATUS_MESSAGES - ? [...msgs.slice(msgs.length - MAX_STATUS_MESSAGES + 1), message] - : [...msgs, message]; + const next = appendStatus(msgs, message); + if (next === msgs) return; this.$statusMessages.set(next); this.emitChange(); } From dbfbfff6e608a39299718bb467f4f05607e91ffd Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Mon, 21 Sep 2026 19:06:59 -0400 Subject: [PATCH 05/10] fix(agent): pause writes during pending questions Track pending requests in the ask bridge and pass its accessor to both Anthropic harness paths. This restores the permission guard against Write/Edit calls racing an unanswered wizard_ask request. Generated-By: PostHog Desktop Task-Id: d14e92bb-6ee1-49b5-8502-39cb80079589 --- .../__tests__/pending-question.test.ts | 201 ++++++++++++++++++ .../agent/runner/harness/anthropic/index.ts | 2 + src/lib/wizard-ask-bridge.ts | 9 + 3 files changed, 212 insertions(+) create mode 100644 src/lib/agent/runner/harness/anthropic/__tests__/pending-question.test.ts diff --git a/src/lib/agent/runner/harness/anthropic/__tests__/pending-question.test.ts b/src/lib/agent/runner/harness/anthropic/__tests__/pending-question.test.ts new file mode 100644 index 000000000..4102211ea --- /dev/null +++ b/src/lib/agent/runner/harness/anthropic/__tests__/pending-question.test.ts @@ -0,0 +1,201 @@ +import { initializeAgent, wizardCanUseTool } from '@lib/agent/agent-interface'; +import { createAskBridge } from '../../../shared/ask'; +import { anthropicBackend } from '..'; +import type { BackendRunInputs, TaskRunInputs } from '../../types'; +import type { AskAnswers } from '@lib/wizard-session'; +import { Harness, Sequence } from '@lib/constants'; +import { HostResolution } from '@lib/host-resolution'; + +vi.mock('@utils/analytics'); +vi.mock('@utils/debug'); +vi.mock('@lib/agent/aio-capture', () => ({ createAioCapture: vi.fn() })); +vi.mock('@lib/agent/agent-interface', async (original) => ({ + ...(await original()), + initializeAgent: vi.fn().mockResolvedValue({}), + runAgent: vi.fn().mockResolvedValue({}), +})); + +const questions = [{ id: 'q', prompt: 'Continue?', kind: 'text' as const }]; + +async function initializeHarness( + mode: 'linear' | 'task', + askBridge: BackendRunInputs['askBridge'], +) { + const credentials = { + accessToken: 'test', + projectApiKey: 'test', + projectId: 1, + host: HostResolution.fromApiHost('https://us.posthog.com'), + }; + const inputs: BackendRunInputs = { + config: { + programId: 'test', + run: { + integrationLabel: 'test', + spinnerMessage: 'Working', + successMessage: 'Done', + estimatedDurationMinutes: 1, + reportFile: 'report.md', + docsUrl: 'https://docs.test', + }, + composed: false, + binding: { + harness: Harness.anthropic, + sequence: Sequence.linear, + model: 'test', + }, + switchboard: { program: 'test', flags: {} }, + skillsBaseUrl: 'https://skills.test', + wizardFlags: {}, + wizardFlagPayloads: {}, + wizardMetadata: {}, + }, + input: { + installDir: '/test', + flags: { + ci: false, + signup: false, + debug: false, + e2eAsk: false, + localMcp: false, + captureAio: false, + benchmark: false, + yaraReport: false, + }, + host: {}, + credentials, + project: null, + apiUser: null, + }, + boot: { + programId: 'test', + skillsBaseUrl: 'https://skills.test', + credentials, + wizardFlags: {}, + wizardFlagPayloads: {}, + wizardMetadata: {}, + project: null, + triageProvider: undefined, + }, + emit: vi.fn(), + prompt: 'test', + spinner: { start: vi.fn(), stop: vi.fn(), message: vi.fn() }, + model: 'test', + askBridge, + }; + if (mode === 'linear') { + await anthropicBackend.run(inputs); + } else { + if (!anthropicBackend.runTask) throw new Error('Missing task harness'); + await anthropicBackend.runTask({ + ...inputs, + spinnerMessage: 'Working', + successMessage: 'Done', + additionalFeatureQueue: [], + requestRemark: false, + analyticsProperties: {}, + orchestrator: {} as TaskRunInputs['orchestrator'], + }); + } + const call = vi.mocked(initializeAgent).mock.calls.at(-1); + if (!call) throw new Error('Harness did not initialize the agent'); + const [config] = call; + return (behavior: 'allow' | 'deny') => { + for (const tool of ['Write', 'Edit']) { + expect( + wizardCanUseTool( + tool, + { file_path: 'src/app.ts', content: 'changed' }, + { wizardAskPending: config.getPendingQuestion?.() != null }, + ).behavior, + ).toBe(behavior); + } + }; +} + +afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); +}); + +describe.each(['linear', 'task'] as const)( + 'Anthropic %s pending-question permission guard', + (mode) => { + it.each(['answer', 'cancel', 'timeout', 'reject'] as const)( + 'blocks simultaneous Write/Edit and releases them after %s', + async (ending) => { + vi.useFakeTimers(); + let resolve!: (answers: AskAnswers) => void; + let reject!: (error: Error) => void; + const cancelAsk = vi.fn(); + const bridge = createAskBridge( + { + ask: () => { + expectPermission('deny'); + return new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + }, + cancelAsk, + }, + { getSource: () => 'test', richLinks: false, timeoutMs: 1000 }, + ); + if (!bridge) throw new Error('Missing ask bridge'); + const expectPermission = await initializeHarness(mode, bridge); + expectPermission('allow'); + const pending = bridge.request({ questions }); + expectPermission('deny'); + + if (ending === 'reject') { + const rejected = expect(pending).rejects.toThrow('answerer failed'); + reject(new Error('answerer failed')); + await rejected; + } else { + if (ending === 'timeout') { + await vi.advanceTimersByTimeAsync(1000); + } else { + resolve({ q: ending === 'answer' ? 'yes' : '__cancelled__' }); + } + await expect(pending).resolves.toEqual({ + answers: { q: ending === 'answer' ? 'yes' : '__cancelled__' }, + timedOut: ending === 'timeout', + }); + } + + expectPermission('allow'); + expect(cancelAsk).toHaveBeenCalledTimes(ending === 'timeout' ? 1 : 0); + expect(vi.getTimerCount()).toBe(0); + }, + ); + + it('keeps the guard when a concurrent question is rejected', async () => { + let resolve!: (answers: AskAnswers) => void; + const ask = vi + .fn() + .mockImplementationOnce( + () => new Promise((res) => (resolve = res)), + ) + .mockRejectedValueOnce(new Error('another request is pending')); + const bridge = createAskBridge( + { ask }, + { getSource: () => 'test', richLinks: false }, + ); + if (!bridge) throw new Error('Missing ask bridge'); + const expectPermission = await initializeHarness(mode, bridge); + const first = bridge.request({ questions }); + await expect(bridge.request({ questions })).rejects.toThrow( + 'another request is pending', + ); + expectPermission('deny'); + resolve({ q: 'yes' }); + await first; + expectPermission('allow'); + }); + + it('allows file mutations without an answerer', async () => { + const expectPermission = await initializeHarness(mode, undefined); + expectPermission('allow'); + }); + }, +); diff --git a/src/lib/agent/runner/harness/anthropic/index.ts b/src/lib/agent/runner/harness/anthropic/index.ts index 342175f8c..660c5808e 100644 --- a/src/lib/agent/runner/harness/anthropic/index.ts +++ b/src/lib/agent/runner/harness/anthropic/index.ts @@ -60,6 +60,7 @@ export const anthropicBackend: AgentHarness = { programId: boot.programId, integrationLabel: config.integrationLabel, askBridge, + getPendingQuestion: askBridge?.getPendingQuestion, askMaxQuestions: config.maxQuestions, allowedTools: runConfig.allowedTools, disallowedTools: runConfig.disallowedTools, @@ -141,6 +142,7 @@ export const anthropicBackend: AgentHarness = { // Only a task allowed to ask carries a bridge, so the Write/Edit pause // that rides on a pending question stays inside that task's agent. askBridge, + getPendingQuestion: askBridge?.getPendingQuestion, orchestrator, capture, emit, diff --git a/src/lib/wizard-ask-bridge.ts b/src/lib/wizard-ask-bridge.ts index 93b06cefa..9dc974d60 100644 --- a/src/lib/wizard-ask-bridge.ts +++ b/src/lib/wizard-ask-bridge.ts @@ -53,6 +53,8 @@ export interface AskResponse { export interface WizardAskBridge { /** Open the WizardAsk overlay and resolve with the user's answers. */ request(req: WizardAskRequest): Promise; + /** An unresolved question keeps file mutations paused across concurrent requests. */ + getPendingQuestion: () => PendingQuestion | null; } export interface WizardAskBridgeOptions { @@ -114,8 +116,13 @@ export function createWizardAskBridge( opts: WizardAskBridgeOptions, ): WizardAskBridge { const timeoutMs = opts.timeoutMs ?? DEFAULT_ASK_TIMEOUT_MS; + const pendingQuestions = new Map(); return { + getPendingQuestion: () => { + for (const pending of pendingQuestions.values()) return pending; + return null; + }, async request({ questions, subject }) { const pending: PendingQuestion = { id: randomUUID(), @@ -124,6 +131,7 @@ export function createWizardAskBridge( richLinks: opts.richLinks ?? false, askedAt: new Date().toISOString(), }; + pendingQuestions.set(pending.id, pending); const startedAt = Date.now(); let timer: ReturnType | undefined; @@ -168,6 +176,7 @@ export function createWizardAskBridge( return { answers, timedOut }; } finally { if (timer) clearTimeout(timer); + pendingQuestions.delete(pending.id); } }, }; From d2142164aafc4dece5a4cd89fcc88e12ffd94efd Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Mon, 21 Sep 2026 19:40:49 -0400 Subject: [PATCH 06/10] test(agent): preserve question wait behavior Cover delayed answers across both sequences and harness bindings, plus completion when questions are disabled. Production behavior is unchanged. Generated-By: PostHog Desktop Task-Id: d14e92bb-6ee1-49b5-8502-39cb80079589 --- .../__tests__/run-agent-standalone.test.ts | 121 ++++++++++++++++-- 1 file changed, 108 insertions(+), 13 deletions(-) diff --git a/src/lib/agent/__tests__/run-agent-standalone.test.ts b/src/lib/agent/__tests__/run-agent-standalone.test.ts index 13f8bc458..481e8a5eb 100644 --- a/src/lib/agent/__tests__/run-agent-standalone.test.ts +++ b/src/lib/agent/__tests__/run-agent-standalone.test.ts @@ -11,7 +11,11 @@ import * as os from 'os'; import * as path from 'path'; import { Harness, Sequence, DEFAULT_AGENT_MODEL } from '@lib/constants'; import { HostResolution } from '@lib/host-resolution'; -import { OutroKind, type PendingQuestion } from '@lib/wizard-session'; +import { + OutroKind, + type AskAnswers, + type PendingQuestion, +} from '@lib/wizard-session'; import { AGENT_ERROR_CODE, ErrorCodes } from '@lib/errors'; import { AgentErrorType } from '@lib/agent/signals'; import type { AgentFailure } from '@lib/agent/runner/shared/types'; @@ -31,6 +35,7 @@ vi.mock('@ui', () => ({ }, })); vi.mock('@utils/debug'); +vi.mock('@utils/terminal-bell'); vi.mock('@lib/yara-hooks', async (original) => ({ ...(await original()), flushScanReport: vi.fn(), @@ -69,9 +74,19 @@ const harnessState = vi.hoisted(() => ({ askQuestions: undefined as PendingQuestion['questions'] | undefined, })); vi.mock('@lib/agent/runner/switchboard/harness', () => { + const askIfRequested = async (inputs: BackendRunInputs | TaskRunInputs) => { + if (!harnessState.askQuestions || !inputs.askBridge) return; + const { answers } = await inputs.askBridge.request({ + questions: harnessState.askQuestions, + }); + inputs.emit({ + kind: 'status', + message: `answered:${JSON.stringify(answers)}`, + }); + }; const fake: AgentHarness = { name: Harness.pi, - runTask(inputs: TaskRunInputs) { + async runTask(inputs: TaskRunInputs) { harnessState.tasks.push(inputs); const { store, currentTaskId } = inputs.orchestrator; if (!currentTaskId) { @@ -81,6 +96,7 @@ vi.mock('@lib/agent/runner/switchboard/harness', () => { } else if (harnessState.taskFailure) { return Promise.resolve({ failure: harnessState.taskFailure }); } else { + await askIfRequested(inputs); store.complete(currentTaskId, { goals: 'install', did: 'installed', @@ -114,15 +130,7 @@ vi.mock('@lib/agent/runner/switchboard/harness', () => { cacheCreation1h: 0, }, }); - if (harnessState.askQuestions && inputs.askBridge) { - const { answers } = await inputs.askBridge.request({ - questions: harnessState.askQuestions, - }); - emit({ - kind: 'status', - message: `answered:${JSON.stringify(answers)}`, - }); - } + await askIfRequested(inputs); if (harnessState.throws) throw harnessState.throws; spinner.stop('Done'); return harnessState.result as never; @@ -157,7 +165,7 @@ vi.mock('@lib/agent/agent-prompt-loader', async (original) => { 'test-program', ), actual.parseAgentPrompt( - '---\ntype: install\n---\nInstall it', + '---\ntype: install\nallowedTools: [wizard_ask]\n---\nInstall it', 'install', 'test-program', ), @@ -173,7 +181,7 @@ vi.mock('@lib/wizard-tools', async (original) => ({ fetchSkillMenu: vi.fn().mockResolvedValue({ categories: {} }), })); -import { runAgent } from '@lib/agent/runner'; +import { runAgent, RunOutcome } from '@lib/agent/runner'; import type { RunConfig, RunInput } from '@lib/agent/runner'; import { analytics } from '@utils/analytics'; import { initLogFile } from '@utils/debug'; @@ -253,6 +261,93 @@ beforeEach(() => { afterEach(() => fs.rmSync(tmp, { recursive: true, force: true })); describe('runAgent standalone', () => { + it.each([ + [Harness.pi, Sequence.linear], + [Harness.anthropic, Sequence.linear], + [Harness.pi, Sequence.orchestrator], + [Harness.anthropic, Sequence.orchestrator], + ])( + 'waits for the answer before completing %s %s', + async (harness, sequence) => { + harnessState.askQuestions = [ + { id: 'q1', prompt: 'Continue?', kind: 'text' }, + ]; + let release: ((answers: AskAnswers) => void) | undefined; + const ask = vi.fn( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + const events: AgentProgress[] = []; + let settled = false; + const running = runAgent( + config({ + binding: { harness, sequence, model: DEFAULT_AGENT_MODEL }, + switchboard: { + program: 'test-program', + flags: {}, + cliHarness: harness, + }, + }), + input(), + { interaction: { ask }, onProgress: (event) => events.push(event) }, + ).then((result) => { + settled = true; + return result; + }); + + try { + await vi.waitFor(() => expect(ask).toHaveBeenCalledTimes(1)); + await new Promise((resolve) => setImmediate(resolve)); + expect(settled).toBe(false); + expect(events.some((event) => event.kind === 'completion')).toBe(false); + expect(analytics.shutdown).not.toHaveBeenCalled(); + expect(flushScanReport).not.toHaveBeenCalled(); + } finally { + release?.({ q1: 'yes' }); + } + + const result = await running; + expect(result.outcome).toBe(RunOutcome.Success); + expect(result.snapshot.statusMessages).toContain('answered:{"q1":"yes"}'); + expect(analytics.shutdown).toHaveBeenCalledExactlyOnceWith('success'); + expect(flushScanReport).toHaveBeenCalledTimes(1); + }, + ); + + it.each([Sequence.linear, Sequence.orchestrator])( + 'does not wait for disabled questions in %s', + async (sequence) => { + harnessState.askQuestions = [ + { id: 'q1', prompt: 'Continue?', kind: 'text' }, + ]; + const ask = vi.fn(() => + Promise.reject(new Error('disabled answerer called')), + ); + const runConfig = config({ + binding: { harness: Harness.pi, sequence, model: DEFAULT_AGENT_MODEL }, + }); + const runInput = input(); + runInput.flags.ci = true; + vi.stubEnv('WIZARD_ASK_AUTODRIVE', ''); + try { + const result = await runAgent(runConfig, runInput, { + interaction: { ask }, + }); + expect(result.outcome).toBe(RunOutcome.Success); + expect(ask).not.toHaveBeenCalled(); + const inputs = + sequence === Sequence.linear + ? (harnessState.lastInputs as BackendRunInputs) + : harnessState.tasks.at(-1); + expect(inputs?.askBridge).toBeUndefined(); + } finally { + vi.unstubAllEnvs(); + } + }, + ); + it.each([Harness.pi, Harness.anthropic])( 'dispatches %s through both sequence arms', async (harness) => { From a778bcdb459065a4e7aa104366eaef23b4917be8 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Mon, 21 Sep 2026 20:00:59 -0400 Subject: [PATCH 07/10] docs(agent): add runner boundary diagram Generated-By: PostHog Desktop Task-Id: d14e92bb-6ee1-49b5-8502-39cb80079589 --- docs/images/wizard-agent-boundary.png | Bin 0 -> 136119 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/images/wizard-agent-boundary.png diff --git a/docs/images/wizard-agent-boundary.png b/docs/images/wizard-agent-boundary.png new file mode 100644 index 0000000000000000000000000000000000000000..1378135853198e832bb1e0cb7282206e9ade598a GIT binary patch literal 136119 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 NDLqqHD3-Sf`Y$ICo67(I literal 0 HcmV?d00001 From c001f46774e02ab87a19773c3634a2c8a6b8dd4e Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Mon, 21 Sep 2026 20:18:01 -0400 Subject: [PATCH 08/10] docs(agent): remove diagram image Keep the diagram as native Mermaid in the PR description. Generated-By: PostHog Desktop Task-Id: d14e92bb-6ee1-49b5-8502-39cb80079589 --- docs/images/wizard-agent-boundary.png | Bin 136119 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 docs/images/wizard-agent-boundary.png diff --git a/docs/images/wizard-agent-boundary.png b/docs/images/wizard-agent-boundary.png deleted file mode 100644 index 1378135853198e832bb1e0cb7282206e9ade598a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 136119 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 NDLqqHD3-Sf`Y$ICo67(I From 690bdc07bbf8a9afcaf9df037e48a5e274343eaf Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Wed, 23 Sep 2026 10:49:51 -0400 Subject: [PATCH 09/10] fix(agent): give each ask its own cancel signal and leave analytics shutdown to the host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AgentInteraction.ask` and `taskNotice` now take `{ signal }`, as §4.3 of the plan specifies. The ask bridge and the seeded-task offer each own one AbortController per request, and a timeout aborts only that request. `cancelAsk` and `cancelTaskNotice` are gone. The host adapter dismisses the overlay when the signal aborts, catches and logs anything the dismissal throws, and detaches once the request settles. The agent no longer calls `analytics.shutdown('success')`. The legacy adapter, the only `runAgent` caller at this layer, sends it after a non-composed run succeeds. Failure and abort still shut down through wizardAbort. A long-lived host can now decide when terminal analytics go out, instead of the first run deciding for it. Generated-By: PostHog Desktop Task-Id: d14e92bb-6ee1-49b5-8502-39cb80079589 --- src/lib/__tests__/wizard-ask-bridge.test.ts | 55 +++++++-- .../__tests__/run-agent-standalone.test.ts | 22 ++-- src/lib/agent/progress.ts | 17 ++- .../__tests__/pending-question.test.ts | 8 +- src/lib/agent/runner/index.ts | 3 +- src/lib/agent/runner/sequence/linear.ts | 1 - .../__tests__/task-notice-timeout.test.ts | 32 ++--- .../orchestrator/orchestrator-runner.ts | 15 ++- src/lib/agent/runner/shared/ask.ts | 7 +- .../__tests__/run-agent-legacy.test.ts | 65 +++++++--- src/lib/programs/run-agent-legacy.ts | 8 +- src/lib/wizard-ask-bridge.ts | 36 +++--- src/ui/__tests__/agent-progress.test.ts | 114 +++++++++++++++--- src/ui/agent-progress.ts | 34 +++++- 14 files changed, 313 insertions(+), 104 deletions(-) diff --git a/src/lib/__tests__/wizard-ask-bridge.test.ts b/src/lib/__tests__/wizard-ask-bridge.test.ts index 1f12b9a4c..650f71b81 100644 --- a/src/lib/__tests__/wizard-ask-bridge.test.ts +++ b/src/lib/__tests__/wizard-ask-bridge.test.ts @@ -212,15 +212,17 @@ describe('createWizardAskBridge', () => { }); describe('timeout', () => { - it('resolves every field with the cancelled sentinel and dismisses the host overlay when the user does not answer in time', async () => { + it('resolves every field with the cancelled sentinel and aborts the question when the user does not answer in time', async () => { vi.useFakeTimers(); try { // showQuestion intentionally never resolves — the timeout has to win. - const cancelQuestion = vi.fn(); + const signals: AbortSignal[] = []; const bridge = createWizardAskBridge({ getSource: () => 'product-tours', - showQuestion: () => new Promise(() => undefined), - cancelQuestion, + showQuestion: (_question, { signal }) => { + signals.push(signal); + return new Promise(() => undefined); + }, timeoutMs: 1000, }); @@ -230,6 +232,7 @@ describe('createWizardAskBridge', () => { { id: 'audience', prompt: 'Who?', kind: 'text' }, ], }); + expect(signals[0].aborted).toBe(false); vi.advanceTimersByTime(1000); @@ -244,7 +247,7 @@ describe('createWizardAskBridge', () => { // Without this, the host's pending-question state survives the // timeout and every later wizard_ask in the run is rejected as a // duplicate request. - expect(cancelQuestion).toHaveBeenCalledTimes(1); + expect(signals[0].aborted).toBe(true); const cancelledCall = wizardCaptureMock.mock.calls.find( ([name]) => name === 'wizard_ask cancelled', @@ -255,14 +258,16 @@ describe('createWizardAskBridge', () => { } }); - it('does not dismiss the overlay when the user answers before the timeout', async () => { + it('does not abort the question when the user answers before the timeout', async () => { vi.useFakeTimers(); try { - const cancelQuestion = vi.fn(); + const signals: AbortSignal[] = []; const bridge = createWizardAskBridge({ getSource: () => 'product-tours', - showQuestion: () => Promise.resolve({ goal: 'ship it' }), - cancelQuestion, + showQuestion: (_question, { signal }) => { + signals.push(signal); + return Promise.resolve({ goal: 'ship it' }); + }, timeoutMs: 1000, }); @@ -271,7 +276,37 @@ describe('createWizardAskBridge', () => { }); vi.advanceTimersByTime(1000); - expect(cancelQuestion).not.toHaveBeenCalled(); + expect(signals[0].aborted).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it('aborts only the question whose timeout fired', async () => { + vi.useFakeTimers(); + try { + const signals: AbortSignal[] = []; + const bridge = createWizardAskBridge({ + getSource: () => 'product-tours', + showQuestion: (_question, { signal }) => { + signals.push(signal); + return new Promise(() => undefined); + }, + timeoutMs: 1000, + }); + const questions = [ + { id: 'goal', prompt: 'Goal?', kind: 'text' as const }, + ]; + + const first = bridge.request({ questions }); + vi.advanceTimersByTime(500); + void bridge.request({ questions }); + vi.advanceTimersByTime(500); + + await expect(first).resolves.toMatchObject({ timedOut: true }); + // The second question is still open: the first one's timeout must not + // dismiss it. + expect(signals.map((signal) => signal.aborted)).toEqual([true, false]); } finally { vi.useRealTimers(); } diff --git a/src/lib/agent/__tests__/run-agent-standalone.test.ts b/src/lib/agent/__tests__/run-agent-standalone.test.ts index 481e8a5eb..3ce26d1c8 100644 --- a/src/lib/agent/__tests__/run-agent-standalone.test.ts +++ b/src/lib/agent/__tests__/run-agent-standalone.test.ts @@ -311,7 +311,7 @@ describe('runAgent standalone', () => { const result = await running; expect(result.outcome).toBe(RunOutcome.Success); expect(result.snapshot.statusMessages).toContain('answered:{"q1":"yes"}'); - expect(analytics.shutdown).toHaveBeenCalledExactlyOnceWith('success'); + expect(analytics.shutdown).not.toHaveBeenCalled(); expect(flushScanReport).toHaveBeenCalledTimes(1); }, ); @@ -377,8 +377,8 @@ describe('runAgent standalone', () => { ]); } } - expect(analytics.shutdown).toHaveBeenCalledTimes(2); - expect(analytics.shutdown).toHaveBeenCalledWith('success'); + // Terminal analytics belong to the process, so to the host. + expect(analytics.shutdown).not.toHaveBeenCalled(); }, ); @@ -434,7 +434,7 @@ describe('runAgent standalone', () => { }); it.each([Sequence.linear, Sequence.orchestrator])( - 'preserves %s completion, shutdown and scan-flush ordering', + 'preserves %s completion and scan-flush ordering and leaves the shutdown to the host', async (sequence) => { const order: string[] = []; vi.mocked(analytics.shutdown).mockImplementationOnce(() => { @@ -473,7 +473,6 @@ describe('runAgent standalone', () => { 'queue-clean', 'completion', 'outro', - 'shutdown', 'scan-flush', ]); }, @@ -502,11 +501,16 @@ describe('runAgent standalone', () => { }); expect(result.failure).toBeUndefined(); - // The answerer saw the question the bridge built, and its answer came back. + // The answerer saw the question the bridge built, with that question's own + // signal, and its answer came back. expect(ask).toHaveBeenCalledTimes(1); - const [question] = ask.mock.calls[0] as unknown as [PendingQuestion]; + const [question, context] = ask.mock.calls[0] as unknown as [ + PendingQuestion, + { signal: AbortSignal }, + ]; expect(question.questions[0].id).toBe('q1'); expect(question.source).toBe('test-integration'); + expect(context.signal.aborted).toBe(false); expect(events).toContainEqual({ kind: 'status', message: 'answered:{"q1":"yes"}', @@ -534,7 +538,7 @@ describe('runAgent standalone', () => { cacheReadTokens: 0, cacheCreationTokens: 0, }); - expect(analytics.shutdown).toHaveBeenCalledExactlyOnceWith('success'); + expect(analytics.shutdown).not.toHaveBeenCalled(); }); it('runs to a complete result with no options at all', async () => { @@ -618,7 +622,7 @@ describe('runAgent standalone', () => { expect(result.failure).toBe(failure); }); - it('skips the terminal outro and the shutdown for a composed sub-run', async () => { + it('skips the terminal outro for a composed sub-run', async () => { const events: AgentProgress[] = []; const result = await runAgent(config({ composed: true }), input(), { diff --git a/src/lib/agent/progress.ts b/src/lib/agent/progress.ts index 8be0d52f2..5e246d40d 100644 --- a/src/lib/agent/progress.ts +++ b/src/lib/agent/progress.ts @@ -77,17 +77,22 @@ export type ProgressEmitter = (event: AgentProgress) => void; * capability is optional. With none supplied the agent installs no ask bridge, * so `wizard_ask` returns its existing "not available" error, and an optional * task notice is declined — the same path a `--ci` run takes today. + * On a request's `signal` abort, the host dismisses that request alone, and + * that dismissal must not throw: abort listeners run where the agent cannot + * catch them, so Node would rethrow the error as an uncaught exception. */ export interface AgentInteraction { /** * Open a question and resolve with the answers. The bridge that calls this * owns the timeout, the `__cancelled__` sentinel and the analytics. */ - ask?: (question: PendingQuestion) => Promise; - /** Dismiss the in-flight question as cancelled (timeouts call this). */ - cancelAsk?: () => void; + ask?: ( + question: PendingQuestion, + context: { signal: AbortSignal }, + ) => Promise; /** Offer an optional step and resolve with whether to keep it. */ - taskNotice?: (notice: TaskNotice) => Promise; - /** Dismiss an in-flight task notice as declined (timeouts call this). */ - cancelTaskNotice?: () => void; + taskNotice?: ( + notice: TaskNotice, + context: { signal: AbortSignal }, + ) => Promise; } diff --git a/src/lib/agent/runner/harness/anthropic/__tests__/pending-question.test.ts b/src/lib/agent/runner/harness/anthropic/__tests__/pending-question.test.ts index 4102211ea..966cbcb9d 100644 --- a/src/lib/agent/runner/harness/anthropic/__tests__/pending-question.test.ts +++ b/src/lib/agent/runner/harness/anthropic/__tests__/pending-question.test.ts @@ -127,17 +127,17 @@ describe.each(['linear', 'task'] as const)( vi.useFakeTimers(); let resolve!: (answers: AskAnswers) => void; let reject!: (error: Error) => void; - const cancelAsk = vi.fn(); + let signal: AbortSignal | undefined; const bridge = createAskBridge( { - ask: () => { + ask: (_question, context) => { expectPermission('deny'); + signal = context.signal; return new Promise((res, rej) => { resolve = res; reject = rej; }); }, - cancelAsk, }, { getSource: () => 'test', richLinks: false, timeoutMs: 1000 }, ); @@ -164,7 +164,7 @@ describe.each(['linear', 'task'] as const)( } expectPermission('allow'); - expect(cancelAsk).toHaveBeenCalledTimes(ending === 'timeout' ? 1 : 0); + expect(signal?.aborted).toBe(ending === 'timeout'); expect(vi.getTimerCount()).toBe(0); }, ); diff --git a/src/lib/agent/runner/index.ts b/src/lib/agent/runner/index.ts index 10ea0bdf8..9a0d7c103 100644 --- a/src/lib/agent/runner/index.ts +++ b/src/lib/agent/runner/index.ts @@ -13,7 +13,8 @@ * [skill install] → agent init → prompt → run → errors → [postRun] → outro * * The agent reports and asks, it never renders, never reads a session, never - * exits the process and never rejects. A decided failure comes back in + * exits the process, never sends the process's terminal analytics and never + * rejects. A decided failure comes back in * `RunResult.failure` with the same fields `wizardAbort` takes; an error the * agent did not decide (a refused mint, an SDK crash) comes back as * `outcome: RunOutcome.Crashed` with the original error attached, so a caller can keep diff --git a/src/lib/agent/runner/sequence/linear.ts b/src/lib/agent/runner/sequence/linear.ts index cbdc169dc..c452a4fa8 100644 --- a/src/lib/agent/runner/sequence/linear.ts +++ b/src/lib/agent/runner/sequence/linear.ts @@ -314,6 +314,5 @@ export async function runLinearProgram({ emit({ kind: 'lifecycle', phase: 'completed', message: run.successMessage }); - await analytics.shutdown('success'); return { outcome: RunOutcome.Success, outro: outroData }; } diff --git a/src/lib/agent/runner/sequence/orchestrator/__tests__/task-notice-timeout.test.ts b/src/lib/agent/runner/sequence/orchestrator/__tests__/task-notice-timeout.test.ts index 79056bd33..c0be82feb 100644 --- a/src/lib/agent/runner/sequence/orchestrator/__tests__/task-notice-timeout.test.ts +++ b/src/lib/agent/runner/sequence/orchestrator/__tests__/task-notice-timeout.test.ts @@ -11,13 +11,14 @@ import type { TaskNotice } from '@lib/wizard-session'; // Hoisted: `vi.mock` factories are lifted above the imports, so the analytics // factory would otherwise read these before they exist. -const { showTaskNotice, cancelTaskNotice, wizardCapture, captureException } = - vi.hoisted(() => ({ - showTaskNotice: vi.fn<(notice: TaskNotice) => Promise>(), - cancelTaskNotice: vi.fn(), - wizardCapture: vi.fn(), - captureException: vi.fn(), - })); +const { showTaskNotice, wizardCapture, captureException } = vi.hoisted(() => ({ + showTaskNotice: + vi.fn< + (notice: TaskNotice, context: { signal: AbortSignal }) => Promise + >(), + wizardCapture: vi.fn(), + captureException: vi.fn(), +})); vi.mock('@utils/analytics', () => ({ analytics: { @@ -36,9 +37,13 @@ import { } from '@lib/agent/runner/sequence/orchestrator/orchestrator-runner'; /** The answerer under test, standing where `getUI()` used to. */ -const interaction = { - taskNotice: (notice: TaskNotice) => showTaskNotice(notice), - cancelTaskNotice: () => cancelTaskNotice(), +const interaction = { taskNotice: showTaskNotice }; + +/** The signal the offer handed the host with its one notice. */ +const noticeSignal = (): AbortSignal => { + const call = showTaskNotice.mock.calls[0]; + if (!call) throw new Error('No notice was shown'); + return call[1].signal; }; const NOTICE: TaskNotice = { @@ -52,7 +57,6 @@ const NOTICE: TaskNotice = { const resetMocks = () => { showTaskNotice.mockReset(); - cancelTaskNotice.mockReset(); wizardCapture.mockReset(); captureException.mockReset(); }; @@ -75,7 +79,7 @@ describe('task notice timeout', () => { await expect(promise).resolves.toEqual({ keep: false, timedOut: true }); // Without this the modal stays on screen over the rest of the run. - expect(cancelTaskNotice).toHaveBeenCalledTimes(1); + expect(noticeSignal().aborted).toBe(true); } finally { vi.useRealTimers(); } @@ -95,7 +99,7 @@ describe('task notice timeout', () => { vi.advanceTimersByTime(5000); // The timer must not fire after an answer — it would close a modal that // is no longer there and stamp a second outcome on the step. - expect(cancelTaskNotice).not.toHaveBeenCalled(); + expect(noticeSignal().aborted).toBe(false); } finally { vi.useRealTimers(); } @@ -114,7 +118,7 @@ describe('task notice timeout', () => { keep: false, timedOut: false, }); - expect(cancelTaskNotice).not.toHaveBeenCalled(); + expect(noticeSignal().aborted).toBe(false); } finally { vi.useRealTimers(); } diff --git a/src/lib/agent/runner/sequence/orchestrator/orchestrator-runner.ts b/src/lib/agent/runner/sequence/orchestrator/orchestrator-runner.ts index 64d49c67e..c0b2b128a 100644 --- a/src/lib/agent/runner/sequence/orchestrator/orchestrator-runner.ts +++ b/src/lib/agent/runner/sequence/orchestrator/orchestrator-runner.ts @@ -226,21 +226,25 @@ export async function offerSeededTask( // No one to show the notice to: a step nobody can answer for must not run. // The same answer a non-interactive host gives today. if (!interaction?.taskNotice) return { keep: false, timedOut: false }; - const { taskNotice, cancelTaskNotice } = interaction; + const { taskNotice } = interaction; + const controller = new AbortController(); let timer: ReturnType | undefined; let timedOut = false; const timeout = new Promise((resolve) => { timer = setTimeout(() => { timedOut = true; - // Dismisses the overlay and settles the showTaskNotice promise too, so - // the losing side of the race cannot leave a modal on screen. - cancelTaskNotice?.(); + // The host dismisses this notice's overlay and settles its promise too, + // so the losing side of the race cannot leave a modal on screen. + controller.abort(); resolve(false); }, timeoutMs); }); try { - const keep = await Promise.race([taskNotice(notice), timeout]); + const keep = await Promise.race([ + taskNotice(notice, { signal: controller.signal }), + timeout, + ]); return { keep, timedOut }; } finally { if (timer) clearTimeout(timer); @@ -1298,6 +1302,5 @@ async function executeOrchestrator( }; emit({ kind: 'completion', outro }); emit({ kind: 'lifecycle', phase: 'completed', message }); - await analytics.shutdown('success'); return { outcome: RunOutcome.Success, outro }; } diff --git a/src/lib/agent/runner/shared/ask.ts b/src/lib/agent/runner/shared/ask.ts index b7c6c1050..56cae7874 100644 --- a/src/lib/agent/runner/shared/ask.ts +++ b/src/lib/agent/runner/shared/ask.ts @@ -3,7 +3,7 @@ * * `createWizardAskBridge` already owns request ids, the timeout race, the * `__cancelled__` sentinel and the analytics; it only ever needed a - * `showQuestion` and a `cancelQuestion`. Here those come from + * `showQuestion` that honours each question's own signal. Here that comes from * `AgentInteraction` instead of `getUI()`. With no answerer there is no bridge, * so `wizard_ask` reports its existing "not available" error rather than * hanging on a question nobody can see. @@ -30,11 +30,10 @@ export function createAskBridge( return createWizardAskBridge({ getSource: options.getSource, - showQuestion: (question) => { + showQuestion: (question, context) => { options.beforeShow?.(); - return ask(question); + return ask(question, context); }, - cancelQuestion: interaction?.cancelAsk, richLinks: options.richLinks, timeoutMs: options.timeoutMs, }); diff --git a/src/lib/programs/__tests__/run-agent-legacy.test.ts b/src/lib/programs/__tests__/run-agent-legacy.test.ts index 6a0b9b1c4..ab074bcbf 100644 --- a/src/lib/programs/__tests__/run-agent-legacy.test.ts +++ b/src/lib/programs/__tests__/run-agent-legacy.test.ts @@ -106,6 +106,21 @@ const session = () => ({ let logSpy: ReturnType; +/** A run that reports, shows its outro and succeeds. */ +const finishRun: typeof runAgent = (_config, _input, options) => { + options?.onProgress?.({ kind: 'status', message: 'Working' }); + options?.onProgress?.({ + kind: 'completion', + outro: { kind: OutroKind.Success, message: 'Done' }, + }); + options?.onProgress?.({ + kind: 'lifecycle', + phase: 'completed', + message: 'Done', + }); + return Promise.resolve({ outcome: RunOutcome.Success, snapshot }); +}; + beforeEach(() => { vi.clearAllMocks(); vi.mocked(authenticate).mockImplementation((sess) => { @@ -114,19 +129,7 @@ beforeEach(() => { }); setUI(new LoggingUI()); logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); - vi.mocked(runAgent).mockImplementation((_config, _input, options) => { - options?.onProgress?.({ kind: 'status', message: 'Working' }); - options?.onProgress?.({ - kind: 'completion', - outro: { kind: OutroKind.Success, message: 'Done' }, - }); - options?.onProgress?.({ - kind: 'lifecycle', - phase: 'completed', - message: 'Done', - }); - return Promise.resolve({ outcome: RunOutcome.Success, snapshot }); - }); + vi.mocked(runAgent).mockImplementation(finishRun); }); afterEach(() => logSpy.mockRestore()); @@ -151,10 +154,35 @@ it.each([ expect(logSpy).toHaveBeenCalledWith('◇ Working'); expect(logSpy).toHaveBeenCalledWith('└ Done'); expect(initLogFile).toHaveBeenCalledOnce(); - expect(analytics.shutdown).not.toHaveBeenCalled(); + expect(analytics.shutdown).toHaveBeenCalledExactlyOnceWith('success'); }, ); +it('sends terminal analytics after the outro and the run, before the host goes on', async () => { + const order: string[] = []; + logSpy.mockImplementation((line) => { + if (line === '└ Done') order.push('outro'); + }); + vi.mocked(runAgent).mockImplementation(async (...args) => { + const result = await finishRun(...args); + order.push('run-returned'); + return result; + }); + vi.mocked(analytics.shutdown).mockImplementation(() => { + order.push('shutdown'); + return Promise.resolve(); + }); + await runProgramAgent(program(), session()); + order.push('host-continues'); + expect(order).toEqual([ + 'outro', + 'run-returned', + 'shutdown', + 'host-continues', + ]); + expect(analytics.shutdown).toHaveBeenCalledExactlyOnceWith('success'); +}); + it('clamps a composed program to linear and keeps host analytics alive', async () => { await runProgramAgent(program(), session(), { composed: true }); expect(runAgent).toHaveBeenCalledWith( @@ -166,6 +194,10 @@ it('clamps a composed program to linear and keeps host analytics alive', async ( expect.anything(), ); expect(analytics.shutdown).not.toHaveBeenCalled(); + + // The host program's own run, later in the same process, ends it once. + await runProgramAgent(program(), session()); + expect(analytics.shutdown).toHaveBeenCalledExactlyOnceWith('success'); }); it.each([RunOutcome.Aborted, RunOutcome.Failed] as const)( @@ -214,6 +246,11 @@ it.each([ ); await vi.waitFor(() => expect(streamShutdown).toHaveBeenCalledOnce()); expect(wizardAbort).not.toHaveBeenCalled(); + // One terminal event for the process, sent before the stream settles. + expect(analytics.shutdown).toHaveBeenCalledExactlyOnceWith('success'); + expect( + vi.mocked(analytics.shutdown).mock.invocationCallOrder[0], + ).toBeLessThan(streamShutdown.mock.invocationCallOrder[0]); expect(runAgent).toHaveBeenCalledWith( expect.objectContaining({ binding: expect.objectContaining({ harness, sequence }), diff --git a/src/lib/programs/run-agent-legacy.ts b/src/lib/programs/run-agent-legacy.ts index d8315cb88..a22ced039 100644 --- a/src/lib/programs/run-agent-legacy.ts +++ b/src/lib/programs/run-agent-legacy.ts @@ -7,7 +7,8 @@ * authenticates, resolves the program's binding, builds the agent's inputs * from the session, maps every progress event back onto `getUI()` one call * per event, answers the agent's questions through `getUI()`, and applies the - * result — `wizardAbort` for a decided failure, nothing more for success. + * result — `wizardAbort` for a decided failure, the terminal analytics event + * for a finished top-level run. * * This is the only file that knows about `getUI()`, the session and * `wizardAbort` on the agent's behalf. Programs replace it in Release B. @@ -275,12 +276,15 @@ async function runProgram( interaction: uiInteraction(ui), }); - // The host owns process exits and rethrowing crashes. + // The host owns process exits, terminal analytics and rethrowing crashes. if (result.outcome === RunOutcome.Crashed) { throw result.failure.error; } if (result.outcome !== RunOutcome.Success) { await wizardAbort(result.failure); + } else if (!composed) { + // A composed sub-run leaves the terminal event to its host program's run. + await analytics.shutdown('success'); } } diff --git a/src/lib/wizard-ask-bridge.ts b/src/lib/wizard-ask-bridge.ts index 9dc974d60..1a007a8a8 100644 --- a/src/lib/wizard-ask-bridge.ts +++ b/src/lib/wizard-ask-bridge.ts @@ -60,8 +60,20 @@ export interface WizardAskBridge { export interface WizardAskBridgeOptions { /** Returns the active skill id, used as the analytics `source` on the request. */ getSource: () => string; - /** Opens the overlay and resolves once the user submits or cancels. */ - showQuestion: (question: PendingQuestion) => Promise; + /** + * Opens the overlay and resolves once the user submits or cancels. `signal` + * is this question's own: it aborts when the timeout wins the race, and the + * host dismisses this question's overlay. Without that the host keeps its + * pending-question state, and every later `wizard_ask` in the run fails with + * "another request is pending" — one unanswered prompt would block + * credential collection for all remaining sources. The host's abort + * handling must not throw: the bridge cannot catch an abort listener's + * error, and Node rethrows it as an uncaught exception. + */ + showQuestion: ( + question: PendingQuestion, + context: { signal: AbortSignal }, + ) => Promise; /** * Per-question timeout in milliseconds. When the user takes longer than * this to answer, every unanswered field resolves with the @@ -74,14 +86,6 @@ export interface WizardAskBridgeOptions { * Propagated onto every {@link PendingQuestion} this bridge creates. */ richLinks?: boolean; - /** - * Dismiss the host's in-flight question overlay. Called when the timeout - * wins the race: without it the host keeps its pending-question state, and - * every later `wizard_ask` in the run fails with "another request is - * pending" — one unanswered prompt would block credential collection for - * all remaining sources. - */ - cancelQuestion?: () => void; } /** Sentinel returned for unanswered fields on cancellation or timeout. */ @@ -134,24 +138,26 @@ export function createWizardAskBridge( pendingQuestions.set(pending.id, pending); const startedAt = Date.now(); + const controller = new AbortController(); let timer: ReturnType | undefined; let timedOut = false; // Race the user against the timeout. Whichever fires first wins. On - // timeout we also cancel the host's overlay: resolving our side alone - // would leave the host's pending-question state set, and the next - // wizard_ask would be rejected as a duplicate request. + // timeout we also abort this question's signal so the host dismisses its + // overlay: resolving our side alone would leave the host's + // pending-question state set, and the next wizard_ask would be rejected + // as a duplicate request. const timeoutPromise = new Promise((resolve) => { timer = setTimeout(() => { timedOut = true; - opts.cancelQuestion?.(); + controller.abort(); resolve(buildCancelledAnswers(questions)); }, timeoutMs); }); try { const answers = await Promise.race([ - opts.showQuestion(pending), + opts.showQuestion(pending, { signal: controller.signal }), timeoutPromise, ]); const durationMs = Date.now() - startedAt; diff --git a/src/ui/__tests__/agent-progress.test.ts b/src/ui/__tests__/agent-progress.test.ts index b6bf8c49e..bdb1535fd 100644 --- a/src/ui/__tests__/agent-progress.test.ts +++ b/src/ui/__tests__/agent-progress.test.ts @@ -1,10 +1,20 @@ vi.mock('@ui', () => ({ getUI: vi.fn() })); vi.mock('@utils/debug'); +vi.mock('@utils/analytics', () => ({ analytics: { wizardCapture: vi.fn() } })); import { createUiReducer, uiInteraction } from '../agent-progress'; import { LoggingUI } from '../logging-ui'; import type { AgentProgress } from '@lib/agent/progress'; +import { + CANCELLED_SENTINEL, + createWizardAskBridge, +} from '@lib/wizard-ask-bridge'; import { OutroKind } from '@lib/wizard-session'; +import { logToFile } from '@utils/debug'; + +beforeEach(() => { + vi.mocked(logToFile).mockClear(); +}); it('projects every progress event onto the matching UI call, in order', () => { const ui = new LoggingUI(); @@ -102,28 +112,104 @@ it('projects every progress event onto the matching UI call, in order', () => { ]); }); -it('forwards answers, notices and their dismissals', async () => { +const question = { id: 'q', source: 'test', questions: [] }; +const notice = { + title: 'Optional', + body: [], + items: [], + prompt: 'Continue?', + confirmLabel: 'Yes', + cancelLabel: 'No', +}; + +it('forwards answers and notices, leaving the host alone once they settle', async () => { const ui = new LoggingUI(); - const question = { id: 'q', source: 'test', questions: [] }; - const notice = { - title: 'Optional', - body: [], - items: [], - prompt: 'Continue?', - confirmLabel: 'Yes', - cancelLabel: 'No', - }; const ask = vi.spyOn(ui, 'requestQuestion').mockResolvedValue({ q: 'yes' }); const cancelAsk = vi.spyOn(ui, 'cancelPendingQuestion'); const show = vi.spyOn(ui, 'showTaskNotice').mockResolvedValue(true); const cancelNotice = vi.spyOn(ui, 'cancelTaskNotice'); const interaction = uiInteraction(ui); - await expect(interaction.ask?.(question)).resolves.toEqual({ q: 'yes' }); - await expect(interaction.taskNotice?.(notice)).resolves.toBe(true); - interaction.cancelAsk?.(); - interaction.cancelTaskNotice?.(); + const asked = new AbortController(); + const noticed = new AbortController(); + await expect( + interaction.ask?.(question, { signal: asked.signal }), + ).resolves.toEqual({ q: 'yes' }); + await expect( + interaction.taskNotice?.(notice, { signal: noticed.signal }), + ).resolves.toBe(true); + // A late abort must not dismiss whatever the host shows next. + asked.abort(); + noticed.abort(); expect(ask).toHaveBeenCalledWith(question); expect(show).toHaveBeenCalledWith(notice); + expect(cancelAsk).not.toHaveBeenCalled(); + expect(cancelNotice).not.toHaveBeenCalled(); +}); + +it('dismisses an open question or notice when its signal aborts', () => { + const ui = new LoggingUI(); + vi.spyOn(ui, 'requestQuestion').mockReturnValue(new Promise(() => undefined)); + const cancelAsk = vi.spyOn(ui, 'cancelPendingQuestion'); + vi.spyOn(ui, 'showTaskNotice').mockReturnValue(new Promise(() => undefined)); + const cancelNotice = vi.spyOn(ui, 'cancelTaskNotice'); + const interaction = uiInteraction(ui); + const asked = new AbortController(); + const noticed = new AbortController(); + void interaction.ask?.(question, { signal: asked.signal }); + void interaction.taskNotice?.(notice, { signal: noticed.signal }); + + asked.abort(); expect(cancelAsk).toHaveBeenCalledOnce(); + expect(cancelNotice).not.toHaveBeenCalled(); + noticed.abort(); expect(cancelNotice).toHaveBeenCalledOnce(); }); + +it('settles a timed-out question when the host dismissal throws', async () => { + vi.useFakeTimers(); + try { + const ui = new LoggingUI(); + vi.spyOn(ui, 'requestQuestion').mockReturnValue( + new Promise(() => undefined), + ); + const broken = new Error('overlay broken'); + vi.spyOn(ui, 'cancelPendingQuestion').mockImplementation(() => { + throw broken; + }); + const { ask } = uiInteraction(ui); + if (!ask) throw new Error('uiInteraction answers questions'); + const bridge = createWizardAskBridge({ + getSource: () => 'test', + showQuestion: ask, + timeoutMs: 1000, + }); + const result = bridge.request({ + questions: [{ id: 'goal', prompt: 'Goal?', kind: 'text' }], + }); + vi.advanceTimersByTime(1000); + await expect(result).resolves.toEqual({ + answers: { goal: CANCELLED_SENTINEL }, + timedOut: true, + }); + expect(bridge.getPendingQuestion()).toBeNull(); + // Node rethrows an abort listener's error as an uncaught exception the + // bridge cannot catch, so the answerer logs it instead. + expect(logToFile).toHaveBeenCalledWith(expect.any(String), broken); + } finally { + vi.useRealTimers(); + } +}); + +it('logs a throwing notice dismissal instead of throwing from the abort', () => { + const ui = new LoggingUI(); + vi.spyOn(ui, 'showTaskNotice').mockReturnValue(new Promise(() => undefined)); + const broken = new Error('overlay broken'); + vi.spyOn(ui, 'cancelTaskNotice').mockImplementation(() => { + throw broken; + }); + const noticed = new AbortController(); + void uiInteraction(ui).taskNotice?.(notice, { signal: noticed.signal }); + + noticed.abort(); + expect(logToFile).toHaveBeenCalledWith(expect.any(String), broken); +}); diff --git a/src/ui/agent-progress.ts b/src/ui/agent-progress.ts index d3e3450cc..c92f1c108 100644 --- a/src/ui/agent-progress.ts +++ b/src/ui/agent-progress.ts @@ -1,5 +1,6 @@ import type { WizardUI, SpinnerHandle } from './wizard-ui'; import type { AgentInteraction, AgentProgress } from '@lib/agent/progress'; +import { logToFile } from '@utils/debug'; // ── Progress → WizardUI, one call per event ─────────────────────────── @@ -63,9 +64,34 @@ export function createUiReducer(ui: WizardUI): (event: AgentProgress) => void { /** The agent's questions, answered wherever `getUI()` answers them today. */ export function uiInteraction(ui: WizardUI): AgentInteraction { return { - ask: (question) => ui.requestQuestion(question), - cancelAsk: () => ui.cancelPendingQuestion(), - taskNotice: (notice) => ui.showTaskNotice(notice), - cancelTaskNotice: () => ui.cancelTaskNotice(), + ask: (question, { signal }) => + dismissOnAbort(ui.requestQuestion(question), signal, () => + ui.cancelPendingQuestion(), + ), + taskNotice: (notice, { signal }) => + dismissOnAbort(ui.showTaskNotice(notice), signal, () => + ui.cancelTaskNotice(), + ), }; } + +/** + * Dismiss one open request on abort; a settled one leaves the UI alone. A + * throw inside an abort listener reaches no caller: Node rethrows it as an + * uncaught exception, so a broken overlay is logged here instead. + */ +function dismissOnAbort( + open: Promise, + signal: AbortSignal, + dismiss: () => void, +): Promise { + const onAbort = () => { + try { + dismiss(); + } catch (error) { + logToFile('[agent-progress] dismissing an aborted request failed', error); + } + }; + signal.addEventListener('abort', onAbort, { once: true }); + return open.finally(() => signal.removeEventListener('abort', onAbort)); +} From c84b5de88a16ff723dcc167e2fc4eb775fe72f50 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Wed, 23 Sep 2026 11:58:39 -0400 Subject: [PATCH 10/10] fix(programs): keep a successful run successful when its analytics flush fails After a non-composed success the legacy adapter awaits analytics.shutdown('success'). posthog-core can reject that on its flush timeout, and the rejection turned a finished run into a TUI error outro and exit 1, or into a headless wizardAbort. The adapter now logs the failure and moves on. There is still exactly one terminal shutdown, because the first status latches. Generated-By: PostHog Desktop Task-Id: d14e92bb-6ee1-49b5-8502-39cb80079589 --- .../__tests__/run-agent-legacy.test.ts | 64 ++++++++++++++++++- src/lib/programs/run-agent-legacy.ts | 7 +- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/src/lib/programs/__tests__/run-agent-legacy.test.ts b/src/lib/programs/__tests__/run-agent-legacy.test.ts index ab074bcbf..f96618232 100644 --- a/src/lib/programs/__tests__/run-agent-legacy.test.ts +++ b/src/lib/programs/__tests__/run-agent-legacy.test.ts @@ -1,4 +1,5 @@ import { runNonInteractive } from '@lib/runners/run-non-interactive'; +import { runWizard } from '@lib/runners/run-wizard'; import { authenticate } from '@lib/agent/runner/shared/authenticate'; import { runProgramAgent } from '../run-agent-legacy'; import { runAgent, RunOutcome, type RunResult } from '@lib/agent/runner'; @@ -6,9 +7,12 @@ import { Harness, Sequence } from '@lib/constants'; import { buildSession, OutroKind } from '@lib/wizard-session'; import { HostResolution } from '@lib/host-resolution'; import { LoggingUI } from '@ui/logging-ui'; +import { InkUI } from '@ui/tui/ink-ui'; +import { startTUI } from '@ui/tui/start-tui'; +import { WizardStore } from '@ui/tui/store'; import { setUI } from '@ui'; import { analytics } from '@utils/analytics'; -import { initLogFile } from '@utils/debug'; +import { initLogFile, logToFile } from '@utils/debug'; import { wizardAbort } from '@utils/wizard-abort'; import type { ProgramConfig } from '../program-step'; @@ -37,12 +41,14 @@ vi.mock('@lib/task-stream/index', () => ({ PostHogDestination: class {}, createFileDestination: () => null, })); +vi.mock('@ui/tui/start-tui', () => ({ startTUI: vi.fn() })); vi.mock('@utils/debug'); vi.mock('@utils/analytics', () => ({ analytics: { build: 'test', runId: 'run-1', wizardCapture: vi.fn(), + captureException: vi.fn(), setTag: vi.fn(), getAllFlagsForWizard: vi.fn().mockResolvedValue({}), getWizardFlagPayloads: vi.fn().mockReturnValue({}), @@ -262,3 +268,59 @@ it.each([ expect(logSpy).toHaveBeenCalledWith('└ Done'); }, ); + +it('keeps a headless run a success when its terminal analytics flush fails', async () => { + const flushError = new Error('flush timed out'); + vi.mocked(analytics.shutdown).mockRejectedValueOnce(flushError); + runNonInteractive( + program(), + { + apiKey: 'phx_test', + projectId: '1', + installDir: '/tmp/adapter-test', + telemetry: false, + }, + 'headless', + ); + await vi.waitFor(() => expect(streamShutdown).toHaveBeenCalledOnce()); + expect(wizardAbort).not.toHaveBeenCalled(); + expect(analytics.shutdown).toHaveBeenCalledExactlyOnceWith('success'); + expect(logToFile).toHaveBeenCalledWith( + expect.stringContaining('analytics shutdown failed'), + flushError, + ); +}); + +it('keeps a TUI run a success when its terminal analytics flush fails', async () => { + const flushError = new Error('flush timed out'); + vi.mocked(analytics.shutdown).mockRejectedValueOnce(flushError); + const store = new WizardStore('metrics'); + const ui = new InkUI(store); + setUI(ui); + const outroError = vi.spyOn(ui, 'outroError'); + vi.spyOn(store, 'runReadyHooks').mockResolvedValue(undefined); + vi.spyOn(store, 'getGate').mockResolvedValue(undefined); + vi.mocked(startTUI).mockReturnValue({ + store, + unmount: vi.fn(), + waitForSetup: () => Promise.resolve(), + }); + const exit = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + + runWizard(program(), { installDir: '/tmp/adapter-test', telemetry: false }); + await vi.waitFor(() => expect(analytics.shutdown).toHaveBeenCalled()); + store.setSkillsComplete(true); + await vi.waitFor(() => expect(exit).toHaveBeenCalled()); + + expect(exit).toHaveBeenCalledExactlyOnceWith(0); + expect(outroError).not.toHaveBeenCalled(); + expect(store.session.outroData?.kind).toBe(OutroKind.Success); + expect(analytics.shutdown).toHaveBeenCalledExactlyOnceWith('success'); + expect(logToFile).toHaveBeenCalledWith( + expect.stringContaining('analytics shutdown failed'), + flushError, + ); + exit.mockRestore(); +}); diff --git a/src/lib/programs/run-agent-legacy.ts b/src/lib/programs/run-agent-legacy.ts index a22ced039..f813e8d2c 100644 --- a/src/lib/programs/run-agent-legacy.ts +++ b/src/lib/programs/run-agent-legacy.ts @@ -284,7 +284,12 @@ async function runProgram( await wizardAbort(result.failure); } else if (!composed) { // A composed sub-run leaves the terminal event to its host program's run. - await analytics.shutdown('success'); + // The run already succeeded: a failed flush is logged, never the outcome. + try { + await analytics.shutdown('success'); + } catch (error) { + logToFile('[agent-runner] analytics shutdown failed:', error); + } } }