diff --git a/packages/client/src/__tests__/terminal-failure-event.test.ts b/packages/client/src/__tests__/terminal-failure-event.test.ts new file mode 100644 index 000000000..4de6609ad --- /dev/null +++ b/packages/client/src/__tests__/terminal-failure-event.test.ts @@ -0,0 +1,164 @@ +/** + * Schema and registry tests for plan-01-terminal-failure-contract: + * - TerminalFailureScopeSchema accepts all required literals and rejects unknown values + * - safeParseEforgeEvent accepts build:terminal-failure for every required scope literal + * - safeParseEforgeEvent rejects build:terminal-failure with an invalid scope + * - eventRegistry['build:terminal-failure'] exists and summary contains the scope + * - TerminalFailureScopeSchema and TerminalFailureEnvelopeSchema are exported from client + */ + +import { describe, it, expect } from 'vitest'; +import { safeParseEforgeEvent } from '../events.schemas.js'; +import { TerminalFailureScopeSchema, TerminalFailureEnvelopeSchema } from '../events.schemas.js'; +import type { TerminalFailureScope, TerminalFailureEnvelope } from '../events.schemas.js'; +import { eventRegistry, getEventSummary } from '../event-registry.js'; +import { Value } from '@sinclair/typebox/value'; + +// --------------------------------------------------------------------------- +// TerminalFailureScopeSchema — all required literals +// --------------------------------------------------------------------------- + +const REQUIRED_SCOPES: TerminalFailureScope[] = [ + 'plan', 'post-merge-validation', 'prd-validation', 'acceptance-validation', + 'landing', 'artifact-recording', 'daemon', 'compile', 'unknown', +]; + +describe('TerminalFailureScopeSchema', () => { + it.each(REQUIRED_SCOPES)('accepts required scope literal: %s', (scope) => { + expect(Value.Check(TerminalFailureScopeSchema, scope)).toBe(true); + }); + + it('rejects a scope outside the required enum', () => { + expect(Value.Check(TerminalFailureScopeSchema, 'invalid-scope')).toBe(false); + expect(Value.Check(TerminalFailureScopeSchema, '')).toBe(false); + expect(Value.Check(TerminalFailureScopeSchema, 'network-error')).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// TerminalFailureEnvelopeSchema — required and optional fields +// --------------------------------------------------------------------------- + +describe('TerminalFailureEnvelopeSchema', () => { + it('accepts minimal envelope with scope, message, and authoritative', () => { + const envelope: TerminalFailureEnvelope = { scope: 'plan', message: 'Build failed', authoritative: true }; + expect(Value.Check(TerminalFailureEnvelopeSchema, envelope)).toBe(true); + }); + + it('accepts full envelope with all optional fields', () => { + const envelope: TerminalFailureEnvelope = { + scope: 'artifact-recording', + message: 'Artifact recording failed', + authoritative: true, + planId: 'plan-01', + stage: 'artifact-recording', + phaseSummary: 'Phase summary', + phaseStatus: 'failed', + eventType: 'daemon:error', + sourceEventType: 'daemon:error', + sourceEventId: 42, + sourceEventTimestamp: '2026-01-01T00:00:00.000Z', + landing: { status: 'skipped', action: 'pr', reason: 'validation failed' }, + validationPassed: true, + prdValidationPassed: true, + acceptanceValidationPassed: false, + }; + expect(Value.Check(TerminalFailureEnvelopeSchema, envelope)).toBe(true); + }); + + it('rejects envelope missing required scope', () => { + const invalid = { message: 'Build failed', authoritative: true }; + expect(Value.Check(TerminalFailureEnvelopeSchema, invalid)).toBe(false); + }); + + it('rejects envelope missing required message', () => { + const invalid = { scope: 'plan', authoritative: true }; + expect(Value.Check(TerminalFailureEnvelopeSchema, invalid)).toBe(false); + }); + + it('rejects envelope missing required authoritative', () => { + const invalid = { scope: 'plan', message: 'Build failed' }; + expect(Value.Check(TerminalFailureEnvelopeSchema, invalid)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// safeParseEforgeEvent — build:terminal-failure for all scopes +// --------------------------------------------------------------------------- + +describe('safeParseEforgeEvent — build:terminal-failure', () => { + it.each(REQUIRED_SCOPES)('accepts build:terminal-failure with scope: %s', (scope) => { + const event = { + type: 'build:terminal-failure', + runId: 'run-01', + failure: { scope, message: `Terminal failure: ${scope}`, authoritative: true }, + timestamp: '2026-01-01T00:00:00.000Z', + }; + const result = safeParseEforgeEvent(event); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe('build:terminal-failure'); + } + }); + + it('rejects build:terminal-failure with invalid scope', () => { + const event = { + type: 'build:terminal-failure', + runId: 'run-01', + failure: { scope: 'not-a-valid-scope', message: 'fail', authoritative: true }, + timestamp: '2026-01-01T00:00:00.000Z', + }; + const result = safeParseEforgeEvent(event); + expect(result.success).toBe(false); + }); + + it('rejects build:terminal-failure missing failure.scope', () => { + const event = { + type: 'build:terminal-failure', + runId: 'run-01', + failure: { message: 'fail', authoritative: true }, + timestamp: '2026-01-01T00:00:00.000Z', + }; + const result = safeParseEforgeEvent(event); + expect(result.success).toBe(false); + }); + + it('rejects build:terminal-failure missing failure.authoritative', () => { + const event = { + type: 'build:terminal-failure', + runId: 'run-01', + failure: { scope: 'plan', message: 'Build failed' }, + timestamp: '2026-01-01T00:00:00.000Z', + }; + const result = safeParseEforgeEvent(event); + expect(result.success).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// eventRegistry — build:terminal-failure entry +// --------------------------------------------------------------------------- + +describe('eventRegistry[build:terminal-failure]', () => { + it('exists in the registry', () => { + expect(eventRegistry['build:terminal-failure']).toBeDefined(); + }); + + it('is session-scoped and persisted', () => { + const meta = eventRegistry['build:terminal-failure']; + expect(meta.scope).toBe('session'); + expect(meta.persist).toBe(true); + }); + + it('summary contains the terminal failure scope', () => { + const event = { + type: 'build:terminal-failure' as const, + runId: 'run-01', + failure: { scope: 'artifact-recording' as TerminalFailureScope, message: 'Recording failed', authoritative: true }, + timestamp: '2026-01-01T00:00:00.000Z', + }; + const summary = getEventSummary(event); + expect(summary).toBeDefined(); + expect(summary).toContain('artifact-recording'); + }); +}); diff --git a/packages/client/src/browser.ts b/packages/client/src/browser.ts index 94964ce13..723f6d103 100644 --- a/packages/client/src/browser.ts +++ b/packages/client/src/browser.ts @@ -228,6 +228,10 @@ export type { StackArtifactRef, StackLayerWire, // --- eforge:endregion plan-01-stack-contracts-config-state-events --- + // --- eforge:region plan-01-terminal-failure-contract --- + TerminalFailureScope, + TerminalFailureEnvelope, + // --- eforge:endregion plan-01-terminal-failure-contract --- } from './events.js'; // --- eforge:region system-configuration-view --- @@ -254,4 +258,7 @@ export { ORCHESTRATION_MODES, SEVERITY_ORDER, isAlwaysYieldedAgentEvent, REVIEW_ // --- eforge:region plan-01-stack-contracts-config-state-events --- StackProviderSchema, LandingPublicationActionSchema, StackLayerStatusSchema, StackArtifactRefSchema, StackLayerWireSchema, // --- eforge:endregion plan-01-stack-contracts-config-state-events --- + // --- eforge:region plan-01-terminal-failure-contract --- + TerminalFailureScopeSchema, TerminalFailureEnvelopeSchema, + // --- eforge:endregion plan-01-terminal-failure-contract --- } from './events.js'; diff --git a/packages/client/src/event-registry.ts b/packages/client/src/event-registry.ts index b93e5c8ca..5889bb272 100644 --- a/packages/client/src/event-registry.ts +++ b/packages/client/src/event-registry.ts @@ -131,10 +131,7 @@ const eventRegistry = { project: () => undefined, }, - 'session:profile': { - scope: 'session', - persist: false, - }, + 'session:profile': { scope: 'session', persist: false }, // ------------------------------------------------------------------------- // Phase lifecycle @@ -1346,17 +1343,22 @@ const eventRegistry = { // Recovery analysis // ------------------------------------------------------------------------- - 'recovery:start': { + // --- eforge:region plan-01-terminal-failure-contract --- + 'build:terminal-failure': { scope: 'session', - persist: false, - summary: (e) => `Analysing failed build for PRD ${e.prdId}`, + persist: true, + summary: (e) => `Build terminal failure (${e.failure.scope}): ${e.failure.message}`, }, + // --- eforge:endregion plan-01-terminal-failure-contract --- - 'recovery:summary': { + 'recovery:start': { scope: 'session', persist: false, + summary: (e) => `Analysing failed build for PRD ${e.prdId}`, }, + 'recovery:summary': { scope: 'session', persist: false }, + 'recovery:complete': { scope: 'session', persist: false, @@ -1369,10 +1371,7 @@ const eventRegistry = { summary: (e) => `Recovery parse failed: ${e.error}`, }, - 'recovery:apply:start': { - scope: 'session', - persist: false, - }, + 'recovery:apply:start': { scope: 'session', persist: false }, 'recovery:apply:complete': { scope: 'session', diff --git a/packages/client/src/events.schemas.ts b/packages/client/src/events.schemas.ts index 95f2fc479..675eb9145 100644 --- a/packages/client/src/events.schemas.ts +++ b/packages/client/src/events.schemas.ts @@ -541,6 +541,23 @@ const FailingPlanEntrySchema = Type.Object({ // --- eforge:endregion plan-01-recovery-summary-reconstruction --- }); +// --- eforge:region plan-01-terminal-failure-contract --- +export const TerminalFailureScopeSchema = Type.Union([ + Type.Literal('plan'), Type.Literal('post-merge-validation'), Type.Literal('prd-validation'), + Type.Literal('acceptance-validation'), Type.Literal('artifact-recording'), + Type.Literal('landing'), Type.Literal('daemon'), Type.Literal('compile'), Type.Literal('unknown'), +]); +const TFLandingSchema = Type.Object({ status: Type.String(), action: Type.Optional(Type.String()), reason: Type.Optional(Type.String()) }); +export const TerminalFailureEnvelopeSchema = Type.Object({ + scope: TerminalFailureScopeSchema, message: Type.String(), + authoritative: Type.Boolean(), planId: Type.Optional(Type.String()), + stage: Type.Optional(Type.String()), phaseSummary: Type.Optional(Type.String()), + phaseStatus: Type.Optional(Type.String()), eventType: Type.Optional(Type.String()), + sourceEventType: Type.Optional(Type.String()), sourceEventId: Type.Optional(Type.Integer()), sourceEventTimestamp: Type.Optional(Type.String()), + landing: Type.Optional(TFLandingSchema), validationPassed: Type.Optional(Type.Boolean()), prdValidationPassed: Type.Optional(Type.Boolean()), acceptanceValidationPassed: Type.Optional(Type.Boolean()), +}); +// --- eforge:endregion plan-01-terminal-failure-contract --- + const BuildFailureSummarySchema = Type.Object({ prdId: Type.String(), setName: Type.String(), @@ -555,12 +572,9 @@ const BuildFailureSummarySchema = Type.Object({ partial: Type.Optional(Type.Boolean()), prdContent: Type.Optional(Type.String()), // --- eforge:region plan-01-recovery-and-acceptance-reporting --- - terminalFailure: Type.Optional(Type.Object({ - stage: Type.String(), - phaseSummary: Type.Optional(Type.String()), - phaseStatus: Type.Optional(Type.String()), - eventType: Type.Optional(Type.String()), - })), + // --- eforge:region plan-01-terminal-failure-contract --- + terminalFailure: Type.Optional(Type.Partial(TerminalFailureEnvelopeSchema)), + // --- eforge:endregion plan-01-terminal-failure-contract --- acceptanceValidation: Type.Optional(Type.Object({ passed: Type.Boolean(), total: Type.Number(), @@ -574,11 +588,7 @@ const BuildFailureSummarySchema = Type.Object({ exitCode: Type.Number(), output: Type.Optional(Type.String()), }))), - landing: Type.Optional(Type.Object({ - status: Type.String(), - action: Type.Optional(Type.String()), - reason: Type.Optional(Type.String()), - })), + landing: Type.Optional(TFLandingSchema), // --- eforge:endregion plan-01-recovery-and-acceptance-reporting --- // --- eforge:region plan-01-recovery-summary-reconstruction --- failingPlans: Type.Optional(Type.Array(FailingPlanEntrySchema)), @@ -881,6 +891,11 @@ export const BuildDecisionSchema = Type.Union([ export type BuildDecision = Static; +const StackSyncTriggerSchema = Type.Optional(Type.Union([ + Type.Literal('manual'), Type.Literal('after-build'), + Type.Literal('scheduled'), Type.Literal('retry-deferred'), +])); + const EforgeEventVariantsSchema = Type.Union([ // Session lifecycle Type.Object({ type: Type.Literal('session:start'), sessionId: Type.String() }), @@ -2204,23 +2219,13 @@ const EforgeEventVariantsSchema = Type.Union([ Type.Object({ type: Type.Literal('stack:sync:start'), syncId: Type.String(), - trigger: Type.Optional(Type.Union([ - Type.Literal('manual'), - Type.Literal('after-build'), - Type.Literal('scheduled'), - Type.Literal('retry-deferred'), - ])), + trigger: StackSyncTriggerSchema, dryRun: Type.Boolean(), }), Type.Object({ type: Type.Literal('stack:sync:complete'), syncId: Type.String(), - trigger: Type.Optional(Type.Union([ - Type.Literal('manual'), - Type.Literal('after-build'), - Type.Literal('scheduled'), - Type.Literal('retry-deferred'), - ])), + trigger: StackSyncTriggerSchema, dryRun: Type.Boolean(), restackCandidates: Type.Array(Type.String()), excludedCandidates: Type.Array(Type.String()), @@ -2232,12 +2237,7 @@ const EforgeEventVariantsSchema = Type.Union([ Type.Object({ type: Type.Literal('stack:sync:failed'), syncId: Type.String(), - trigger: Type.Optional(Type.Union([ - Type.Literal('manual'), - Type.Literal('after-build'), - Type.Literal('scheduled'), - Type.Literal('retry-deferred'), - ])), + trigger: StackSyncTriggerSchema, dryRun: Type.Boolean(), outcome: Type.Union([Type.Literal('failed'), Type.Literal('conflict')]), reason: Type.String(), @@ -2246,30 +2246,24 @@ const EforgeEventVariantsSchema = Type.Union([ Type.Object({ type: Type.Literal('stack:sync:deferred'), syncId: Type.String(), - trigger: Type.Optional(Type.Union([ - Type.Literal('manual'), - Type.Literal('after-build'), - Type.Literal('scheduled'), - Type.Literal('retry-deferred'), - ])), + trigger: StackSyncTriggerSchema, reason: Type.String(), excludedCandidates: Type.Array(Type.String()), }), Type.Object({ type: Type.Literal('stack:sync:skipped'), syncId: Type.String(), - trigger: Type.Optional(Type.Union([ - Type.Literal('manual'), - Type.Literal('after-build'), - Type.Literal('scheduled'), - Type.Literal('retry-deferred'), - ])), + trigger: StackSyncTriggerSchema, dryRun: Type.Boolean(), reason: Type.String(), restackCandidates: Type.Array(Type.String()), excludedCandidates: Type.Array(Type.String()), }), // --- eforge:endregion plan-01-core-daemon-stack-sync --- + // --- eforge:region plan-01-terminal-failure-contract --- + Type.Object({ type: Type.Literal('build:terminal-failure'), runId: Type.String(), + failure: TerminalFailureEnvelopeSchema }), + // --- eforge:endregion plan-01-terminal-failure-contract --- ]); // --------------------------------------------------------------------------- @@ -2328,6 +2322,10 @@ export type LandedCommit = Static; export type PlanSummaryEntry = Static; export type FailingPlanEntry = Static; export type BuildFailureSummary = Static; +// --- eforge:region plan-01-terminal-failure-contract --- +export type TerminalFailureScope = Static; +export type TerminalFailureEnvelope = Static; +// --- eforge:endregion plan-01-terminal-failure-contract --- export type QueueEvent = Static; export type PlanningDecisionEvent = Static; // --- eforge:region plan-01-supervisor-foundation --- diff --git a/packages/client/src/events.ts b/packages/client/src/events.ts index a4034d2a1..fe2986d81 100644 --- a/packages/client/src/events.ts +++ b/packages/client/src/events.ts @@ -11,6 +11,10 @@ */ export type { + // --- eforge:region plan-01-terminal-failure-contract --- + TerminalFailureScope, + TerminalFailureEnvelope, + // --- eforge:endregion plan-01-terminal-failure-contract --- EforgeEvent, DaemonRunUpsertEvent, AgentRole, @@ -87,6 +91,10 @@ export { StackArtifactRefSchema, StackLayerWireSchema, // --- eforge:endregion plan-01-stack-contracts-config-state-events --- + // --- eforge:region plan-01-terminal-failure-contract --- + TerminalFailureScopeSchema, + TerminalFailureEnvelopeSchema, + // --- eforge:endregion plan-01-terminal-failure-contract --- } from './events.schemas.js'; export { EforgeEventSchema } from './events.schemas.js'; diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 5cb43f157..af8ffa289 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -372,6 +372,10 @@ export type { StackArtifactRef, StackLayerWire, // --- eforge:endregion plan-01-stack-contracts-config-state-events --- + // --- eforge:region plan-01-terminal-failure-contract --- + TerminalFailureScope, + TerminalFailureEnvelope, + // --- eforge:endregion plan-01-terminal-failure-contract --- } from './events.js'; export { ORCHESTRATION_MODES, SEVERITY_ORDER, isAlwaysYieldedAgentEvent, EforgeEventSchema, REVIEW_PERSPECTIVES, BuildDecisionSchema, PlanningDecisionSchema, @@ -385,6 +389,9 @@ export { ORCHESTRATION_MODES, SEVERITY_ORDER, isAlwaysYieldedAgentEvent, EforgeE // --- eforge:region plan-01-stack-contracts-config-state-events --- StackProviderSchema, LandingPublicationActionSchema, StackLayerStatusSchema, StackArtifactRefSchema, StackLayerWireSchema, // --- eforge:endregion plan-01-stack-contracts-config-state-events --- + // --- eforge:region plan-01-terminal-failure-contract --- + TerminalFailureScopeSchema, TerminalFailureEnvelopeSchema, + // --- eforge:endregion plan-01-terminal-failure-contract --- } from './events.js'; export type { diff --git a/packages/console-ui/src/lib/run-state/handlers/index.ts b/packages/console-ui/src/lib/run-state/handlers/index.ts index c93c96ac0..730ddc1f1 100644 --- a/packages/console-ui/src/lib/run-state/handlers/index.ts +++ b/packages/console-ui/src/lib/run-state/handlers/index.ts @@ -327,6 +327,11 @@ export const IGNORED_EVENT_TYPES = [ 'landing:auto-merge:start', 'landing:auto-merge:complete', 'landing:auto-merge:skipped', + // --- eforge:region plan-01-terminal-failure-contract --- + // build:terminal-failure — run-level authoritative terminal failure event. + // Monitor UI rendering is future work; session reducer does not handle it. + 'build:terminal-failure', + // --- eforge:endregion plan-01-terminal-failure-contract --- ] as const; // --------------------------------------------------------------------------- diff --git a/packages/engine/src/eforge.ts b/packages/engine/src/eforge.ts index 8e9f17c46..86672cd78 100644 --- a/packages/engine/src/eforge.ts +++ b/packages/engine/src/eforge.ts @@ -55,6 +55,9 @@ import { runPrdValidator } from './agents/prd-validator.js'; import { buildPrdValidatorDiff } from './prd-validator-diff.js'; import { runGapCloser } from './agents/gap-closer.js'; import { Orchestrator, type ValidationFixer, type PrdValidator, type GapCloser } from './orchestrator.js'; +// --- eforge:region plan-01-terminal-failure-contract --- +import { createBuildTerminalFailureTracker } from './terminal-failure.js'; +// --- eforge:endregion plan-01-terminal-failure-contract --- import type { MergeResolver } from './worktree-ops.js'; import { computeWorktreeBase, createMergeWorktree } from './worktree-ops.js'; import { deriveNameFromSource, parseOrchestrationConfig, parsePlanFile, validatePlanSet, validatePlanSetName } from './plan.js'; @@ -663,6 +666,9 @@ export class EforgeEngine { let status: 'completed' | 'failed' = 'completed'; let summary = 'Build complete'; + // --- eforge:region plan-01-terminal-failure-contract --- + const terminalTracker = createBuildTerminalFailureTracker(runId); + // --- eforge:endregion plan-01-terminal-failure-contract --- // Emit profile info before config warnings yield { timestamp: new Date().toISOString(), type: 'session:profile', profileName: this.configProfile.name, source: this.configProfile.source, scope: this.configProfile.scope, config: this.configProfile.config }; @@ -1124,21 +1130,11 @@ export class EforgeEngine { yield mergeEvents.shift()!; } yield event; - if (event.type === 'plan:build:failed') { - status = 'failed'; - summary = event.error.startsWith('Merge failed') - ? `Merge failed for ${event.planId}` - : `Build failed for ${event.planId}`; - } - if (event.type === 'validation:complete') { - if (event.passed) { - status = 'completed'; - summary = 'Build complete'; - } else { - status = 'failed'; - summary = 'Post-merge validation failed'; - } - } + // --- eforge:region plan-01-terminal-failure-contract --- + terminalTracker.observe(event); + // --- eforge:endregion plan-01-terminal-failure-contract --- + if (event.type === 'plan:build:failed') { status = 'failed'; summary = event.error.startsWith('Merge failed') ? `Merge failed for ${event.planId}` : `Build failed for ${event.planId}`; } + if (event.type === 'validation:complete') { status = event.passed ? 'completed' : 'failed'; summary = event.passed ? 'Build complete' : 'Post-merge validation failed'; } if (event.type === 'prd_validation:complete') { if (!event.passed) { status = 'failed'; @@ -1179,6 +1175,10 @@ export class EforgeEngine { summary = (err as Error).message; } finally { tracing?.setOutput({ status, summary }); + // --- eforge:region plan-01-terminal-failure-contract --- + const terminalEvt = terminalTracker.toEvent(status, summary); + if (terminalEvt) yield terminalEvt; + // --- eforge:endregion plan-01-terminal-failure-contract --- yield { type: 'phase:end', runId, diff --git a/packages/engine/src/events.ts b/packages/engine/src/events.ts index 32d8a944b..83395ed94 100644 --- a/packages/engine/src/events.ts +++ b/packages/engine/src/events.ts @@ -37,6 +37,10 @@ export type { StackProvider, LandingPublicationAction, // --- eforge:endregion plan-01-stack-contracts-config-state-events --- + // --- eforge:region plan-01-terminal-failure-contract --- + TerminalFailureScope, + TerminalFailureEnvelope, + // --- eforge:endregion plan-01-terminal-failure-contract --- } from '@eforge-build/client'; export { @@ -44,6 +48,10 @@ export { SEVERITY_ORDER, isAlwaysYieldedAgentEvent, EforgeEventSchema, + // --- eforge:region plan-01-terminal-failure-contract --- + TerminalFailureScopeSchema, + TerminalFailureEnvelopeSchema, + // --- eforge:endregion plan-01-terminal-failure-contract --- } from '@eforge-build/client'; // Engine-only types not part of the wire protocol: diff --git a/packages/engine/src/recovery/event-history.ts b/packages/engine/src/recovery/event-history.ts index 43ac41f41..fbc328377 100644 --- a/packages/engine/src/recovery/event-history.ts +++ b/packages/engine/src/recovery/event-history.ts @@ -11,6 +11,7 @@ import { DatabaseSync } from 'node:sqlite'; import type { BuildFailureSummary, FailingPlanEntry, PlanSummaryEntry, LandedCommit, AcceptanceCriterionVerdict } from '../events.js'; import { classifyAgentTerminalSubtype } from '../harness.js'; +import { findAuthoritativeTerminalEvent, reconstructPlanMaps, buildPlanSummaries, extractValidationCommands, extractLandingInfo, buildAuthoritativeFragment, detectLegacyFallbackFragment } from './terminal-failure-history.js'; export interface SynthesizeOptions { setName: string; @@ -83,6 +84,36 @@ export function synthesizeFromEvents(options: SynthesizeOptions): Partial; + const failedPhaseRow = failedPhaseRows.find(r => { + const d = parseEventData(r.data); + const res = d.result; + return Boolean(res && typeof res === 'object' && (res as Record).status === 'failed'); + }); + if (failedPhaseRow) { + const authTerminal = findAuthoritativeTerminalEvent(db, runId, failedPhaseRow.id); + if (authTerminal) { + const maps = reconstructPlanMaps(db, runId); + const valStartRow = db.prepare(`SELECT id FROM events WHERE run_id = ? AND type = 'validation:start' AND id <= ? ORDER BY id DESC LIMIT 1`).get(runId, failedPhaseRow.id) as { id: number } | undefined; + const valCmds = extractValidationCommands(db, runId, valStartRow?.id ?? 0, failedPhaseRow.id); + const landingInfo = extractLandingInfo(db, runId, failedPhaseRow.id); + const fragment = buildAuthoritativeFragment(authTerminal, maps, prdId, setName, modelsUsed, failedPhaseRow.timestamp, valCmds, landingInfo); + return fragment; + } + // phase:end found but no authoritative terminal event — legacy fallback applies. + isLegacyFallback = true; + } + // --- eforge:endregion plan-01-terminal-failure-contract --- + let failingPlan: FailingPlanEntry; let plans: PlanSummaryEntry[]; let failedAt: string; @@ -318,6 +349,9 @@ export function synthesizeFromEvents(options: SynthesizeOptions): Partial(); + const stopStmt = db.prepare( `SELECT id, plan_id as planId, agent, data, timestamp FROM events WHERE run_id = ? AND type = 'agent:stop' AND id <= ? ORDER BY id DESC LIMIT 20`, ); const stopEvents = stopStmt.all(runId, failedPhase.id) as unknown as EventHistoryRow[]; const failedStop = stopEvents.find((event) => { const parsed = parseEventData(event.data); - return typeof parsed.error === 'string' && parsed.error.length > 0; + if (!(typeof parsed.error === 'string' && parsed.error.length > 0)) return false; + if (event.planId) { + const latestStatus = tfStatusMap.get(event.planId); + if (latestStatus === 'completed' || latestStatus === 'merged') { + // Only suppress if the completed/merged status occurred AFTER this stop event + const statusId = tfStatusIdMap.get(event.planId); + if (statusId === undefined || statusId > event.id) return false; + } + } + return true; }); - if (!failedStop) return null; + if (!failedStop) { + // All errored stops are superseded — return unknown terminal failure using phase summary + return { + prdId, setName, featureBranch: `eforge/${setName}`, baseBranch: 'main', + plans: [], failingPlan: { planId: 'unknown' }, + landedCommits: [] as LandedCommit[], diffStat: '', modelsUsed, + failedAt: failedPhase.timestamp, partial: true, + terminalFailure: { stage: 'unknown', scope: 'unknown', message: phaseSummary ?? 'Build failed', authoritative: false }, + }; + } + isLegacyFallback = true; const parsedStop = parseEventData(failedStop.data); const errorMessage = typeof parsedStop.error === 'string' ? parsedStop.error : undefined; const agentId = typeof parsedStop.agentId === 'string' ? parsedStop.agentId : undefined; @@ -476,7 +543,16 @@ export function synthesizeFromEvents(options: SynthesizeOptions): Partial { + try { + const p = JSON.parse(data); + return p && typeof p === 'object' ? p as Record : {}; + } catch { return {}; } +} + +// --------------------------------------------------------------------------- +// Authoritative terminal event lookup +// --------------------------------------------------------------------------- + +export interface AuthoritativeTerminalEvent { + id: number; + timestamp: string; + scope: string; + message: string; + planId?: string; + sourceEventType?: string; + landing?: { status: string; action?: string; reason?: string }; + validationPassed?: boolean; + prdValidationPassed?: boolean; + acceptanceValidationPassed?: boolean; +} + +/** + * Find the latest `build:terminal-failure` event for the run at or before + * the failed phase:end. Returns undefined if none found. + */ +export function findAuthoritativeTerminalEvent( + db: DatabaseSync, + runId: string, + failedPhaseId: number, +): AuthoritativeTerminalEvent | undefined { + const stmt = db.prepare( + `SELECT id, data, timestamp FROM events WHERE run_id = ? AND type = 'build:terminal-failure' AND id <= ? ORDER BY id DESC LIMIT 1`, + ); + const row = stmt.get(runId, failedPhaseId) as { id: number; data: string; timestamp: string } | undefined; + if (!row) return undefined; + const d = parseData(row.data); + const failure = d.failure && typeof d.failure === 'object' ? d.failure as Record : {}; + // Only treat this row as authoritative if failure.authoritative is explicitly true. + if (failure.authoritative !== true) return undefined; + const scope = typeof failure.scope === 'string' ? failure.scope : 'unknown'; + const message = typeof failure.message === 'string' ? failure.message : ''; + const planId = typeof failure.planId === 'string' ? failure.planId : undefined; + const sourceEventType = typeof failure.sourceEventType === 'string' ? failure.sourceEventType : undefined; + const landingRaw = failure.landing && typeof failure.landing === 'object' ? failure.landing as Record : undefined; + const landing = landingRaw && typeof landingRaw.status === 'string' + ? { status: landingRaw.status, action: typeof landingRaw.action === 'string' ? landingRaw.action : undefined, reason: typeof landingRaw.reason === 'string' ? landingRaw.reason : undefined } + : undefined; + return { + id: row.id, timestamp: row.timestamp, scope, message, planId, sourceEventType, landing, + ...(typeof failure.validationPassed === 'boolean' ? { validationPassed: failure.validationPassed } : {}), + ...(typeof failure.prdValidationPassed === 'boolean' ? { prdValidationPassed: failure.prdValidationPassed } : {}), + ...(typeof failure.acceptanceValidationPassed === 'boolean' ? { acceptanceValidationPassed: failure.acceptanceValidationPassed } : {}), + }; +} + +// --------------------------------------------------------------------------- +// Plan status reconstruction (shared between authoritative and fallback paths) +// --------------------------------------------------------------------------- + +export interface PlanStatusMaps { + planStatusMap: Map; + planStatusTimestampMap: Map; + /** Event id of the latest plan:status:change entry per plan (for ordering-based stale stop filtering). */ + planStatusIdMap: Map; + mergeCompleteMap: Map; + testCompleteMap: Map; + toolUseMap: Map; +} + +export function reconstructPlanMaps(db: DatabaseSync, runId: string): PlanStatusMaps { + const planStatusMap = new Map(); + const planStatusTimestampMap = new Map(); + const planStatusIdMap = new Map(); + const mergeCompleteMap = new Map(); + const testCompleteMap = new Map(); + const toolUseMap = new Map(); + + const statusRows = db.prepare( + `SELECT id, plan_id as planId, data, timestamp FROM events WHERE run_id = ? AND type = 'plan:status:change' AND plan_id IS NOT NULL ORDER BY id ASC`, + ).all(runId) as Array<{ id: number; planId: string; data: string; timestamp: string }>; + for (const r of statusRows) { + const d = parseData(r.data); + planStatusMap.set(r.planId, typeof d.status === 'string' ? d.status : 'unknown'); + planStatusTimestampMap.set(r.planId, r.timestamp); + planStatusIdMap.set(r.planId, r.id); + } + + const mergeRows = db.prepare( + `SELECT plan_id as planId, data, timestamp FROM events WHERE run_id = ? AND type = 'plan:merge:complete' AND plan_id IS NOT NULL ORDER BY id ASC`, + ).all(runId) as Array<{ planId: string; data: string; timestamp: string }>; + for (const r of mergeRows) { + const d = parseData(r.data); + mergeCompleteMap.set(r.planId, { mergedAt: r.timestamp, ...(typeof d.commitSha === 'string' ? { commitSha: d.commitSha } : {}) }); + } + + const testRows = db.prepare( + `SELECT plan_id as planId, data FROM events WHERE run_id = ? AND type = 'plan:build:test:complete' AND plan_id IS NOT NULL ORDER BY id ASC`, + ).all(runId) as Array<{ planId: string; data: string }>; + for (const r of testRows) { + const d = parseData(r.data); + if (typeof d.passed === 'number' && typeof d.failed === 'number') testCompleteMap.set(r.planId, { testPassed: d.passed, testFailed: d.failed }); + } + + const toolUseRows = db.prepare( + `SELECT plan_id as planId, COUNT(*) as count FROM events WHERE run_id = ? AND type = 'agent:tool_use' AND plan_id IS NOT NULL GROUP BY plan_id`, + ).all(runId) as Array<{ planId: string; count: number }>; + for (const r of toolUseRows) toolUseMap.set(r.planId, r.count); + + return { planStatusMap, planStatusTimestampMap, planStatusIdMap, mergeCompleteMap, testCompleteMap, toolUseMap }; +} + +// --------------------------------------------------------------------------- +// Build PlanSummaryEntry[] from maps +// --------------------------------------------------------------------------- + +export function buildPlanSummaries( + allPlanIds: Set, + maps: PlanStatusMaps, + planErrorMap: Map, +): PlanSummaryEntry[] { + return [...allPlanIds].map(planId => { + const status = maps.planStatusMap.get(planId) ?? 'failed'; + const errEntry = planErrorMap.get(planId); + const mergeEntry = maps.mergeCompleteMap.get(planId); + const testEntry = maps.testCompleteMap.get(planId); + const tuCount = maps.toolUseMap.get(planId); + const statusTs = maps.planStatusTimestampMap.get(planId); + return { + planId, status, + ...(mergeEntry?.mergedAt ? { mergedAt: mergeEntry.mergedAt } : {}), + ...(errEntry?.error !== undefined ? { error: errEntry.error } : {}), + ...(errEntry?.terminalSubtype ? { terminalSubtype: errEntry.terminalSubtype } : {}), + ...(mergeEntry?.commitSha ? { commitSha: mergeEntry.commitSha } : {}), + ...(testEntry !== undefined ? { testPassed: testEntry.testPassed, testFailed: testEntry.testFailed } : {}), + ...(tuCount !== undefined ? { toolUseCount: tuCount } : {}), + ...(status === 'completed' && statusTs ? { completedAt: statusTs } : {}), + }; + }); +} + +// --------------------------------------------------------------------------- +// Validation command extraction for a build window +// --------------------------------------------------------------------------- + +export function extractValidationCommands( + db: DatabaseSync, + runId: string, + windowStartId: number, + windowEndId: number, +): Array<{ command: string; exitCode: number; output?: string }> { + const rows = db.prepare( + `SELECT data FROM events WHERE run_id = ? AND type = 'validation:command:complete' AND id > ? AND id <= ? ORDER BY id`, + ).all(runId, windowStartId, windowEndId) as { data: string }[]; + const result: Array<{ command: string; exitCode: number; output?: string }> = []; + for (const r of rows) { + const d = parseData(r.data); + if (typeof d.command === 'string' && typeof d.exitCode === 'number') { + result.push({ command: d.command, exitCode: d.exitCode, ...(typeof d.output === 'string' ? { output: d.output } : {}) }); + } + } + return result; +} + +// --------------------------------------------------------------------------- +// Landing evidence extraction +// --------------------------------------------------------------------------- + +export function extractLandingInfo( + db: DatabaseSync, + runId: string, + upToId: number, +): { status: string; action?: string; reason?: string } | undefined { + const row = db.prepare( + `SELECT type, data FROM events WHERE run_id = ? AND (type = 'landing:skipped' OR type = 'stack:landing:update') AND id <= ? ORDER BY id DESC LIMIT 1`, + ).get(runId, upToId) as { type: string; data: string } | undefined; + if (!row) return undefined; + const d = parseData(row.data); + const status = row.type === 'landing:skipped' ? 'skipped' : (typeof d.status === 'string' ? d.status : undefined); + if (!status) return undefined; + return { status, ...(typeof d.action === 'string' ? { action: d.action } : {}), ...(typeof d.reason === 'string' ? { reason: d.reason } : {}) }; +} + +// --------------------------------------------------------------------------- +// Build authoritative BuildFailureSummary fragment from terminal event +// --------------------------------------------------------------------------- + +export function buildAuthoritativeFragment( + terminal: AuthoritativeTerminalEvent, + maps: PlanStatusMaps, + prdId: string, + setName: string, + modelsUsed: string[], + failedPhaseTimestamp: string, + validationCommands?: Array<{ command: string; exitCode: number; output?: string }>, + landingInfo?: { status: string; action?: string; reason?: string }, +): Partial { + const allPlanIds = new Set(maps.planStatusMap.keys()); + const emptyErrorMap = new Map(); + const plans = buildPlanSummaries(allPlanIds, maps, emptyErrorMap); + + // failingPlan: use planId for plan-scoped failures; synthetic compat ID for others + const failingPlanId = terminal.planId ?? (terminal.scope !== 'plan' ? terminal.scope : 'unknown'); + const failingPlan: FailingPlanEntry = { planId: failingPlanId, ...(terminal.scope === 'plan' ? { errorMessage: terminal.message } : {}) }; + + return { + prdId, setName, + featureBranch: `eforge/${setName}`, + baseBranch: 'main', + plans, + failingPlan, + landedCommits: [], + diffStat: '', + modelsUsed, + failedAt: failedPhaseTimestamp, + terminalFailure: { + scope: terminal.scope as import('../events.js').TerminalFailureScope, + message: terminal.message, + authoritative: true, + ...(terminal.planId ? { planId: terminal.planId } : {}), + ...(terminal.sourceEventType ? { sourceEventType: terminal.sourceEventType } : {}), + ...(terminal.landing ? { landing: terminal.landing } : {}), + ...(landingInfo && !terminal.landing ? { landing: landingInfo } : {}), + }, + ...(validationCommands && validationCommands.length > 0 ? { validationCommands } : {}), + ...(terminal.landing ? { landing: terminal.landing } : {}), + ...(landingInfo && !terminal.landing ? { landing: landingInfo } : {}), + }; +} + +// --------------------------------------------------------------------------- +// Legacy fallback fragment detection (no authoritative build:terminal-failure) +// Detects artifact-recording, landing, and post-merge-validation failures. +// --------------------------------------------------------------------------- + +/** Base fields common to all legacy fallback fragments. */ +function makeLegacyBase( + prdId: string, setName: string, modelsUsed: string[], failedAt: string, + failingPlanId: string, failingPlanMsg: string, +): Partial { + return { + prdId, setName, featureBranch: `eforge/${setName}`, baseBranch: 'main', + plans: [{ planId: failingPlanId, status: 'failed', error: failingPlanMsg }], + failingPlan: { planId: failingPlanId, errorMessage: failingPlanMsg }, + landedCommits: [] as LandedCommit[], diffStat: '', modelsUsed, + failedAt, partial: true, + }; +} + +/** + * Probe the DB for well-known non-plan terminal failure patterns that predate + * the authoritative build:terminal-failure event. Returns a synthesized fragment + * or undefined if none of the patterns match. + * + * Order: artifact-recording > landing > post-merge-validation + */ +export function detectLegacyFallbackFragment( + db: DatabaseSync, + runId: string, + failedPhaseId: number, + failedPhaseTimestamp: string, + prdId: string, + setName: string, + modelsUsed: string[], + phaseSummary: string | undefined, + phaseStatus: string | undefined, +): Partial | undefined { + const phaseFields = { ...(phaseSummary !== undefined ? { phaseSummary } : {}), ...(phaseStatus !== undefined ? { phaseStatus } : {}) }; + + // --- artifact-recording: daemon:error with source=stack:artifact-recording --- + const artifactRows = db.prepare( + `SELECT id, data FROM events WHERE run_id = ? AND type = 'daemon:error' AND id <= ? ORDER BY id DESC LIMIT 20`, + ).all(runId, failedPhaseId) as { id: number; data: string }[]; + const artifactRow = artifactRows.find((r) => { const d = parseData(r.data); return d.source === 'stack:artifact-recording'; }); + if (artifactRow) { + const d = parseData(artifactRow.data); + const msg = typeof d.message === 'string' ? d.message : 'Failed to record stack artifact'; + const valStart = db.prepare(`SELECT id FROM events WHERE run_id = ? AND type = 'validation:start' AND id <= ? ORDER BY id DESC LIMIT 1`).get(runId, artifactRow.id) as { id: number } | undefined; + const valCmds = extractValidationCommands(db, runId, valStart?.id ?? 0, failedPhaseId); + const landing = extractLandingInfo(db, runId, failedPhaseId); + return { + ...makeLegacyBase(prdId, setName, modelsUsed, failedPhaseTimestamp, 'artifact-recording', msg), + terminalFailure: { stage: 'artifact-recording', scope: 'artifact-recording', message: msg, authoritative: false, ...phaseFields, eventType: 'daemon:error', sourceEventType: 'daemon:error' }, + ...(valCmds.length > 0 ? { validationCommands: valCmds } : {}), + ...(landing !== undefined ? { landing } : {}), + }; + } + + // --- landing: landing:skipped or stack:landing:update with status=failed/skipped --- + const landingRow = db.prepare( + `SELECT type, data FROM events WHERE run_id = ? AND (type = 'landing:skipped' OR type = 'stack:landing:update') AND id <= ? ORDER BY id DESC LIMIT 1`, + ).get(runId, failedPhaseId) as { type: string; data: string } | undefined; + if (landingRow) { + const d = parseData(landingRow.data); + const status = landingRow.type === 'landing:skipped' ? 'skipped' : (typeof d.status === 'string' ? d.status : undefined); + if (status === 'failed' || status === 'skipped') { + const msg = landingRow.type === 'landing:skipped' + ? (typeof d.reason === 'string' ? d.reason : 'Landing skipped') + : (typeof d.reason === 'string' ? `Stack landing failed: ${d.reason}` : 'Stack landing failed'); + const landingInfo = { status, ...(typeof d.action === 'string' ? { action: d.action } : {}), ...(typeof d.reason === 'string' ? { reason: d.reason } : {}) }; + return { + ...makeLegacyBase(prdId, setName, modelsUsed, failedPhaseTimestamp, 'landing', msg), + terminalFailure: { stage: 'landing', scope: 'landing', message: msg, authoritative: false, ...phaseFields, eventType: landingRow.type, sourceEventType: landingRow.type }, + landing: landingInfo, + }; + } + } + + // --- post-merge-validation: validation:complete with passed=false --- + const valRow = db.prepare( + `SELECT id, data FROM events WHERE run_id = ? AND type = 'validation:complete' AND id <= ? ORDER BY id DESC LIMIT 1`, + ).get(runId, failedPhaseId) as { id: number; data: string } | undefined; + if (valRow && parseData(valRow.data).passed === false) { + const msg = 'Post-merge validation failed'; + const valStart = db.prepare(`SELECT id FROM events WHERE run_id = ? AND type = 'validation:start' AND id <= ? ORDER BY id DESC LIMIT 1`).get(runId, valRow.id) as { id: number } | undefined; + const valCmds = extractValidationCommands(db, runId, valStart?.id ?? 0, failedPhaseId); + return { + ...makeLegacyBase(prdId, setName, modelsUsed, failedPhaseTimestamp, 'post-merge-validation', msg), + terminalFailure: { stage: 'post-merge-validation', scope: 'post-merge-validation', message: msg, authoritative: false, ...phaseFields, eventType: 'validation:complete', sourceEventType: 'validation:complete' }, + ...(valCmds.length > 0 ? { validationCommands: valCmds } : {}), + }; + } + + return undefined; +} diff --git a/packages/engine/src/terminal-failure.ts b/packages/engine/src/terminal-failure.ts new file mode 100644 index 000000000..6a274aeb4 --- /dev/null +++ b/packages/engine/src/terminal-failure.ts @@ -0,0 +1,111 @@ +/** + * Runtime terminal failure tracker for EforgeEngine.build(). + * + * Observes orchestrator events as they are yielded and retains the latest + * terminal failure evidence. Call toEvent() after the build loop completes + * to produce a single authoritative `build:terminal-failure` event for + * failed builds. + * + * Precedence (later evidence can supersede earlier): + * plan:build:failed → scope: 'plan' + * validation:complete passed=false → scope: 'post-merge-validation' + * prd_validation:complete passed=false → scope: 'prd-validation' + * acceptance_validation:complete passed=false → scope: 'acceptance-validation' + * daemon:error source=stack:artifact-recording → scope: 'artifact-recording' + * stack:landing:update status=failed → scope: 'landing' + * landing:skipped (after a failed status or related evidence) → scope: 'landing' + * other daemon:error → scope: 'daemon' + */ + +import type { EforgeEvent, TerminalFailureScope } from './events.js'; + +// --------------------------------------------------------------------------- +// Internal evidence shape +// --------------------------------------------------------------------------- + +interface FailureEvidence { + scope: TerminalFailureScope; + message: string; + planId?: string; + sourceEventType?: string; + sourceEventTimestamp?: string; + landing?: { status: string; action?: string; reason?: string }; + validationPassed?: boolean; + prdValidationPassed?: boolean; + acceptanceValidationPassed?: boolean; +} + +// Priority order: higher index = higher priority (can supersede) +const SCOPE_PRIORITY: TerminalFailureScope[] = [ + 'unknown', 'daemon', 'plan', 'compile', + 'post-merge-validation', 'prd-validation', 'acceptance-validation', + 'landing', 'artifact-recording', +]; + +function scopePriority(scope: TerminalFailureScope): number { + return SCOPE_PRIORITY.indexOf(scope); +} + +// --------------------------------------------------------------------------- +// Tracker factory +// --------------------------------------------------------------------------- + +export interface BuildTerminalFailureTracker { + observe(event: EforgeEvent): void; + toEvent(status: 'completed' | 'failed', summary: string): EforgeEvent | undefined; +} + +export function createBuildTerminalFailureTracker(runId: string): BuildTerminalFailureTracker { + let evidence: FailureEvidence | undefined; + let emitted = false; + + function update(candidate: FailureEvidence): void { + if (!evidence || scopePriority(candidate.scope) >= scopePriority(evidence.scope)) { + evidence = candidate; + } + } + + return { + observe(event: EforgeEvent): void { + if (event.type === 'plan:build:failed') { + update({ scope: 'plan', message: event.error, planId: event.planId, sourceEventType: event.type, sourceEventTimestamp: event.timestamp }); + } else if (event.type === 'validation:complete' && !event.passed) { + update({ scope: 'post-merge-validation', message: 'Post-merge validation failed', sourceEventType: event.type, sourceEventTimestamp: event.timestamp, validationPassed: false }); + } else if (event.type === 'prd_validation:complete' && !event.passed) { + update({ scope: 'prd-validation', message: `PRD validation failed: ${(event.gaps ?? []).length} gap(s) found`, sourceEventType: event.type, sourceEventTimestamp: event.timestamp, prdValidationPassed: false }); + } else if (event.type === 'acceptance_validation:complete' && !event.passed) { + update({ scope: 'acceptance-validation', message: 'Acceptance criteria validation failed', sourceEventType: event.type, sourceEventTimestamp: event.timestamp, acceptanceValidationPassed: false }); + } else if (event.type === 'daemon:error' && event.source === 'stack:artifact-recording') { + update({ scope: 'artifact-recording', message: event.message, sourceEventType: event.type, sourceEventTimestamp: event.timestamp }); + } else if (event.type === 'stack:landing:update' && event.status === 'failed') { + update({ scope: 'landing', message: event.reason ?? 'Stack landing failed', sourceEventType: event.type, sourceEventTimestamp: event.timestamp, landing: { status: 'failed', action: event.action, reason: event.reason } }); + } else if (event.type === 'landing:skipped') { + update({ scope: 'landing', message: event.reason ?? 'Landing skipped', sourceEventType: event.type, sourceEventTimestamp: event.timestamp, landing: { status: 'skipped', action: event.action, ...(event.reason !== undefined ? { reason: event.reason } : {}) } }); + } else if (event.type === 'daemon:error' && (!evidence || evidence.scope === 'unknown')) { + update({ scope: 'daemon', message: event.message, sourceEventType: event.type, sourceEventTimestamp: event.timestamp }); + } + }, + toEvent(status: 'completed' | 'failed', summary: string): EforgeEvent | undefined { + if (status !== 'failed' || emitted) return undefined; + emitted = true; + const ev = evidence ?? { scope: 'unknown' as TerminalFailureScope, message: summary }; + return { + type: 'build:terminal-failure', + runId, + failure: { + scope: ev.scope, + message: ev.message, + authoritative: true, + ...(ev.planId !== undefined ? { planId: ev.planId } : {}), + ...(ev.sourceEventType !== undefined ? { sourceEventType: ev.sourceEventType } : {}), + ...(ev.sourceEventTimestamp !== undefined ? { sourceEventTimestamp: ev.sourceEventTimestamp } : {}), + ...(ev.landing !== undefined ? { landing: ev.landing } : {}), + ...(ev.validationPassed !== undefined ? { validationPassed: ev.validationPassed } : {}), + ...(ev.prdValidationPassed !== undefined ? { prdValidationPassed: ev.prdValidationPassed } : {}), + ...(ev.acceptanceValidationPassed !== undefined ? { acceptanceValidationPassed: ev.acceptanceValidationPassed } : {}), + }, + timestamp: new Date().toISOString(), + }; + }, + }; +} diff --git a/packages/monitor-ui/src/lib/reducer/index.ts b/packages/monitor-ui/src/lib/reducer/index.ts index 7b3a30d21..924da251b 100644 --- a/packages/monitor-ui/src/lib/reducer/index.ts +++ b/packages/monitor-ui/src/lib/reducer/index.ts @@ -378,6 +378,11 @@ export const IGNORED_EVENT_TYPES = [ // Daemon-scoped; handled by daemonHandlerRegistry, not per-session eforgeReducer. 'stack:sync:skipped', // --- eforge:endregion plan-03-docs-and-workflow-guidance --- + // --- eforge:region plan-01-terminal-failure-contract --- + // build:terminal-failure — run-level authoritative terminal failure event. + // Monitor UI rendering is future work; session reducer does not handle it. + 'build:terminal-failure', + // --- eforge:endregion plan-01-terminal-failure-contract --- ] as const; // --------------------------------------------------------------------------- diff --git a/test/recovery-terminal-failure.test.ts b/test/recovery-terminal-failure.test.ts new file mode 100644 index 000000000..edf39b2d1 --- /dev/null +++ b/test/recovery-terminal-failure.test.ts @@ -0,0 +1,400 @@ +/** + * Recovery regression tests for the authoritative terminal failure contract. + * + * Tests cover: + * - Authoritative precedence: build:terminal-failure in DB → authoritative:true, partial omitted + * - Legacy fallback without authoritative event → partial:true, authoritative:false + * - Artifact-recording sequence: scope=artifact-recording, validation commands, landing:skipped + * - Stale agent:stop supersession by completed/merged plan status + * - Fallback taxonomy: distinct stages/scopes per validation gate + * - Non-plan terminal failures: failingPlans remains empty + */ + +import { describe, it, expect } from 'vitest'; +import { mkdirSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { execFileSync } from 'node:child_process'; +import { join } from 'node:path'; +import type { BuildFailureSummary } from '@eforge-build/engine/events'; +import { useTempDir } from './test-tmpdir.js'; +import { synthesizeFromEvents } from '@eforge-build/engine/recovery/event-history'; +import { buildFailureSummary } from '@eforge-build/engine/recovery/failure-summary'; +import { writeRecoverySidecar } from '@eforge-build/engine/recovery/sidecar'; +import { openDatabase } from '@eforge-build/monitor/db'; +import { EforgeEngine } from '@eforge-build/engine/eforge'; +import { StubHarness } from './stub-harness.js'; +import { collectEvents, filterEvents } from './test-events.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function seedGitRepo(dir: string): void { + const { execFileSync } = require('node:child_process') as typeof import('node:child_process'); + const gitOpts = { cwd: dir }; + execFileSync('git', ['init', '-b', 'main'], gitOpts); + execFileSync('git', ['config', 'user.email', 'test@example.com'], gitOpts); + execFileSync('git', ['config', 'user.name', 'Test'], gitOpts); + execFileSync('git', ['commit', '--allow-empty', '-m', 'initial'], gitOpts); +} + +function makeBaseRun(db: ReturnType, runId: string, setName: string, dir: string): void { + db.insertRun({ id: runId, sessionId: `session-${runId}`, planSet: setName, command: 'build', status: 'failed', startedAt: new Date('2026-01-01T10:00:00.000Z').toISOString(), cwd: dir, pid: 9999 }); +} + +function insertPhaseEnd(db: ReturnType, runId: string, status: 'failed' | 'completed', id_hint?: string): void { + db.insertEvent({ runId, type: 'phase:end', data: JSON.stringify({ type: 'phase:end', runId, result: { status, summary: `Phase ${status}` } }), timestamp: new Date('2026-01-01T11:00:00.000Z').toISOString() }); +} + +// --------------------------------------------------------------------------- +// Authoritative precedence +// --------------------------------------------------------------------------- + +describe('authoritative terminal failure precedence', () => { + const makeTempDir = useTempDir('eforge-tf-test-'); + + it('uses build:terminal-failure event as authoritative source when present', async () => { + const dir = makeTempDir(); + seedGitRepo(dir); + mkdirSync(join(dir, '.eforge'), { recursive: true }); + const dbPath = join(dir, '.eforge', 'tf-auth.db'); + const db = openDatabase(dbPath); + + makeBaseRun(db, 'run-auth-01', 'auth-set', dir); + // Insert a misleading stale agent:stop with error + db.insertEvent({ runId: 'run-auth-01', type: 'agent:stop', data: JSON.stringify({ type: 'agent:stop', agent: 'builder', planId: 'plan-old', error: 'stale error from old run' }), timestamp: new Date('2026-01-01T10:10:00.000Z').toISOString() }); + // Insert plan:status:change to mark plan-old as completed (superseded) + db.insertEvent({ runId: 'run-auth-01', type: 'plan:status:change', planId: 'plan-old', data: JSON.stringify({ status: 'completed' }), timestamp: new Date('2026-01-01T10:20:00.000Z').toISOString() }); + // Insert the authoritative terminal failure event + db.insertEvent({ runId: 'run-auth-01', type: 'build:terminal-failure', data: JSON.stringify({ type: 'build:terminal-failure', runId: 'run-auth-01', failure: { scope: 'artifact-recording', message: 'Stack artifact recording failed', authoritative: true } }), timestamp: new Date('2026-01-01T10:50:00.000Z').toISOString() }); + insertPhaseEnd(db, 'run-auth-01', 'failed'); + db.close(); + + const fragment = synthesizeFromEvents({ setName: 'auth-set', prdId: 'auth-prd', dbPath }); + expect(fragment).not.toBeNull(); + expect(fragment!.terminalFailure).toBeDefined(); + expect(fragment!.terminalFailure!.scope).toBe('artifact-recording'); + expect(fragment!.terminalFailure!.authoritative).toBe(true); + // partial should NOT be set in authoritative path + expect(fragment!.partial).toBeUndefined(); + // The stale agent:stop for plan-old should not affect failingPlan + expect(fragment!.failingPlan?.planId).toBe('artifact-recording'); + }); + + it('fallback without authoritative event sets partial:true and authoritative:false', async () => { + const dir = makeTempDir(); + mkdirSync(join(dir, '.eforge'), { recursive: true }); + const dbPath = join(dir, '.eforge', 'tf-fallback.db'); + const db = openDatabase(dbPath); + + makeBaseRun(db, 'run-fb-01', 'fallback-set', dir); + db.insertEvent({ runId: 'run-fb-01', type: 'plan:build:failed', planId: 'plan-02', data: JSON.stringify({ type: 'plan:build:failed', planId: 'plan-02', error: 'Build failed: type error' }), timestamp: new Date('2026-01-01T10:30:00.000Z').toISOString() }); + insertPhaseEnd(db, 'run-fb-01', 'failed'); + db.close(); + + const fragment = synthesizeFromEvents({ setName: 'fallback-set', prdId: 'fallback-prd', dbPath }); + expect(fragment).not.toBeNull(); + expect(fragment!.partial).toBe(true); + expect(fragment!.terminalFailure).toBeDefined(); + expect(fragment!.terminalFailure!.authoritative).toBe(false); + expect(fragment!.terminalFailure!.scope).toBe('plan'); + }); +}); + +// --------------------------------------------------------------------------- +// Legacy fallback artifact-recording detection +// --------------------------------------------------------------------------- + +describe('legacy fallback artifact-recording detection', () => { + const makeTempDir = useTempDir('eforge-tf-legacy-art-'); + + it('legacy fallback without authoritative event: stale agent:stop plus artifact-recording evidence sets authoritative:false and correct scope', async () => { + const dir = makeTempDir(); + mkdirSync(join(dir, '.eforge'), { recursive: true }); + const dbPath = join(dir, '.eforge', 'tf-legacy-art.db'); + const db = openDatabase(dbPath); + + makeBaseRun(db, 'run-legacy-art-01', 'legacy-art-set', dir); + // Stale errored agent:stop for plan-old (later completed — should be ignored) + db.insertEvent({ runId: 'run-legacy-art-01', type: 'agent:stop', planId: 'plan-old', data: JSON.stringify({ type: 'agent:stop', agent: 'builder', planId: 'plan-old', error: 'Stale error from old attempt' }), timestamp: new Date('2026-01-01T10:10:00.000Z').toISOString() }); + db.insertEvent({ runId: 'run-legacy-art-01', type: 'plan:status:change', planId: 'plan-old', data: JSON.stringify({ status: 'completed' }), timestamp: new Date('2026-01-01T10:20:00.000Z').toISOString() }); + // Validation passed + db.insertEvent({ runId: 'run-legacy-art-01', type: 'validation:complete', data: JSON.stringify({ type: 'validation:complete', passed: true }), timestamp: new Date('2026-01-01T10:25:00.000Z').toISOString() }); + // PRD validation passed + db.insertEvent({ runId: 'run-legacy-art-01', type: 'prd_validation:complete', data: JSON.stringify({ type: 'prd_validation:complete', passed: true, gaps: [] }), timestamp: new Date('2026-01-01T10:27:00.000Z').toISOString() }); + // Acceptance validation passed + db.insertEvent({ runId: 'run-legacy-art-01', type: 'acceptance_validation:complete', data: JSON.stringify({ type: 'acceptance_validation:complete', passed: true, verdicts: [{ criterion: 'C1', verdict: 'pass', evidence: 'OK' }], source: 'prd' }), timestamp: new Date('2026-01-01T10:28:00.000Z').toISOString() }); + // Landing skipped due to artifact recording failure + db.insertEvent({ runId: 'run-legacy-art-01', type: 'landing:skipped', data: JSON.stringify({ type: 'landing:skipped', action: 'pr', featureBranch: 'eforge/legacy-art-set', baseBranch: 'main', reason: 'artifact recording failed' }), timestamp: new Date('2026-01-01T10:30:00.000Z').toISOString() }); + // Daemon artifact-recording error (NO authoritative build:terminal-failure event) + db.insertEvent({ runId: 'run-legacy-art-01', type: 'daemon:error', data: JSON.stringify({ type: 'daemon:error', source: 'stack:artifact-recording', message: 'Failed to record stack artifact: ENOENT' }), timestamp: new Date('2026-01-01T10:35:00.000Z').toISOString() }); + insertPhaseEnd(db, 'run-legacy-art-01', 'failed'); + db.close(); + + const fragment = synthesizeFromEvents({ setName: 'legacy-art-set', prdId: 'legacy-art-prd', dbPath }); + expect(fragment).not.toBeNull(); + // Should detect artifact-recording from daemon:error without authoritative event + expect(fragment!.terminalFailure).toBeDefined(); + expect(fragment!.terminalFailure!.authoritative).toBe(false); + expect(fragment!.terminalFailure!.scope).toBe('artifact-recording'); + expect(fragment!.terminalFailure!.message).toContain('artifact'); + // partial must be true (legacy fallback) + expect(fragment!.partial).toBe(true); + // Landing should be captured + expect(fragment!.landing).toBeDefined(); + expect(fragment!.landing!.status).toBe('skipped'); + // failingPlans should be empty (non-plan terminal failure) + expect(fragment!.failingPlans).toBeUndefined(); + // Stale plan-old agent:stop should NOT be in failingPlan + expect(fragment!.failingPlan?.planId).not.toBe('plan-old'); + }); +}); + +// --------------------------------------------------------------------------- +// Artifact-recording sequence fixture +// --------------------------------------------------------------------------- + +describe('artifact-recording terminal failure sequence', () => { + const makeTempDir = useTempDir('eforge-tf-art-'); + + it('produces scope=artifact-recording, validation commands, landing:skipped, empty failingPlans', async () => { + const dir = makeTempDir(); + seedGitRepo(dir); + mkdirSync(join(dir, '.eforge'), { recursive: true }); + const dbPath = join(dir, '.eforge', 'tf-art.db'); + const db = openDatabase(dbPath); + + makeBaseRun(db, 'run-art-01', 'art-set', dir); + // Validation run completed + db.insertEvent({ runId: 'run-art-01', type: 'validation:start', data: JSON.stringify({ type: 'validation:start', commands: ['pnpm test'] }), timestamp: new Date('2026-01-01T10:10:00.000Z').toISOString() }); + db.insertEvent({ runId: 'run-art-01', type: 'validation:command:complete', data: JSON.stringify({ type: 'validation:command:complete', command: 'pnpm test', exitCode: 0 }), timestamp: new Date('2026-01-01T10:15:00.000Z').toISOString() }); + db.insertEvent({ runId: 'run-art-01', type: 'validation:complete', data: JSON.stringify({ type: 'validation:complete', passed: true }), timestamp: new Date('2026-01-01T10:20:00.000Z').toISOString() }); + // Landing skipped + db.insertEvent({ runId: 'run-art-01', type: 'landing:skipped', data: JSON.stringify({ type: 'landing:skipped', action: 'pr', reason: 'validation passed but artifact recording failed' }), timestamp: new Date('2026-01-01T10:30:00.000Z').toISOString() }); + // Daemon artifact-recording error + db.insertEvent({ runId: 'run-art-01', type: 'daemon:error', data: JSON.stringify({ type: 'daemon:error', source: 'stack:artifact-recording', message: 'Failed to record stack artifact: ENOENT' }), timestamp: new Date('2026-01-01T10:35:00.000Z').toISOString() }); + // Authoritative terminal failure + db.insertEvent({ runId: 'run-art-01', type: 'build:terminal-failure', data: JSON.stringify({ type: 'build:terminal-failure', runId: 'run-art-01', failure: { scope: 'artifact-recording', message: 'Failed to record stack artifact: ENOENT', authoritative: true } }), timestamp: new Date('2026-01-01T10:36:00.000Z').toISOString() }); + insertPhaseEnd(db, 'run-art-01', 'failed'); + db.close(); + + const summary = await buildFailureSummary({ setName: 'art-set', prdId: 'art-prd', cwd: dir, dbPath }); + + // Scope and stage must be artifact-recording + expect(summary.terminalFailure).toBeDefined(); + expect(summary.terminalFailure!.scope).toBe('artifact-recording'); + expect(summary.terminalFailure!.authoritative).toBe(true); + expect(summary.terminalFailure!.message).toContain('artifact'); + + // Validation commands must be included + expect(summary.validationCommands).toBeDefined(); + expect(summary.validationCommands!.length).toBeGreaterThan(0); + expect(summary.validationCommands![0].command).toBe('pnpm test'); + expect(summary.validationCommands![0].exitCode).toBe(0); + + // Landing info must be present + expect(summary.landing).toBeDefined(); + expect(summary.landing!.status).toBe('skipped'); + + // failingPlans must be empty (non-plan terminal failure) + expect(summary.failingPlans).toBeUndefined(); + // failingPlan uses synthetic compat ID + expect(summary.failingPlan.planId).toBe('artifact-recording'); + + // Sidecar markdown must contain expected sections + const { mdPath } = await writeRecoverySidecar({ + failedPrdDir: dir, + prdId: 'art-prd', + summary, + verdict: { verdict: 'manual', confidence: 'low', rationale: 'artifact-recording failure', completedWork: [], remainingWork: [], risks: [] }, + }); + const { readFile } = require('node:fs/promises') as typeof import('node:fs/promises'); + const md = await readFile(mdPath, 'utf-8'); + expect(md).toContain('Terminal Failure'); + expect(md).toContain('artifact-recording'); + expect(md).toContain('Landing Status'); + // Partial warning should NOT appear since authoritative + expect(md).not.toContain('Partial analysis'); + }); +}); + +// --------------------------------------------------------------------------- +// Stale agent:stop supersession +// --------------------------------------------------------------------------- + +describe('stale agent:stop supersession', () => { + const makeTempDir = useTempDir('eforge-tf-stale-'); + + it('ignores errored agent:stop for plan later marked completed', async () => { + const dir = makeTempDir(); + mkdirSync(join(dir, '.eforge'), { recursive: true }); + const dbPath = join(dir, '.eforge', 'tf-stale.db'); + const db = openDatabase(dbPath); + + makeBaseRun(db, 'run-stale-01', 'stale-set', dir); + // Old errored stop for plan-01 which later completed + db.insertEvent({ runId: 'run-stale-01', type: 'agent:stop', planId: 'plan-01', data: JSON.stringify({ type: 'agent:stop', agent: 'builder', planId: 'plan-01', error: 'Stale error from old attempt' }), timestamp: new Date('2026-01-01T10:10:00.000Z').toISOString() }); + // plan-01 later completed + db.insertEvent({ runId: 'run-stale-01', type: 'plan:status:change', planId: 'plan-01', data: JSON.stringify({ status: 'completed' }), timestamp: new Date('2026-01-01T10:20:00.000Z').toISOString() }); + // No plan:build:failed events, no authoritative terminal event + insertPhaseEnd(db, 'run-stale-01', 'failed'); + db.close(); + + const fragment = synthesizeFromEvents({ setName: 'stale-set', prdId: 'stale-prd', dbPath }); + expect(fragment).not.toBeNull(); + // The stale stop for plan-01 should be ignored + // Either failingPlan is 'unknown' (all stops superseded) or plan-01 is NOT the failing plan + if (fragment!.failingPlan) { + expect(fragment!.failingPlan.planId).not.toBe('plan-01'); + } + }); + + it('ignores errored agent:stop for plan later marked merged', async () => { + const dir = makeTempDir(); + mkdirSync(join(dir, '.eforge'), { recursive: true }); + const dbPath = join(dir, '.eforge', 'tf-stale-merged.db'); + const db = openDatabase(dbPath); + + makeBaseRun(db, 'run-stale-02', 'stale-merged-set', dir); + // Old errored stop for plan-01 which later was merged + db.insertEvent({ runId: 'run-stale-02', type: 'agent:stop', planId: 'plan-01', data: JSON.stringify({ type: 'agent:stop', agent: 'builder', planId: 'plan-01', error: 'Stale error from old attempt' }), timestamp: new Date('2026-01-01T10:10:00.000Z').toISOString() }); + // plan-01 later merged + db.insertEvent({ runId: 'run-stale-02', type: 'plan:status:change', planId: 'plan-01', data: JSON.stringify({ status: 'merged' }), timestamp: new Date('2026-01-01T10:20:00.000Z').toISOString() }); + // No plan:build:failed events, no authoritative terminal event + insertPhaseEnd(db, 'run-stale-02', 'failed'); + db.close(); + + const fragment = synthesizeFromEvents({ setName: 'stale-merged-set', prdId: 'stale-merged-prd', dbPath }); + expect(fragment).not.toBeNull(); + // The stale stop for plan-01 should be ignored (plan later merged) + // failingPlan should NOT be plan-01 + if (fragment!.failingPlan) { + expect(fragment!.failingPlan.planId).not.toBe('plan-01'); + } + // failingPlans should not contain plan-01 + if (fragment!.failingPlans) { + expect(fragment!.failingPlans.map((p) => p.planId)).not.toContain('plan-01'); + } + }); +}); + +// --------------------------------------------------------------------------- +// Fallback taxonomy +// --------------------------------------------------------------------------- + +describe('fallback scope taxonomy', () => { + const makeTempDir = useTempDir('eforge-tf-tax-'); + + it('returns scope=post-merge-validation for failed validation:complete without authoritative event (legacy fallback)', async () => { + const dir = makeTempDir(); + mkdirSync(join(dir, '.eforge'), { recursive: true }); + const dbPath = join(dir, '.eforge', 'tf-postmerge.db'); + const db = openDatabase(dbPath); + + makeBaseRun(db, 'run-pm-01', 'pm-set', dir); + // Post-merge validation:complete with passed=false — NO authoritative build:terminal-failure event + db.insertEvent({ runId: 'run-pm-01', type: 'validation:complete', data: JSON.stringify({ type: 'validation:complete', passed: false }), timestamp: new Date('2026-01-01T10:10:00.000Z').toISOString() }); + insertPhaseEnd(db, 'run-pm-01', 'failed'); + db.close(); + + const fragment = synthesizeFromEvents({ setName: 'pm-set', prdId: 'pm-prd', dbPath }); + expect(fragment).not.toBeNull(); + expect(fragment!.terminalFailure!.scope).toBe('post-merge-validation'); + expect(fragment!.terminalFailure!.stage).toBe('post-merge-validation'); + expect(fragment!.terminalFailure!.authoritative).toBe(false); + expect(fragment!.partial).toBe(true); + }); + + it('returns scope=prd-validation for failed prd_validation:complete (no authoritative event)', async () => { + const dir = makeTempDir(); + mkdirSync(join(dir, '.eforge'), { recursive: true }); + const dbPath = join(dir, '.eforge', 'tf-prdval.db'); + const db = openDatabase(dbPath); + + makeBaseRun(db, 'run-pv-01', 'pv-set', dir); + db.insertEvent({ runId: 'run-pv-01', type: 'prd_validation:complete', data: JSON.stringify({ type: 'prd_validation:complete', passed: false, gaps: [{ description: 'Missing endpoint' }] }), timestamp: new Date('2026-01-01T10:10:00.000Z').toISOString() }); + insertPhaseEnd(db, 'run-pv-01', 'failed'); + db.close(); + + const fragment = synthesizeFromEvents({ setName: 'pv-set', prdId: 'pv-prd', dbPath }); + expect(fragment!.terminalFailure!.stage).toBe('prd-validation'); + expect(fragment!.terminalFailure!.scope).toBe('prd-validation'); + expect(fragment!.terminalFailure!.authoritative).toBe(false); + expect(fragment!.partial).toBe(true); + }); + + it('returns scope=acceptance-validation for failed acceptance_validation:complete after clean prd validation', async () => { + const dir = makeTempDir(); + mkdirSync(join(dir, '.eforge'), { recursive: true }); + const dbPath = join(dir, '.eforge', 'tf-accval.db'); + const db = openDatabase(dbPath); + + makeBaseRun(db, 'run-av-01', 'av-set', dir); + db.insertEvent({ runId: 'run-av-01', type: 'prd_validation:complete', data: JSON.stringify({ type: 'prd_validation:complete', passed: true, gaps: [] }), timestamp: new Date('2026-01-01T10:05:00.000Z').toISOString() }); + db.insertEvent({ runId: 'run-av-01', type: 'acceptance_validation:complete', data: JSON.stringify({ type: 'acceptance_validation:complete', passed: false, verdicts: [{ criterion: 'C1', verdict: 'fail', evidence: 'Missing' }] }), timestamp: new Date('2026-01-01T10:10:00.000Z').toISOString() }); + insertPhaseEnd(db, 'run-av-01', 'failed'); + db.close(); + + const fragment = synthesizeFromEvents({ setName: 'av-set', prdId: 'av-prd', dbPath }); + expect(fragment!.terminalFailure!.stage).toBe('acceptance-validation'); + expect(fragment!.terminalFailure!.scope).toBe('acceptance-validation'); + expect(fragment!.terminalFailure!.authoritative).toBe(false); + expect(fragment!.partial).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// writeRecoverySidecar — partial analysis warning +// --------------------------------------------------------------------------- + +describe('writeRecoverySidecar partial analysis warning', () => { + const makeTempDir = useTempDir('eforge-tf-sidecar-partial-'); + + it('Markdown includes Partial analysis warning when summary.partial is true even with a normal verdict', async () => { + const dir = makeTempDir(); + const partialSummary: BuildFailureSummary = { + prdId: 'test-prd', setName: 'test-set', featureBranch: 'eforge/test-set', baseBranch: 'main', + plans: [{ planId: 'plan-01', status: 'failed', error: 'Build failed' }], + failingPlan: { planId: 'plan-01', errorMessage: 'Build failed' }, + landedCommits: [], diffStat: '', modelsUsed: [], failedAt: '2024-01-01T00:00:00.000Z', + partial: true, + }; + const { mdPath } = await writeRecoverySidecar({ + failedPrdDir: dir, prdId: 'test-prd', summary: partialSummary, + verdict: { verdict: 'manual', confidence: 'low', rationale: 'partial', completedWork: [], remainingWork: [], risks: [] }, + }); + const md = await readFile(mdPath, 'utf-8'); + expect(md).toContain('Partial analysis'); + }); +}); + +// --------------------------------------------------------------------------- +// EforgeEngine.build — terminal failure event emission +// --------------------------------------------------------------------------- + +describe('EforgeEngine.build terminal failure emission', () => { + const makeTempDir = useTempDir('eforge-build-tf-test-'); + + it('emits exactly one build:terminal-failure before phase:end when build fails', async () => { + const dir = makeTempDir(); + const g = { cwd: dir }; + execFileSync('git', ['init', '-b', 'main'], g); + execFileSync('git', ['config', 'user.email', 'test@example.com'], g); + execFileSync('git', ['config', 'user.name', 'Test'], g); + execFileSync('git', ['commit', '--allow-empty', '-m', 'chore: initial commit'], g); + + const engine = await EforgeEngine.create({ cwd: dir, agentRuntimes: new StubHarness([]) }); + // A missing plan set causes an early failure (no orchestration.yaml) + const events = await collectEvents(engine.build('missing-plan-set')); + const tfEvents = filterEvents(events, 'build:terminal-failure'); + const phaseEndEvents = filterEvents(events, 'phase:end'); + // Exactly one terminal failure must be emitted before the failed phase:end + expect(tfEvents).toHaveLength(1); + expect(events.indexOf(tfEvents[0]!)).toBeLessThan(events.indexOf(phaseEndEvents[phaseEndEvents.length - 1]!)); + const tf = tfEvents[0] as { type: string; failure: { authoritative: boolean; scope: string } }; + expect(tf.failure.authoritative).toBe(true); + expect(typeof tf.failure.scope).toBe('string'); + const lastPhaseEnd = phaseEndEvents[phaseEndEvents.length - 1] as { type: string; result: { status: string } }; + expect(lastPhaseEnd.result.status).toBe('failed'); + }); +}); diff --git a/test/recovery.test.ts b/test/recovery.test.ts index 0c81aefde..74d11fb2e 100644 --- a/test/recovery.test.ts +++ b/test/recovery.test.ts @@ -646,7 +646,7 @@ describe('buildFailureSummary', () => { expect(summary.baseBranch).toBe('main'); // falls back to main (no remote tracking) expect(summary.featureBranch).toBe('eforge/test-recovery-set'); expect(summary.prdId).toBe('test-prd'); - // Monitor DB had events, so partial should not be true + // Monitor DB had plan:build:failed events with direct evidence — not legacy inference, not partial expect(summary.partial).toBeUndefined(); }); @@ -1352,7 +1352,7 @@ describe('buildFailureSummary multi-plan reconstruction', () => { dbPath, }); - expect(summary.partial).toBeUndefined(); + expect(summary.partial).toBeUndefined(); // direct evidence, not partial }); it('plan:error:set enriches summary.plans error when no plan:build:failed row exists for the plan', async () => { diff --git a/web/content/reference/events.md b/web/content/reference/events.md index 37e31e4df..9c49ca346 100644 --- a/web/content/reference/events.md +++ b/web/content/reference/events.md @@ -11,7 +11,7 @@ with one of the variant objects below. The `type` field discriminates the varian ## Event Variants -Total variants: 206 +Total variants: 207 | Event type | Additional fields | |------------|-------------------| @@ -221,6 +221,7 @@ Total variants: 206 | `stack:sync:failed` | `dryRun`, `error`, `outcome`, `reason`, `syncId`, `trigger` | | `stack:sync:deferred` | `excludedCandidates`, `reason`, `syncId`, `trigger` | | `stack:sync:skipped` | `dryRun`, `excludedCandidates`, `reason`, `restackCandidates`, `syncId`, `trigger` | +| `build:terminal-failure` | `failure`, `runId` | ## JSON Schema diff --git a/web/public/llms-full.txt b/web/public/llms-full.txt index a4d9c9335..e880a7385 100644 --- a/web/public/llms-full.txt +++ b/web/public/llms-full.txt @@ -577,7 +577,7 @@ with one of the variant objects below. The `type` field discriminates the varian ## Event Variants -Total variants: 206 +Total variants: 207 | Event type | Additional fields | |------------|-------------------| @@ -787,6 +787,7 @@ Total variants: 206 | `stack:sync:failed` | `dryRun`, `error`, `outcome`, `reason`, `syncId`, `trigger` | | `stack:sync:deferred` | `excludedCandidates`, `reason`, `syncId`, `trigger` | | `stack:sync:skipped` | `dryRun`, `excludedCandidates`, `reason`, `restackCandidates`, `syncId`, `trigger` | +| `build:terminal-failure` | `failure`, `runId` | ## JSON Schema diff --git a/web/public/reference/events.md b/web/public/reference/events.md index 37e31e4df..9c49ca346 100644 --- a/web/public/reference/events.md +++ b/web/public/reference/events.md @@ -11,7 +11,7 @@ with one of the variant objects below. The `type` field discriminates the varian ## Event Variants -Total variants: 206 +Total variants: 207 | Event type | Additional fields | |------------|-------------------| @@ -221,6 +221,7 @@ Total variants: 206 | `stack:sync:failed` | `dryRun`, `error`, `outcome`, `reason`, `syncId`, `trigger` | | `stack:sync:deferred` | `excludedCandidates`, `reason`, `syncId`, `trigger` | | `stack:sync:skipped` | `dryRun`, `excludedCandidates`, `reason`, `restackCandidates`, `syncId`, `trigger` | +| `build:terminal-failure` | `failure`, `runId` | ## JSON Schema diff --git a/web/public/schemas/events.schema.json b/web/public/schemas/events.schema.json index 37cb7e77a..e92ec310f 100644 --- a/web/public/schemas/events.schema.json +++ b/web/public/schemas/events.schema.json @@ -8004,10 +8004,56 @@ }, "terminalFailure": { "type": "object", - "required": [ - "stage" - ], "properties": { + "scope": { + "anyOf": [ + { + "const": "plan", + "type": "string" + }, + { + "const": "post-merge-validation", + "type": "string" + }, + { + "const": "prd-validation", + "type": "string" + }, + { + "const": "acceptance-validation", + "type": "string" + }, + { + "const": "artifact-recording", + "type": "string" + }, + { + "const": "landing", + "type": "string" + }, + { + "const": "daemon", + "type": "string" + }, + { + "const": "compile", + "type": "string" + }, + { + "const": "unknown", + "type": "string" + } + ] + }, + "message": { + "type": "string" + }, + "authoritative": { + "type": "boolean" + }, + "planId": { + "type": "string" + }, "stage": { "type": "string" }, @@ -8019,6 +8065,41 @@ }, "eventType": { "type": "string" + }, + "sourceEventType": { + "type": "string" + }, + "sourceEventId": { + "type": "integer" + }, + "sourceEventTimestamp": { + "type": "string" + }, + "landing": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string" + }, + "action": { + "type": "string" + }, + "reason": { + "type": "string" + } + } + }, + "validationPassed": { + "type": "boolean" + }, + "prdValidationPassed": { + "type": "boolean" + }, + "acceptanceValidationPassed": { + "type": "boolean" } } }, @@ -10373,6 +10454,129 @@ } } } + }, + { + "type": "object", + "required": [ + "type", + "runId", + "failure" + ], + "properties": { + "type": { + "const": "build:terminal-failure", + "type": "string" + }, + "runId": { + "type": "string" + }, + "failure": { + "type": "object", + "required": [ + "scope", + "message", + "authoritative" + ], + "properties": { + "scope": { + "anyOf": [ + { + "const": "plan", + "type": "string" + }, + { + "const": "post-merge-validation", + "type": "string" + }, + { + "const": "prd-validation", + "type": "string" + }, + { + "const": "acceptance-validation", + "type": "string" + }, + { + "const": "artifact-recording", + "type": "string" + }, + { + "const": "landing", + "type": "string" + }, + { + "const": "daemon", + "type": "string" + }, + { + "const": "compile", + "type": "string" + }, + { + "const": "unknown", + "type": "string" + } + ] + }, + "message": { + "type": "string" + }, + "authoritative": { + "type": "boolean" + }, + "planId": { + "type": "string" + }, + "stage": { + "type": "string" + }, + "phaseSummary": { + "type": "string" + }, + "phaseStatus": { + "type": "string" + }, + "eventType": { + "type": "string" + }, + "sourceEventType": { + "type": "string" + }, + "sourceEventId": { + "type": "integer" + }, + "sourceEventTimestamp": { + "type": "string" + }, + "landing": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string" + }, + "action": { + "type": "string" + }, + "reason": { + "type": "string" + } + } + }, + "validationPassed": { + "type": "boolean" + }, + "prdValidationPassed": { + "type": "boolean" + }, + "acceptanceValidationPassed": { + "type": "boolean" + } + } + } + } } ] }