popeye-cli 2.2.0 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/gemini.d.ts +14 -0
- package/dist/adapters/gemini.d.ts.map +1 -1
- package/dist/adapters/gemini.js +41 -6
- package/dist/adapters/gemini.js.map +1 -1
- package/dist/adapters/grok.d.ts +14 -0
- package/dist/adapters/grok.d.ts.map +1 -1
- package/dist/adapters/grok.js +42 -6
- package/dist/adapters/grok.js.map +1 -1
- package/dist/adapters/openai.d.ts +10 -0
- package/dist/adapters/openai.d.ts.map +1 -1
- package/dist/adapters/openai.js +44 -5
- package/dist/adapters/openai.js.map +1 -1
- package/dist/cli/commands/create.js +1 -1
- package/dist/cli/commands/create.js.map +1 -1
- package/dist/cli/interactive.d.ts.map +1 -1
- package/dist/cli/interactive.js +324 -20
- package/dist/cli/interactive.js.map +1 -1
- package/dist/generators/all.d.ts.map +1 -1
- package/dist/generators/all.js +3 -2
- package/dist/generators/all.js.map +1 -1
- package/dist/generators/doc-parser.d.ts +21 -6
- package/dist/generators/doc-parser.d.ts.map +1 -1
- package/dist/generators/doc-parser.js +55 -4
- package/dist/generators/doc-parser.js.map +1 -1
- package/dist/generators/templates/fullstack.js +1 -1
- package/dist/generators/templates/website-components.js +1 -1
- package/dist/generators/templates/website-components.js.map +1 -1
- package/dist/generators/templates/website-config.d.ts +4 -1
- package/dist/generators/templates/website-config.d.ts.map +1 -1
- package/dist/generators/templates/website-config.js +17 -11
- package/dist/generators/templates/website-config.js.map +1 -1
- package/dist/generators/templates/website-conversion.js +1 -1
- package/dist/generators/templates/website-conversion.js.map +1 -1
- package/dist/generators/templates/website-landing.js +1 -1
- package/dist/generators/templates/website-landing.js.map +1 -1
- package/dist/generators/templates/website-layout.d.ts +36 -4
- package/dist/generators/templates/website-layout.d.ts.map +1 -1
- package/dist/generators/templates/website-layout.js +466 -23
- package/dist/generators/templates/website-layout.js.map +1 -1
- package/dist/generators/templates/website-pricing.js +1 -1
- package/dist/generators/templates/website-pricing.js.map +1 -1
- package/dist/generators/templates/website-sections.js +1 -1
- package/dist/generators/templates/website-sections.js.map +1 -1
- package/dist/generators/templates/website-seo.d.ts.map +1 -1
- package/dist/generators/templates/website-seo.js +4 -1
- package/dist/generators/templates/website-seo.js.map +1 -1
- package/dist/generators/templates/website.d.ts +1 -1
- package/dist/generators/templates/website.d.ts.map +1 -1
- package/dist/generators/templates/website.js +1 -1
- package/dist/generators/templates/website.js.map +1 -1
- package/dist/generators/website-content-ai.d.ts +52 -0
- package/dist/generators/website-content-ai.d.ts.map +1 -0
- package/dist/generators/website-content-ai.js +141 -0
- package/dist/generators/website-content-ai.js.map +1 -0
- package/dist/generators/website-content-scanner.d.ts +1 -1
- package/dist/generators/website-content-scanner.d.ts.map +1 -1
- package/dist/generators/website-content-scanner.js +98 -1
- package/dist/generators/website-content-scanner.js.map +1 -1
- package/dist/generators/website-context.d.ts +34 -1
- package/dist/generators/website-context.d.ts.map +1 -1
- package/dist/generators/website-context.js +131 -9
- package/dist/generators/website-context.js.map +1 -1
- package/dist/generators/website-debug.d.ts +12 -0
- package/dist/generators/website-debug.d.ts.map +1 -1
- package/dist/generators/website-debug.js +16 -0
- package/dist/generators/website-debug.js.map +1 -1
- package/dist/generators/website.d.ts.map +1 -1
- package/dist/generators/website.js +26 -4
- package/dist/generators/website.js.map +1 -1
- package/dist/pipeline/auto-recovery.d.ts +56 -0
- package/dist/pipeline/auto-recovery.d.ts.map +1 -0
- package/dist/pipeline/auto-recovery.js +185 -0
- package/dist/pipeline/auto-recovery.js.map +1 -0
- package/dist/pipeline/change-request.d.ts +39 -0
- package/dist/pipeline/change-request.d.ts.map +1 -1
- package/dist/pipeline/change-request.js +40 -1
- package/dist/pipeline/change-request.js.map +1 -1
- package/dist/pipeline/check-runner.d.ts +30 -1
- package/dist/pipeline/check-runner.d.ts.map +1 -1
- package/dist/pipeline/check-runner.js +122 -1
- package/dist/pipeline/check-runner.js.map +1 -1
- package/dist/pipeline/command-resolver.d.ts.map +1 -1
- package/dist/pipeline/command-resolver.js +33 -2
- package/dist/pipeline/command-resolver.js.map +1 -1
- package/dist/pipeline/consensus/arbitrator-query.d.ts +22 -0
- package/dist/pipeline/consensus/arbitrator-query.d.ts.map +1 -0
- package/dist/pipeline/consensus/arbitrator-query.js +70 -0
- package/dist/pipeline/consensus/arbitrator-query.js.map +1 -0
- package/dist/pipeline/consensus/consensus-runner.d.ts +131 -7
- package/dist/pipeline/consensus/consensus-runner.d.ts.map +1 -1
- package/dist/pipeline/consensus/consensus-runner.js +809 -35
- package/dist/pipeline/consensus/consensus-runner.js.map +1 -1
- package/dist/pipeline/cr-lifecycle.d.ts +42 -0
- package/dist/pipeline/cr-lifecycle.d.ts.map +1 -0
- package/dist/pipeline/cr-lifecycle.js +89 -0
- package/dist/pipeline/cr-lifecycle.js.map +1 -0
- package/dist/pipeline/gate-engine.d.ts +1 -0
- package/dist/pipeline/gate-engine.d.ts.map +1 -1
- package/dist/pipeline/gate-engine.js +26 -7
- package/dist/pipeline/gate-engine.js.map +1 -1
- package/dist/pipeline/orchestrator.d.ts +1 -1
- package/dist/pipeline/orchestrator.d.ts.map +1 -1
- package/dist/pipeline/orchestrator.js +306 -16
- package/dist/pipeline/orchestrator.js.map +1 -1
- package/dist/pipeline/packets/consensus-packet-builder.d.ts +15 -4
- package/dist/pipeline/packets/consensus-packet-builder.d.ts.map +1 -1
- package/dist/pipeline/packets/consensus-packet-builder.js +29 -17
- package/dist/pipeline/packets/consensus-packet-builder.js.map +1 -1
- package/dist/pipeline/phases/architecture.d.ts.map +1 -1
- package/dist/pipeline/phases/architecture.js +5 -3
- package/dist/pipeline/phases/architecture.js.map +1 -1
- package/dist/pipeline/phases/audit.d.ts.map +1 -1
- package/dist/pipeline/phases/audit.js +5 -3
- package/dist/pipeline/phases/audit.js.map +1 -1
- package/dist/pipeline/phases/consensus-architecture.d.ts.map +1 -1
- package/dist/pipeline/phases/consensus-architecture.js +10 -1
- package/dist/pipeline/phases/consensus-architecture.js.map +1 -1
- package/dist/pipeline/phases/consensus-master-plan.d.ts.map +1 -1
- package/dist/pipeline/phases/consensus-master-plan.js +10 -3
- package/dist/pipeline/phases/consensus-master-plan.js.map +1 -1
- package/dist/pipeline/phases/consensus-role-plans.d.ts.map +1 -1
- package/dist/pipeline/phases/consensus-role-plans.js +10 -1
- package/dist/pipeline/phases/consensus-role-plans.js.map +1 -1
- package/dist/pipeline/phases/done.d.ts.map +1 -1
- package/dist/pipeline/phases/done.js +9 -4
- package/dist/pipeline/phases/done.js.map +1 -1
- package/dist/pipeline/phases/intake.d.ts.map +1 -1
- package/dist/pipeline/phases/intake.js +7 -3
- package/dist/pipeline/phases/intake.js.map +1 -1
- package/dist/pipeline/phases/phase-context.d.ts +2 -0
- package/dist/pipeline/phases/phase-context.d.ts.map +1 -1
- package/dist/pipeline/phases/phase-context.js +3 -1
- package/dist/pipeline/phases/phase-context.js.map +1 -1
- package/dist/pipeline/phases/production-gate.d.ts.map +1 -1
- package/dist/pipeline/phases/production-gate.js +28 -3
- package/dist/pipeline/phases/production-gate.js.map +1 -1
- package/dist/pipeline/phases/qa-validation.d.ts.map +1 -1
- package/dist/pipeline/phases/qa-validation.js +38 -5
- package/dist/pipeline/phases/qa-validation.js.map +1 -1
- package/dist/pipeline/phases/recovery-loop.d.ts +2 -0
- package/dist/pipeline/phases/recovery-loop.d.ts.map +1 -1
- package/dist/pipeline/phases/recovery-loop.js +200 -6
- package/dist/pipeline/phases/recovery-loop.js.map +1 -1
- package/dist/pipeline/phases/review.d.ts.map +1 -1
- package/dist/pipeline/phases/review.js +58 -28
- package/dist/pipeline/phases/review.js.map +1 -1
- package/dist/pipeline/phases/role-planning.d.ts.map +1 -1
- package/dist/pipeline/phases/role-planning.js +18 -2
- package/dist/pipeline/phases/role-planning.js.map +1 -1
- package/dist/pipeline/phases/stuck.d.ts.map +1 -1
- package/dist/pipeline/phases/stuck.js +10 -0
- package/dist/pipeline/phases/stuck.js.map +1 -1
- package/dist/pipeline/repo-snapshot.d.ts.map +1 -1
- package/dist/pipeline/repo-snapshot.js +3 -0
- package/dist/pipeline/repo-snapshot.js.map +1 -1
- package/dist/pipeline/role-execution-adapter.d.ts +2 -1
- package/dist/pipeline/role-execution-adapter.d.ts.map +1 -1
- package/dist/pipeline/role-execution-adapter.js +22 -7
- package/dist/pipeline/role-execution-adapter.js.map +1 -1
- package/dist/pipeline/skill-loader.d.ts +19 -0
- package/dist/pipeline/skill-loader.d.ts.map +1 -1
- package/dist/pipeline/skill-loader.js +22 -0
- package/dist/pipeline/skill-loader.js.map +1 -1
- package/dist/pipeline/skills/coverage-gate.d.ts +44 -0
- package/dist/pipeline/skills/coverage-gate.d.ts.map +1 -0
- package/dist/pipeline/skills/coverage-gate.js +143 -0
- package/dist/pipeline/skills/coverage-gate.js.map +1 -0
- package/dist/pipeline/skills/usage-registry.d.ts +48 -0
- package/dist/pipeline/skills/usage-registry.d.ts.map +1 -0
- package/dist/pipeline/skills/usage-registry.js +55 -0
- package/dist/pipeline/skills/usage-registry.js.map +1 -0
- package/dist/pipeline/strategy-context.d.ts +20 -0
- package/dist/pipeline/strategy-context.d.ts.map +1 -0
- package/dist/pipeline/strategy-context.js +55 -0
- package/dist/pipeline/strategy-context.js.map +1 -0
- package/dist/pipeline/type-defs/artifacts.d.ts +25 -5
- package/dist/pipeline/type-defs/artifacts.d.ts.map +1 -1
- package/dist/pipeline/type-defs/artifacts.js +4 -0
- package/dist/pipeline/type-defs/artifacts.js.map +1 -1
- package/dist/pipeline/type-defs/audit.d.ts +25 -13
- package/dist/pipeline/type-defs/audit.d.ts.map +1 -1
- package/dist/pipeline/type-defs/checks.d.ts +18 -8
- package/dist/pipeline/type-defs/checks.d.ts.map +1 -1
- package/dist/pipeline/type-defs/checks.js +4 -0
- package/dist/pipeline/type-defs/checks.js.map +1 -1
- package/dist/pipeline/type-defs/packets.d.ts +104 -18
- package/dist/pipeline/type-defs/packets.d.ts.map +1 -1
- package/dist/pipeline/type-defs/packets.js +17 -1
- package/dist/pipeline/type-defs/packets.js.map +1 -1
- package/dist/pipeline/type-defs/state.d.ts +160 -16
- package/dist/pipeline/type-defs/state.d.ts.map +1 -1
- package/dist/pipeline/type-defs/state.js +26 -1
- package/dist/pipeline/type-defs/state.js.map +1 -1
- package/dist/shared/text-utils.d.ts +23 -0
- package/dist/shared/text-utils.d.ts.map +1 -0
- package/dist/shared/text-utils.js +66 -0
- package/dist/shared/text-utils.js.map +1 -0
- package/dist/shared/website-strategy-format.d.ts +18 -0
- package/dist/shared/website-strategy-format.d.ts.map +1 -0
- package/dist/shared/website-strategy-format.js +47 -0
- package/dist/shared/website-strategy-format.js.map +1 -0
- package/dist/state/index.d.ts +2 -0
- package/dist/state/index.d.ts.map +1 -1
- package/dist/state/index.js +57 -8
- package/dist/state/index.js.map +1 -1
- package/dist/types/consensus.d.ts +1 -0
- package/dist/types/consensus.d.ts.map +1 -1
- package/dist/types/consensus.js.map +1 -1
- package/dist/types/website-strategy.d.ts +1 -1
- package/dist/types/workflow.d.ts +447 -0
- package/dist/types/workflow.d.ts.map +1 -1
- package/dist/types/workflow.js +3 -0
- package/dist/types/workflow.js.map +1 -1
- package/dist/upgrade/handlers.d.ts.map +1 -1
- package/dist/upgrade/handlers.js +6 -3
- package/dist/upgrade/handlers.js.map +1 -1
- package/dist/workflow/consensus.d.ts.map +1 -1
- package/dist/workflow/consensus.js +1 -0
- package/dist/workflow/consensus.js.map +1 -1
- package/dist/workflow/website-strategy.d.ts.map +1 -1
- package/dist/workflow/website-strategy.js +2 -29
- package/dist/workflow/website-strategy.js.map +1 -1
- package/dist/workflow/website-updater.d.ts.map +1 -1
- package/dist/workflow/website-updater.js +3 -2
- package/dist/workflow/website-updater.js.map +1 -1
- package/package.json +1 -1
- package/src/adapters/gemini.ts +51 -6
- package/src/adapters/grok.ts +51 -6
- package/src/adapters/openai.ts +53 -5
- package/src/cli/commands/create.ts +1 -1
- package/src/cli/interactive.ts +333 -19
- package/src/generators/all.ts +3 -2
- package/src/generators/doc-parser.ts +75 -15
- package/src/generators/templates/fullstack.ts +1 -1
- package/src/generators/templates/website-components.ts +1 -1
- package/src/generators/templates/website-config.ts +23 -11
- package/src/generators/templates/website-conversion.ts +1 -1
- package/src/generators/templates/website-landing.ts +1 -1
- package/src/generators/templates/website-layout.ts +491 -23
- package/src/generators/templates/website-pricing.ts +1 -1
- package/src/generators/templates/website-sections.ts +1 -1
- package/src/generators/templates/website-seo.ts +4 -1
- package/src/generators/templates/website.ts +3 -0
- package/src/generators/website-content-ai.ts +186 -0
- package/src/generators/website-content-scanner.ts +113 -1
- package/src/generators/website-context.ts +151 -12
- package/src/generators/website-debug.ts +26 -0
- package/src/generators/website.ts +28 -3
- package/src/pipeline/auto-recovery.ts +283 -0
- package/src/pipeline/change-request.ts +63 -1
- package/src/pipeline/check-runner.ts +141 -2
- package/src/pipeline/command-resolver.ts +34 -2
- package/src/pipeline/consensus/arbitrator-query.ts +101 -0
- package/src/pipeline/consensus/consensus-runner.ts +1099 -42
- package/src/pipeline/cr-lifecycle.ts +103 -0
- package/src/pipeline/gate-engine.ts +35 -7
- package/src/pipeline/orchestrator.ts +361 -16
- package/src/pipeline/packets/consensus-packet-builder.ts +44 -18
- package/src/pipeline/phases/architecture.ts +6 -3
- package/src/pipeline/phases/audit.ts +6 -3
- package/src/pipeline/phases/consensus-architecture.ts +10 -1
- package/src/pipeline/phases/consensus-master-plan.ts +10 -3
- package/src/pipeline/phases/consensus-role-plans.ts +10 -1
- package/src/pipeline/phases/done.ts +15 -4
- package/src/pipeline/phases/intake.ts +7 -3
- package/src/pipeline/phases/phase-context.ts +6 -1
- package/src/pipeline/phases/production-gate.ts +41 -3
- package/src/pipeline/phases/qa-validation.ts +51 -5
- package/src/pipeline/phases/recovery-loop.ts +229 -7
- package/src/pipeline/phases/review.ts +73 -30
- package/src/pipeline/phases/role-planning.ts +21 -2
- package/src/pipeline/phases/stuck.ts +10 -0
- package/src/pipeline/repo-snapshot.ts +3 -0
- package/src/pipeline/role-execution-adapter.ts +30 -4
- package/src/pipeline/skill-loader.ts +33 -0
- package/src/pipeline/skills/coverage-gate.ts +199 -0
- package/src/pipeline/skills/usage-registry.ts +87 -0
- package/src/pipeline/strategy-context.ts +60 -0
- package/src/pipeline/type-defs/artifacts.ts +4 -0
- package/src/pipeline/type-defs/checks.ts +4 -0
- package/src/pipeline/type-defs/packets.ts +18 -1
- package/src/pipeline/type-defs/state.ts +26 -1
- package/src/shared/text-utils.ts +70 -0
- package/src/shared/website-strategy-format.ts +56 -0
- package/src/state/index.ts +60 -8
- package/src/types/consensus.ts +1 -0
- package/src/types/workflow.ts +6 -0
- package/src/upgrade/handlers.ts +9 -3
- package/src/workflow/consensus.ts +1 -0
- package/src/workflow/website-strategy.ts +2 -36
- package/src/workflow/website-updater.ts +4 -2
- package/tests/adapters/gemini.test.ts +165 -0
- package/tests/adapters/grok.test.ts +137 -0
- package/tests/adapters/openai.test.ts +128 -0
- package/tests/generators/doc-parser.test.ts +88 -9
- package/tests/generators/quality-gate.test.ts +19 -3
- package/tests/generators/website-components.test.ts +34 -0
- package/tests/generators/website-content-ai.test.ts +308 -0
- package/tests/generators/website-content-scanner.test.ts +86 -0
- package/tests/generators/website-context.test.ts +3 -2
- package/tests/integration/smokestack-scaffold.test.ts +385 -0
- package/tests/pipeline/auto-recovery.test.ts +337 -0
- package/tests/pipeline/change-request.test.ts +70 -0
- package/tests/pipeline/command-resolver.test.ts +42 -0
- package/tests/pipeline/consensus/arbitrator-query.test.ts +107 -0
- package/tests/pipeline/consensus-runner.test.ts +1333 -10
- package/tests/pipeline/consensus-scoring.test.ts +602 -18
- package/tests/pipeline/gate-engine.test.ts +34 -0
- package/tests/pipeline/install-check.test.ts +261 -0
- package/tests/pipeline/orchestrator.test.ts +1506 -15
- package/tests/pipeline/packets/builders.test.ts +29 -6
- package/tests/pipeline/phases/role-planning.strategy.test.ts +204 -0
- package/tests/pipeline/pipeline-persistence.test.ts +230 -0
- package/tests/pipeline/recovery-loop-guidance.test.ts +280 -0
- package/tests/pipeline/role-execution-adapter.test.ts +88 -0
- package/tests/pipeline/skills/coverage-gate.test.ts +370 -0
- package/tests/pipeline/skills/usage-registry.test.ts +114 -0
- package/tests/pipeline/strategy-context.test.ts +148 -0
- package/tests/shared/text-utils.test.ts +155 -0
- package/tests/state/progress-analysis.test.ts +375 -0
- package/tests/upgrade/handlers.test.ts +33 -2
- package/tests/workflow/consensus.test.ts +6 -0
- package/tsconfig.json +1 -1
|
@@ -8,6 +8,7 @@ import { createGateEngine } from '../../src/pipeline/gate-engine.js';
|
|
|
8
8
|
import { createDefaultPipelineState } from '../../src/pipeline/types.js';
|
|
9
9
|
import type { PipelinePhase, PipelineState, ArtifactEntry } from '../../src/pipeline/types.js';
|
|
10
10
|
import type { GateResult } from '../../src/pipeline/gate-engine.js';
|
|
11
|
+
import { resolveActiveCR, computeLoopSignature, checkStagnation, STAGNATION_THRESHOLD } from '../../src/pipeline/cr-lifecycle.js';
|
|
11
12
|
|
|
12
13
|
// We test the orchestrator's core logic by simulating gate/phase behavior
|
|
13
14
|
// rather than importing runPipeline (which pulls in LLM deps)
|
|
@@ -30,44 +31,139 @@ function makeArtifact(type: string, phase: string): ArtifactEntry {
|
|
|
30
31
|
/**
|
|
31
32
|
* Simulates the orchestrator's core transition logic without
|
|
32
33
|
* actually running phase handlers or importing LLM dependencies.
|
|
34
|
+
*
|
|
35
|
+
* v2.5.4: Includes recovery budget reset on forward phase change and CR routing.
|
|
36
|
+
* v2.7.0: Includes failedPhase guard on budget reset, baseline capture, and regression detection.
|
|
33
37
|
*/
|
|
34
38
|
function simulateOrchestratorLoop(
|
|
35
39
|
startPhase: PipelinePhase,
|
|
36
40
|
pipeline: PipelineState,
|
|
37
|
-
phaseOutcomes: Map<PipelinePhase, boolean
|
|
38
|
-
|
|
39
|
-
|
|
41
|
+
phaseOutcomes: Map<PipelinePhase, boolean> | ((phase: PipelinePhase, attempt: number) => boolean),
|
|
42
|
+
options?: {
|
|
43
|
+
maxIterations?: number;
|
|
44
|
+
/** Pending CRs to route after REVIEW/AUDIT */
|
|
45
|
+
pendingCRs?: Array<{ cr_id: string; target_phase: PipelinePhase; status: string }>;
|
|
46
|
+
/** v2.7.0: Custom gate results per phase (for regression detection tests) */
|
|
47
|
+
gateResultOverrides?: Map<PipelinePhase, (attempt: number) => Partial<GateResult>>;
|
|
48
|
+
},
|
|
49
|
+
): { finalPhase: PipelinePhase; recoveryCount: number; phaseLog: PipelinePhase[]; progressLog: string[] } {
|
|
50
|
+
const maxIterations = options?.maxIterations ?? 50;
|
|
40
51
|
const engine = createGateEngine();
|
|
41
52
|
let phase = startPhase;
|
|
42
53
|
let failedPhase: PipelinePhase | null = null;
|
|
43
54
|
const phaseLog: PipelinePhase[] = [phase];
|
|
55
|
+
const progressLog: string[] = [];
|
|
56
|
+
const attemptCounts = new Map<PipelinePhase, number>();
|
|
57
|
+
|
|
58
|
+
// Install pending CRs if provided
|
|
59
|
+
if (options?.pendingCRs) {
|
|
60
|
+
pipeline.pendingChangeRequests = options.pendingCRs as PipelineState['pendingChangeRequests'];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const crCheckPhases = new Set<PipelinePhase>(['REVIEW', 'AUDIT']);
|
|
64
|
+
|
|
65
|
+
const getOutcome = (p: PipelinePhase): boolean => {
|
|
66
|
+
const attempt = (attemptCounts.get(p) ?? 0) + 1;
|
|
67
|
+
attemptCounts.set(p, attempt);
|
|
68
|
+
if (typeof phaseOutcomes === 'function') {
|
|
69
|
+
return phaseOutcomes(p, attempt);
|
|
70
|
+
}
|
|
71
|
+
return phaseOutcomes.get(p) ?? false;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** v2.7.0: Build a GateResult for the current phase (used for regression detection) */
|
|
75
|
+
const getGateResult = (p: PipelinePhase, pass: boolean): GateResult => {
|
|
76
|
+
const attempt = attemptCounts.get(p) ?? 1;
|
|
77
|
+
const overrideFn = options?.gateResultOverrides?.get(p);
|
|
78
|
+
const overrides = overrideFn ? overrideFn(attempt) : {};
|
|
79
|
+
return {
|
|
80
|
+
phase: p,
|
|
81
|
+
pass,
|
|
82
|
+
blockers: [],
|
|
83
|
+
missingArtifacts: [],
|
|
84
|
+
failedChecks: [],
|
|
85
|
+
timestamp: new Date().toISOString(),
|
|
86
|
+
...overrides,
|
|
87
|
+
};
|
|
88
|
+
};
|
|
44
89
|
|
|
45
90
|
for (let i = 0; i < maxIterations; i++) {
|
|
46
91
|
if (phase === 'DONE' || phase === 'STUCK') break;
|
|
47
92
|
|
|
48
|
-
const gatePass =
|
|
93
|
+
const gatePass = getOutcome(phase);
|
|
94
|
+
const gateResult = getGateResult(phase, gatePass);
|
|
49
95
|
|
|
50
96
|
if (gatePass) {
|
|
97
|
+
// v2.7.0: Clear failedPhase when the originally-failed phase now passes
|
|
98
|
+
if (pipeline.failedPhase === phase) {
|
|
99
|
+
pipeline.failedPhase = undefined;
|
|
100
|
+
pipeline.recoveryBaselineFailedCheckCount = undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// v2.5.4: Check for pending CR routing after REVIEW/AUDIT
|
|
104
|
+
if (crCheckPhases.has(phase) && pipeline.pendingChangeRequests) {
|
|
105
|
+
const nextCR = pipeline.pendingChangeRequests.find((cr) => cr.status === 'proposed');
|
|
106
|
+
if (nextCR) {
|
|
107
|
+
nextCR.status = 'approved';
|
|
108
|
+
// v2.5.4: CR routing to a new phase — reset recovery budget
|
|
109
|
+
if (nextCR.target_phase !== phase && pipeline.recoveryCount > 0) {
|
|
110
|
+
progressLog.push(`Recovery budget reset: ${pipeline.recoveryCount} -> 0 (CR routing ${phase} -> ${nextCR.target_phase})`);
|
|
111
|
+
pipeline.recoveryCount = 0;
|
|
112
|
+
pipeline.lastRewindTarget = undefined;
|
|
113
|
+
}
|
|
114
|
+
pipeline.activeChangeRequestId = nextCR.cr_id;
|
|
115
|
+
phase = nextCR.target_phase;
|
|
116
|
+
pipeline.pipelinePhase = phase;
|
|
117
|
+
phaseLog.push(phase);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
51
122
|
if (phase === 'RECOVERY_LOOP') {
|
|
52
123
|
// Recovery succeeded — go back to failed phase
|
|
53
124
|
phase = failedPhase ?? 'QA_VALIDATION';
|
|
54
125
|
failedPhase = null;
|
|
55
126
|
} else {
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
127
|
+
const nextPhase = engine.getNextPhase(phase, gateResult);
|
|
128
|
+
|
|
129
|
+
// v2.5.4 + v2.7.0: Reset recovery budget on forward phase change,
|
|
130
|
+
// but NOT during recovery traversal (failedPhase still set)
|
|
131
|
+
if (nextPhase !== phase && pipeline.recoveryCount > 0 && !pipeline.failedPhase) {
|
|
132
|
+
progressLog.push(`Recovery budget reset: ${pipeline.recoveryCount} -> 0 (advancing ${phase} -> ${nextPhase})`);
|
|
133
|
+
pipeline.recoveryCount = 0;
|
|
134
|
+
pipeline.lastRewindTarget = undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
phase = nextPhase;
|
|
65
138
|
}
|
|
66
139
|
} else {
|
|
140
|
+
// v2.7.0: Regression detection — recovery made things worse
|
|
141
|
+
if (
|
|
142
|
+
pipeline.failedPhase === phase &&
|
|
143
|
+
pipeline.recoveryBaselineFailedCheckCount !== undefined &&
|
|
144
|
+
(gateResult.failedChecks.length + gateResult.missingArtifacts.length) > pipeline.recoveryBaselineFailedCheckCount
|
|
145
|
+
) {
|
|
146
|
+
progressLog.push(
|
|
147
|
+
`[regression] Recovery worsened ${phase}: ` +
|
|
148
|
+
`${pipeline.recoveryBaselineFailedCheckCount} -> ` +
|
|
149
|
+
`${gateResult.failedChecks.length + gateResult.missingArtifacts.length} failing checks. ` +
|
|
150
|
+
`Treating budget as exhausted.`
|
|
151
|
+
);
|
|
152
|
+
pipeline.recoveryCount = pipeline.maxRecoveryIterations;
|
|
153
|
+
}
|
|
154
|
+
|
|
67
155
|
if (pipeline.recoveryCount >= pipeline.maxRecoveryIterations) {
|
|
68
156
|
phase = 'STUCK';
|
|
69
157
|
} else {
|
|
158
|
+
// v2.7.0: Capture baseline for fresh failure or when failure origin changes
|
|
159
|
+
if (
|
|
160
|
+
pipeline.recoveryBaselineFailedCheckCount === undefined ||
|
|
161
|
+
pipeline.failedPhase !== phase
|
|
162
|
+
) {
|
|
163
|
+
pipeline.recoveryBaselineFailedCheckCount = gateResult.failedChecks.length + gateResult.missingArtifacts.length;
|
|
164
|
+
}
|
|
70
165
|
failedPhase = phase;
|
|
166
|
+
pipeline.failedPhase = phase;
|
|
71
167
|
phase = 'RECOVERY_LOOP';
|
|
72
168
|
pipeline.recoveryCount++;
|
|
73
169
|
}
|
|
@@ -77,7 +173,7 @@ function simulateOrchestratorLoop(
|
|
|
77
173
|
phaseLog.push(phase);
|
|
78
174
|
}
|
|
79
175
|
|
|
80
|
-
return { finalPhase: phase, recoveryCount: pipeline.recoveryCount, phaseLog };
|
|
176
|
+
return { finalPhase: phase, recoveryCount: pipeline.recoveryCount, phaseLog, progressLog };
|
|
81
177
|
}
|
|
82
178
|
|
|
83
179
|
describe('Orchestrator Transition Logic', () => {
|
|
@@ -284,12 +380,13 @@ describe('Orchestrator Transition Logic', () => {
|
|
|
284
380
|
const result1 = engine.evaluateGate('PRODUCTION_GATE', pipeline);
|
|
285
381
|
expect(result1.pass).toBe(false);
|
|
286
382
|
|
|
287
|
-
// Add check results
|
|
383
|
+
// Add check results (including skill_coverage added in v2.2.1)
|
|
288
384
|
pipeline.gateChecks['PRODUCTION_GATE'] = [
|
|
289
385
|
{ check_type: 'build', status: 'pass', command: 'npm run build', exit_code: 0, duration_ms: 1000, timestamp: '' },
|
|
290
386
|
{ check_type: 'test', status: 'pass', command: 'npm test', exit_code: 0, duration_ms: 2000, timestamp: '' },
|
|
291
387
|
{ check_type: 'lint', status: 'pass', command: 'npm run lint', exit_code: 0, duration_ms: 500, timestamp: '' },
|
|
292
388
|
{ check_type: 'typecheck', status: 'pass', command: 'tsc', exit_code: 0, duration_ms: 300, timestamp: '' },
|
|
389
|
+
{ check_type: 'skill_coverage', status: 'pass', command: 'skill-coverage-check', exit_code: 0, duration_ms: 0, timestamp: '' },
|
|
293
390
|
];
|
|
294
391
|
|
|
295
392
|
const result2 = engine.evaluateGate('PRODUCTION_GATE', pipeline);
|
|
@@ -611,4 +708,1398 @@ describe('Orchestrator Transition Logic', () => {
|
|
|
611
708
|
expect(phase).toBe('QA_VALIDATION');
|
|
612
709
|
});
|
|
613
710
|
});
|
|
711
|
+
|
|
712
|
+
// ─── v2.4.5: Resume from STUCK ──────────────────────────
|
|
713
|
+
|
|
714
|
+
describe('v2.4.5: resume from STUCK', () => {
|
|
715
|
+
it('should reset to failedPhase when resuming from STUCK', () => {
|
|
716
|
+
pipeline.pipelinePhase = 'STUCK';
|
|
717
|
+
pipeline.failedPhase = 'CONSENSUS_ROLE_PLANS';
|
|
718
|
+
pipeline.recoveryCount = 5;
|
|
719
|
+
|
|
720
|
+
// Simulate resumePipeline logic (without calling runPipeline)
|
|
721
|
+
if (pipeline.pipelinePhase === 'STUCK' && pipeline.failedPhase) {
|
|
722
|
+
pipeline.pipelinePhase = pipeline.failedPhase;
|
|
723
|
+
pipeline.recoveryCount = 0;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
expect(pipeline.pipelinePhase).toBe('CONSENSUS_ROLE_PLANS');
|
|
727
|
+
expect(pipeline.recoveryCount).toBe(0);
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
it('should clear stale gate data for the failed phase', () => {
|
|
731
|
+
pipeline.pipelinePhase = 'STUCK';
|
|
732
|
+
pipeline.failedPhase = 'CONSENSUS_ROLE_PLANS';
|
|
733
|
+
pipeline.recoveryCount = 5;
|
|
734
|
+
|
|
735
|
+
// Populate gate data that should be cleared
|
|
736
|
+
pipeline.gateResults['CONSENSUS_ROLE_PLANS'] = {
|
|
737
|
+
phase: 'CONSENSUS_ROLE_PLANS',
|
|
738
|
+
pass: false,
|
|
739
|
+
blockers: ['skill_coverage failed'],
|
|
740
|
+
missingArtifacts: [],
|
|
741
|
+
failedChecks: [],
|
|
742
|
+
timestamp: new Date().toISOString(),
|
|
743
|
+
};
|
|
744
|
+
pipeline.gateChecks['CONSENSUS_ROLE_PLANS'] = [
|
|
745
|
+
{ check_type: 'skill_coverage', status: 'fail', command: 'skill-coverage-check', exit_code: 1, duration_ms: 0, timestamp: '' },
|
|
746
|
+
];
|
|
747
|
+
|
|
748
|
+
// Preserve other phases' gate data
|
|
749
|
+
pipeline.gateResults['CONSENSUS_ARCHITECTURE'] = {
|
|
750
|
+
phase: 'CONSENSUS_ARCHITECTURE',
|
|
751
|
+
pass: true,
|
|
752
|
+
blockers: [],
|
|
753
|
+
missingArtifacts: [],
|
|
754
|
+
failedChecks: [],
|
|
755
|
+
timestamp: new Date().toISOString(),
|
|
756
|
+
};
|
|
757
|
+
|
|
758
|
+
// Simulate resume logic
|
|
759
|
+
if (pipeline.pipelinePhase === 'STUCK' && pipeline.failedPhase) {
|
|
760
|
+
pipeline.pipelinePhase = pipeline.failedPhase;
|
|
761
|
+
pipeline.recoveryCount = 0;
|
|
762
|
+
delete pipeline.gateResults[pipeline.failedPhase];
|
|
763
|
+
delete pipeline.gateChecks[pipeline.failedPhase];
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
expect(pipeline.gateResults['CONSENSUS_ROLE_PLANS']).toBeUndefined();
|
|
767
|
+
expect(pipeline.gateChecks['CONSENSUS_ROLE_PLANS']).toBeUndefined();
|
|
768
|
+
// Other phases preserved
|
|
769
|
+
expect(pipeline.gateResults['CONSENSUS_ARCHITECTURE']).toBeDefined();
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
it('should not modify pipeline when not at STUCK', () => {
|
|
773
|
+
pipeline.pipelinePhase = 'ROLE_PLANNING';
|
|
774
|
+
pipeline.failedPhase = undefined;
|
|
775
|
+
pipeline.recoveryCount = 2;
|
|
776
|
+
|
|
777
|
+
// Simulate resume logic — condition not met
|
|
778
|
+
if (pipeline.pipelinePhase === 'STUCK' && pipeline.failedPhase) {
|
|
779
|
+
pipeline.pipelinePhase = pipeline.failedPhase;
|
|
780
|
+
pipeline.recoveryCount = 0;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
expect(pipeline.pipelinePhase).toBe('ROLE_PLANNING');
|
|
784
|
+
expect(pipeline.recoveryCount).toBe(2);
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
it('should not auto-recover when STUCK without failedPhase', () => {
|
|
788
|
+
pipeline.pipelinePhase = 'STUCK';
|
|
789
|
+
pipeline.failedPhase = undefined;
|
|
790
|
+
pipeline.recoveryCount = 5;
|
|
791
|
+
const progressMessages: string[] = [];
|
|
792
|
+
|
|
793
|
+
// Simulate resume logic with progress tracking
|
|
794
|
+
if (pipeline.pipelinePhase === 'STUCK' && pipeline.failedPhase) {
|
|
795
|
+
pipeline.pipelinePhase = pipeline.failedPhase;
|
|
796
|
+
pipeline.recoveryCount = 0;
|
|
797
|
+
} else if (pipeline.pipelinePhase === 'STUCK' && !pipeline.failedPhase) {
|
|
798
|
+
progressMessages.push('Pipeline is STUCK but failedPhase is missing');
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
expect(pipeline.pipelinePhase).toBe('STUCK');
|
|
802
|
+
expect(pipeline.recoveryCount).toBe(5);
|
|
803
|
+
expect(progressMessages).toHaveLength(1);
|
|
804
|
+
expect(progressMessages[0]).toContain('failedPhase is missing');
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
it('should reset recoveryCount from previous value to 0', () => {
|
|
808
|
+
pipeline.pipelinePhase = 'STUCK';
|
|
809
|
+
pipeline.failedPhase = 'PRODUCTION_GATE';
|
|
810
|
+
pipeline.recoveryCount = 3;
|
|
811
|
+
|
|
812
|
+
const prevRecovery = pipeline.recoveryCount;
|
|
813
|
+
|
|
814
|
+
if (pipeline.pipelinePhase === 'STUCK' && pipeline.failedPhase) {
|
|
815
|
+
pipeline.pipelinePhase = pipeline.failedPhase;
|
|
816
|
+
pipeline.recoveryCount = 0;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
expect(prevRecovery).toBe(3);
|
|
820
|
+
expect(pipeline.recoveryCount).toBe(0);
|
|
821
|
+
expect(pipeline.pipelinePhase).toBe('PRODUCTION_GATE');
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
it('should purge legacy CRs without drift_key on resume from STUCK with guidance (v2.5.2)', () => {
|
|
825
|
+
pipeline.pipelinePhase = 'STUCK';
|
|
826
|
+
pipeline.failedPhase = 'QA_VALIDATION';
|
|
827
|
+
pipeline.recoveryCount = 5;
|
|
828
|
+
pipeline.activeChangeRequestId = 'CR-STALE';
|
|
829
|
+
pipeline.pendingChangeRequests = [
|
|
830
|
+
// Legacy CRs (no drift_key, approved) — should be purged
|
|
831
|
+
{ cr_id: 'CR-OLD1', change_type: 'config', target_phase: 'QA_VALIDATION', status: 'approved' },
|
|
832
|
+
{ cr_id: 'CR-OLD2', change_type: 'config', target_phase: 'QA_VALIDATION', status: 'approved' },
|
|
833
|
+
{ cr_id: 'CR-STALE', change_type: 'scope', target_phase: 'CONSENSUS_MASTER_PLAN', status: 'approved' },
|
|
834
|
+
// New CR with drift_key — should be kept
|
|
835
|
+
{ cr_id: 'CR-NEW', change_type: 'config', target_phase: 'QA_VALIDATION', status: 'resolved', drift_key: 'dk-1' },
|
|
836
|
+
// Proposed CR without drift_key (possibly manual) — should be kept
|
|
837
|
+
{ cr_id: 'CR-MANUAL', change_type: 'scope', target_phase: 'CONSENSUS_MASTER_PLAN', status: 'proposed' },
|
|
838
|
+
];
|
|
839
|
+
|
|
840
|
+
// Simulate resume logic: purge runs BEFORE guidance check (v2.5.2)
|
|
841
|
+
if (pipeline.pipelinePhase === 'STUCK' && pipeline.failedPhase) {
|
|
842
|
+
// v2.5.2: Purge legacy CRs (unconditional)
|
|
843
|
+
pipeline.pendingChangeRequests = pipeline.pendingChangeRequests!.filter(
|
|
844
|
+
(cr) => cr.drift_key != null || cr.status === 'proposed',
|
|
845
|
+
);
|
|
846
|
+
if (!pipeline.pendingChangeRequests.some((cr) => cr.cr_id === pipeline.activeChangeRequestId)) {
|
|
847
|
+
pipeline.activeChangeRequestId = undefined;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// Guidance provided — reset phase
|
|
851
|
+
pipeline.pipelinePhase = pipeline.failedPhase;
|
|
852
|
+
pipeline.recoveryCount = 0;
|
|
853
|
+
pipeline.lastRewindTarget = undefined;
|
|
854
|
+
delete pipeline.gateResults[pipeline.failedPhase];
|
|
855
|
+
delete pipeline.gateChecks[pipeline.failedPhase];
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
expect(pipeline.pendingChangeRequests).toHaveLength(2); // CR-NEW + CR-MANUAL
|
|
859
|
+
expect(pipeline.pendingChangeRequests!.map(cr => cr.cr_id)).toEqual(['CR-NEW', 'CR-MANUAL']);
|
|
860
|
+
expect(pipeline.activeChangeRequestId).toBeUndefined(); // CR-STALE was purged
|
|
861
|
+
expect(pipeline.pipelinePhase).toBe('QA_VALIDATION'); // Reset to failed phase
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
it('should purge legacy CRs even without guidance on resume from STUCK (v2.5.2)', () => {
|
|
865
|
+
pipeline.pipelinePhase = 'STUCK';
|
|
866
|
+
pipeline.failedPhase = 'QA_VALIDATION';
|
|
867
|
+
pipeline.recoveryCount = 5;
|
|
868
|
+
pipeline.activeChangeRequestId = 'CR-STALE';
|
|
869
|
+
pipeline.pendingChangeRequests = [
|
|
870
|
+
{ cr_id: 'CR-OLD1', change_type: 'config', target_phase: 'QA_VALIDATION', status: 'approved' },
|
|
871
|
+
{ cr_id: 'CR-STALE', change_type: 'scope', target_phase: 'CONSENSUS_MASTER_PLAN', status: 'approved' },
|
|
872
|
+
{ cr_id: 'CR-NEW', change_type: 'config', target_phase: 'QA_VALIDATION', status: 'resolved', drift_key: 'dk-1' },
|
|
873
|
+
];
|
|
874
|
+
|
|
875
|
+
// Simulate resume logic: purge runs even without guidance
|
|
876
|
+
const guidance = ''; // No guidance
|
|
877
|
+
if (pipeline.pipelinePhase === 'STUCK' && pipeline.failedPhase) {
|
|
878
|
+
// v2.5.2: Purge legacy CRs (unconditional — before guidance check)
|
|
879
|
+
pipeline.pendingChangeRequests = pipeline.pendingChangeRequests!.filter(
|
|
880
|
+
(cr) => cr.drift_key != null || cr.status === 'proposed',
|
|
881
|
+
);
|
|
882
|
+
if (!pipeline.pendingChangeRequests.some((cr) => cr.cr_id === pipeline.activeChangeRequestId)) {
|
|
883
|
+
pipeline.activeChangeRequestId = undefined;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
if (guidance.length === 0) {
|
|
887
|
+
// No guidance — pipeline stays STUCK, but CRs are cleaned
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
expect(pipeline.pendingChangeRequests).toHaveLength(1); // Only CR-NEW
|
|
892
|
+
expect(pipeline.pendingChangeRequests![0].cr_id).toBe('CR-NEW');
|
|
893
|
+
expect(pipeline.activeChangeRequestId).toBeUndefined(); // CR-STALE was purged
|
|
894
|
+
expect(pipeline.pipelinePhase).toBe('STUCK'); // Still stuck (no guidance)
|
|
895
|
+
expect(pipeline.recoveryCount).toBe(5); // Not reset (no guidance)
|
|
896
|
+
});
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
// ─── v2.4.6: Recovery rewind loop detection ──────────────
|
|
900
|
+
|
|
901
|
+
describe('v2.4.6: Recovery rewind loop detection', () => {
|
|
902
|
+
it('should rewind normally on first QA failure (lastRewindTarget undefined)', () => {
|
|
903
|
+
// Simulate: RECOVERY_LOOP passes, RCA says IMPLEMENTATION, lastRewindTarget is undefined
|
|
904
|
+
let phase: PipelinePhase = 'RECOVERY_LOOP';
|
|
905
|
+
let failedPhase: PipelinePhase | null = 'QA_VALIDATION';
|
|
906
|
+
pipeline.lastRewindTarget = undefined;
|
|
907
|
+
pipeline.recoveryCount = 1;
|
|
908
|
+
|
|
909
|
+
// Simulate RCA rewind target
|
|
910
|
+
let rewindTarget: PipelinePhase | undefined = 'IMPLEMENTATION';
|
|
911
|
+
|
|
912
|
+
// v2.4.6 repeated-rewind check
|
|
913
|
+
if (rewindTarget && rewindTarget === pipeline.lastRewindTarget) {
|
|
914
|
+
rewindTarget = undefined;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
const effectiveTarget = rewindTarget ?? failedPhase ?? 'QA_VALIDATION';
|
|
918
|
+
phase = effectiveTarget;
|
|
919
|
+
pipeline.lastRewindTarget = rewindTarget;
|
|
920
|
+
|
|
921
|
+
if (failedPhase) {
|
|
922
|
+
delete pipeline.gateResults[failedPhase];
|
|
923
|
+
delete pipeline.gateChecks[failedPhase];
|
|
924
|
+
}
|
|
925
|
+
failedPhase = null;
|
|
926
|
+
|
|
927
|
+
expect(phase).toBe('IMPLEMENTATION');
|
|
928
|
+
expect(pipeline.lastRewindTarget).toBe('IMPLEMENTATION');
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
it('should skip repeated same-target rewind and re-test failed phase', () => {
|
|
932
|
+
// Simulate: lastRewindTarget === 'IMPLEMENTATION', RCA says IMPLEMENTATION again
|
|
933
|
+
let phase: PipelinePhase = 'RECOVERY_LOOP';
|
|
934
|
+
let failedPhase: PipelinePhase | null = 'QA_VALIDATION';
|
|
935
|
+
pipeline.lastRewindTarget = 'IMPLEMENTATION';
|
|
936
|
+
pipeline.recoveryCount = 2;
|
|
937
|
+
const progressMessages: string[] = [];
|
|
938
|
+
|
|
939
|
+
let rewindTarget: PipelinePhase | undefined = 'IMPLEMENTATION';
|
|
940
|
+
|
|
941
|
+
// v2.4.6 repeated-rewind check
|
|
942
|
+
if (rewindTarget && rewindTarget === pipeline.lastRewindTarget) {
|
|
943
|
+
progressMessages.push(
|
|
944
|
+
`Repeated rewind to ${rewindTarget} detected ` +
|
|
945
|
+
`(recovery #${pipeline.recoveryCount}) — re-testing ` +
|
|
946
|
+
`${failedPhase ?? 'QA_VALIDATION'} directly`,
|
|
947
|
+
);
|
|
948
|
+
rewindTarget = undefined;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
const effectiveTarget = rewindTarget ?? failedPhase ?? 'QA_VALIDATION';
|
|
952
|
+
phase = effectiveTarget;
|
|
953
|
+
pipeline.lastRewindTarget = rewindTarget;
|
|
954
|
+
failedPhase = null;
|
|
955
|
+
|
|
956
|
+
expect(phase).toBe('QA_VALIDATION');
|
|
957
|
+
expect(pipeline.lastRewindTarget).toBeUndefined();
|
|
958
|
+
expect(progressMessages).toHaveLength(1);
|
|
959
|
+
expect(progressMessages[0]).toContain('Repeated rewind');
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
it('should allow different rewind targets to proceed normally', () => {
|
|
963
|
+
// First rewind: IMPLEMENTATION
|
|
964
|
+
pipeline.lastRewindTarget = 'IMPLEMENTATION';
|
|
965
|
+
|
|
966
|
+
// Second RCA says ARCHITECTURE (different target)
|
|
967
|
+
let rewindTarget: PipelinePhase | undefined = 'ARCHITECTURE';
|
|
968
|
+
|
|
969
|
+
if (rewindTarget && rewindTarget === pipeline.lastRewindTarget) {
|
|
970
|
+
rewindTarget = undefined;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
const effectiveTarget = rewindTarget ?? 'QA_VALIDATION';
|
|
974
|
+
pipeline.lastRewindTarget = rewindTarget;
|
|
975
|
+
|
|
976
|
+
expect(effectiveTarget).toBe('ARCHITECTURE');
|
|
977
|
+
expect(pipeline.lastRewindTarget).toBe('ARCHITECTURE');
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
it('should clear stale gate data after recovery rewind', () => {
|
|
981
|
+
const failedPhase: PipelinePhase = 'QA_VALIDATION';
|
|
982
|
+
|
|
983
|
+
// Populate stale gate data
|
|
984
|
+
pipeline.gateResults['QA_VALIDATION'] = {
|
|
985
|
+
phase: 'QA_VALIDATION',
|
|
986
|
+
pass: false,
|
|
987
|
+
blockers: ['test failed'],
|
|
988
|
+
missingArtifacts: [],
|
|
989
|
+
failedChecks: [],
|
|
990
|
+
timestamp: new Date().toISOString(),
|
|
991
|
+
};
|
|
992
|
+
pipeline.gateChecks['QA_VALIDATION'] = [
|
|
993
|
+
{ check_type: 'test', status: 'fail', command: 'npm test', exit_code: 1, duration_ms: 500, timestamp: '' },
|
|
994
|
+
];
|
|
995
|
+
|
|
996
|
+
// Preserve other phases' data
|
|
997
|
+
pipeline.gateResults['IMPLEMENTATION'] = {
|
|
998
|
+
phase: 'IMPLEMENTATION',
|
|
999
|
+
pass: true,
|
|
1000
|
+
blockers: [],
|
|
1001
|
+
missingArtifacts: [],
|
|
1002
|
+
failedChecks: [],
|
|
1003
|
+
timestamp: new Date().toISOString(),
|
|
1004
|
+
};
|
|
1005
|
+
|
|
1006
|
+
// Simulate clearing (as orchestrator does after recovery)
|
|
1007
|
+
delete pipeline.gateResults[failedPhase];
|
|
1008
|
+
delete pipeline.gateChecks[failedPhase];
|
|
1009
|
+
|
|
1010
|
+
expect(pipeline.gateResults['QA_VALIDATION']).toBeUndefined();
|
|
1011
|
+
expect(pipeline.gateChecks['QA_VALIDATION']).toBeUndefined();
|
|
1012
|
+
expect(pipeline.gateResults['IMPLEMENTATION']).toBeDefined();
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
it('should clear lastRewindTarget when resuming from STUCK', () => {
|
|
1016
|
+
pipeline.pipelinePhase = 'STUCK';
|
|
1017
|
+
pipeline.failedPhase = 'QA_VALIDATION';
|
|
1018
|
+
pipeline.recoveryCount = 5;
|
|
1019
|
+
pipeline.lastRewindTarget = 'IMPLEMENTATION';
|
|
1020
|
+
|
|
1021
|
+
// Simulate resumePipeline logic
|
|
1022
|
+
if (pipeline.pipelinePhase === 'STUCK' && pipeline.failedPhase) {
|
|
1023
|
+
pipeline.pipelinePhase = pipeline.failedPhase;
|
|
1024
|
+
pipeline.recoveryCount = 0;
|
|
1025
|
+
pipeline.lastRewindTarget = undefined;
|
|
1026
|
+
delete pipeline.gateResults[pipeline.failedPhase];
|
|
1027
|
+
delete pipeline.gateChecks[pipeline.failedPhase];
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
expect(pipeline.pipelinePhase).toBe('QA_VALIDATION');
|
|
1031
|
+
expect(pipeline.recoveryCount).toBe(0);
|
|
1032
|
+
expect(pipeline.lastRewindTarget).toBeUndefined();
|
|
1033
|
+
});
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
// ─── v2.4.8: RECOVERY_LOOP auto-resume ──────────────
|
|
1037
|
+
|
|
1038
|
+
describe('v2.4.8: RECOVERY_LOOP auto-resume', () => {
|
|
1039
|
+
it('should auto-resume from RECOVERY_LOOP without guidance', () => {
|
|
1040
|
+
pipeline.pipelinePhase = 'RECOVERY_LOOP';
|
|
1041
|
+
pipeline.failedPhase = 'QA_VALIDATION';
|
|
1042
|
+
pipeline.recoveryCount = 1;
|
|
1043
|
+
pipeline.maxRecoveryIterations = 5;
|
|
1044
|
+
pipeline.lastRewindTarget = 'IMPLEMENTATION';
|
|
1045
|
+
|
|
1046
|
+
// Simulate v2.4.8 resumePipeline logic: RECOVERY_LOOP with remaining attempts
|
|
1047
|
+
if (
|
|
1048
|
+
pipeline.pipelinePhase === 'RECOVERY_LOOP' &&
|
|
1049
|
+
pipeline.failedPhase &&
|
|
1050
|
+
pipeline.recoveryCount < (pipeline.maxRecoveryIterations ?? 5)
|
|
1051
|
+
) {
|
|
1052
|
+
pipeline.pipelinePhase = pipeline.failedPhase;
|
|
1053
|
+
delete pipeline.gateResults[pipeline.failedPhase];
|
|
1054
|
+
delete pipeline.gateChecks[pipeline.failedPhase];
|
|
1055
|
+
// Do NOT reset recoveryCount or lastRewindTarget
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
expect(pipeline.pipelinePhase).toBe('QA_VALIDATION');
|
|
1059
|
+
expect(pipeline.recoveryCount).toBe(1); // preserved, not reset
|
|
1060
|
+
expect(pipeline.lastRewindTarget).toBe('IMPLEMENTATION'); // preserved
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
it('should preserve recoveryCount (not reset to 0) on auto-resume', () => {
|
|
1064
|
+
pipeline.pipelinePhase = 'RECOVERY_LOOP';
|
|
1065
|
+
pipeline.failedPhase = 'QA_VALIDATION';
|
|
1066
|
+
pipeline.recoveryCount = 3;
|
|
1067
|
+
pipeline.maxRecoveryIterations = 5;
|
|
1068
|
+
|
|
1069
|
+
if (
|
|
1070
|
+
pipeline.pipelinePhase === 'RECOVERY_LOOP' &&
|
|
1071
|
+
pipeline.failedPhase &&
|
|
1072
|
+
pipeline.recoveryCount < (pipeline.maxRecoveryIterations ?? 5)
|
|
1073
|
+
) {
|
|
1074
|
+
pipeline.pipelinePhase = pipeline.failedPhase;
|
|
1075
|
+
delete pipeline.gateResults[pipeline.failedPhase];
|
|
1076
|
+
delete pipeline.gateChecks[pipeline.failedPhase];
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
expect(pipeline.recoveryCount).toBe(3);
|
|
1080
|
+
expect(pipeline.pipelinePhase).toBe('QA_VALIDATION');
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
it('should clear stale gate data on auto-resume', () => {
|
|
1084
|
+
pipeline.pipelinePhase = 'RECOVERY_LOOP';
|
|
1085
|
+
pipeline.failedPhase = 'QA_VALIDATION';
|
|
1086
|
+
pipeline.recoveryCount = 1;
|
|
1087
|
+
pipeline.maxRecoveryIterations = 5;
|
|
1088
|
+
|
|
1089
|
+
// Pre-fill gate data
|
|
1090
|
+
pipeline.gateResults['QA_VALIDATION'] = {
|
|
1091
|
+
phase: 'QA_VALIDATION',
|
|
1092
|
+
pass: false,
|
|
1093
|
+
blockers: ['test failed'],
|
|
1094
|
+
missingArtifacts: [],
|
|
1095
|
+
failedChecks: [],
|
|
1096
|
+
timestamp: new Date().toISOString(),
|
|
1097
|
+
};
|
|
1098
|
+
pipeline.gateChecks['QA_VALIDATION'] = [
|
|
1099
|
+
{ check_type: 'test', status: 'fail', command: 'npm test', exit_code: 1, duration_ms: 500, timestamp: '' },
|
|
1100
|
+
];
|
|
1101
|
+
pipeline.gateResults['IMPLEMENTATION'] = {
|
|
1102
|
+
phase: 'IMPLEMENTATION',
|
|
1103
|
+
pass: true,
|
|
1104
|
+
blockers: [],
|
|
1105
|
+
missingArtifacts: [],
|
|
1106
|
+
failedChecks: [],
|
|
1107
|
+
timestamp: new Date().toISOString(),
|
|
1108
|
+
};
|
|
1109
|
+
|
|
1110
|
+
if (
|
|
1111
|
+
pipeline.pipelinePhase === 'RECOVERY_LOOP' &&
|
|
1112
|
+
pipeline.failedPhase &&
|
|
1113
|
+
pipeline.recoveryCount < (pipeline.maxRecoveryIterations ?? 5)
|
|
1114
|
+
) {
|
|
1115
|
+
pipeline.pipelinePhase = pipeline.failedPhase;
|
|
1116
|
+
delete pipeline.gateResults[pipeline.failedPhase];
|
|
1117
|
+
delete pipeline.gateChecks[pipeline.failedPhase];
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
expect(pipeline.gateResults['QA_VALIDATION']).toBeUndefined();
|
|
1121
|
+
expect(pipeline.gateChecks['QA_VALIDATION']).toBeUndefined();
|
|
1122
|
+
expect(pipeline.gateResults['IMPLEMENTATION']).toBeDefined();
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
it('should NOT auto-resume RECOVERY_LOOP at max iterations (block like STUCK)', () => {
|
|
1126
|
+
pipeline.pipelinePhase = 'RECOVERY_LOOP';
|
|
1127
|
+
pipeline.failedPhase = 'QA_VALIDATION';
|
|
1128
|
+
pipeline.recoveryCount = 5;
|
|
1129
|
+
pipeline.maxRecoveryIterations = 5;
|
|
1130
|
+
|
|
1131
|
+
let autoResumed = false;
|
|
1132
|
+
let blockedLikeStuck = false;
|
|
1133
|
+
|
|
1134
|
+
// Simulate v2.4.8 resume logic
|
|
1135
|
+
if (
|
|
1136
|
+
pipeline.pipelinePhase === 'RECOVERY_LOOP' &&
|
|
1137
|
+
pipeline.failedPhase &&
|
|
1138
|
+
pipeline.recoveryCount < (pipeline.maxRecoveryIterations ?? 5)
|
|
1139
|
+
) {
|
|
1140
|
+
autoResumed = true;
|
|
1141
|
+
} else if (
|
|
1142
|
+
(pipeline.pipelinePhase === 'STUCK' ||
|
|
1143
|
+
(pipeline.pipelinePhase === 'RECOVERY_LOOP' &&
|
|
1144
|
+
pipeline.recoveryCount >= (pipeline.maxRecoveryIterations ?? 5))) &&
|
|
1145
|
+
pipeline.failedPhase
|
|
1146
|
+
) {
|
|
1147
|
+
const guidance = ''; // no guidance provided
|
|
1148
|
+
if (guidance.length > 0) {
|
|
1149
|
+
autoResumed = true;
|
|
1150
|
+
} else {
|
|
1151
|
+
blockedLikeStuck = true;
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
expect(autoResumed).toBe(false);
|
|
1156
|
+
expect(blockedLikeStuck).toBe(true);
|
|
1157
|
+
expect(pipeline.pipelinePhase).toBe('RECOVERY_LOOP'); // unchanged, would return error
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
it('should still require guidance for STUCK (regression guard)', () => {
|
|
1161
|
+
pipeline.pipelinePhase = 'STUCK';
|
|
1162
|
+
pipeline.failedPhase = 'QA_VALIDATION';
|
|
1163
|
+
pipeline.recoveryCount = 5;
|
|
1164
|
+
pipeline.maxRecoveryIterations = 5;
|
|
1165
|
+
|
|
1166
|
+
let autoResumed = false;
|
|
1167
|
+
let blockedLikeStuck = false;
|
|
1168
|
+
|
|
1169
|
+
// Simulate v2.4.8 resume logic
|
|
1170
|
+
if (
|
|
1171
|
+
pipeline.pipelinePhase === 'RECOVERY_LOOP' &&
|
|
1172
|
+
pipeline.failedPhase &&
|
|
1173
|
+
pipeline.recoveryCount < (pipeline.maxRecoveryIterations ?? 5)
|
|
1174
|
+
) {
|
|
1175
|
+
autoResumed = true;
|
|
1176
|
+
} else if (
|
|
1177
|
+
(pipeline.pipelinePhase === 'STUCK' ||
|
|
1178
|
+
(pipeline.pipelinePhase === 'RECOVERY_LOOP' &&
|
|
1179
|
+
pipeline.recoveryCount >= (pipeline.maxRecoveryIterations ?? 5))) &&
|
|
1180
|
+
pipeline.failedPhase
|
|
1181
|
+
) {
|
|
1182
|
+
const guidance = '';
|
|
1183
|
+
if (guidance.length > 0) {
|
|
1184
|
+
autoResumed = true;
|
|
1185
|
+
} else {
|
|
1186
|
+
blockedLikeStuck = true;
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
expect(autoResumed).toBe(false);
|
|
1191
|
+
expect(blockedLikeStuck).toBe(true);
|
|
1192
|
+
expect(pipeline.pipelinePhase).toBe('STUCK');
|
|
1193
|
+
});
|
|
1194
|
+
});
|
|
1195
|
+
|
|
1196
|
+
// ─── v2.4.9: CR resolution after QA passes ──────────────
|
|
1197
|
+
|
|
1198
|
+
describe('v2.4.9: CR resolution after QA passes', () => {
|
|
1199
|
+
it('should resolve active config CR and set baselineSnapshotOverride', () => {
|
|
1200
|
+
pipeline.pendingChangeRequests = [
|
|
1201
|
+
{ cr_id: 'CR-100', change_type: 'config', target_phase: 'QA_VALIDATION', status: 'approved', drift_key: 'dk-1' },
|
|
1202
|
+
];
|
|
1203
|
+
pipeline.activeChangeRequestId = 'CR-100';
|
|
1204
|
+
pipeline.latestRepoSnapshot = {
|
|
1205
|
+
artifact_id: 'snap-latest',
|
|
1206
|
+
path: 'docs/snapshot.json',
|
|
1207
|
+
sha256: 'abc',
|
|
1208
|
+
version: 1,
|
|
1209
|
+
type: 'repo_snapshot',
|
|
1210
|
+
};
|
|
1211
|
+
|
|
1212
|
+
resolveActiveCR(pipeline);
|
|
1213
|
+
|
|
1214
|
+
expect(pipeline.pendingChangeRequests![0].status).toBe('resolved');
|
|
1215
|
+
expect(pipeline.activeChangeRequestId).toBeUndefined();
|
|
1216
|
+
expect(pipeline.baselineSnapshotOverride).toBeDefined();
|
|
1217
|
+
expect(pipeline.baselineSnapshotOverride!.artifact_id).toBe('snap-latest');
|
|
1218
|
+
});
|
|
1219
|
+
|
|
1220
|
+
it('should resolve non-config CR and advance baseline (v2.5.1: all types advance)', () => {
|
|
1221
|
+
// v2.5.1: ALL CR types advance baseline, not just config
|
|
1222
|
+
const changeTypes = ['scope', 'architecture', 'dependency', 'requirement'] as const;
|
|
1223
|
+
for (const changeType of changeTypes) {
|
|
1224
|
+
const p = createDefaultPipelineState();
|
|
1225
|
+
p.pendingChangeRequests = [
|
|
1226
|
+
{ cr_id: `CR-${changeType}`, change_type: changeType, target_phase: 'CONSENSUS_MASTER_PLAN', status: 'approved', drift_key: `dk-${changeType}` },
|
|
1227
|
+
];
|
|
1228
|
+
p.activeChangeRequestId = `CR-${changeType}`;
|
|
1229
|
+
p.latestRepoSnapshot = {
|
|
1230
|
+
artifact_id: `snap-${changeType}`,
|
|
1231
|
+
path: 'docs/snapshot.json',
|
|
1232
|
+
sha256: 'abc',
|
|
1233
|
+
version: 1,
|
|
1234
|
+
type: 'repo_snapshot',
|
|
1235
|
+
};
|
|
1236
|
+
|
|
1237
|
+
resolveActiveCR(p);
|
|
1238
|
+
|
|
1239
|
+
expect(p.pendingChangeRequests![0].status).toBe('resolved');
|
|
1240
|
+
expect(p.activeChangeRequestId).toBeUndefined();
|
|
1241
|
+
expect(p.baselineSnapshotOverride).toBeDefined();
|
|
1242
|
+
expect(p.baselineSnapshotOverride!.artifact_id).toBe(`snap-${changeType}`);
|
|
1243
|
+
}
|
|
1244
|
+
});
|
|
1245
|
+
|
|
1246
|
+
it('should gracefully do nothing when no activeChangeRequestId', () => {
|
|
1247
|
+
pipeline.activeChangeRequestId = undefined;
|
|
1248
|
+
pipeline.pendingChangeRequests = [
|
|
1249
|
+
{ cr_id: 'CR-300', change_type: 'config', target_phase: 'QA_VALIDATION', status: 'approved' },
|
|
1250
|
+
];
|
|
1251
|
+
|
|
1252
|
+
resolveActiveCR(pipeline);
|
|
1253
|
+
|
|
1254
|
+
// Nothing changed
|
|
1255
|
+
expect(pipeline.pendingChangeRequests![0].status).toBe('approved');
|
|
1256
|
+
});
|
|
1257
|
+
});
|
|
1258
|
+
|
|
1259
|
+
// ─── v2.4.9: Stagnation detection ──────────────────────
|
|
1260
|
+
|
|
1261
|
+
describe('v2.4.9: Stagnation detection', () => {
|
|
1262
|
+
it('should detect stagnation when same signature repeats STAGNATION_THRESHOLD times', () => {
|
|
1263
|
+
// Set up identical state to produce identical signatures
|
|
1264
|
+
pipeline.pipelinePhase = 'REVIEW';
|
|
1265
|
+
pipeline.lastSignatures = [];
|
|
1266
|
+
|
|
1267
|
+
// Call checkStagnation repeatedly with identical state
|
|
1268
|
+
let stagnant = false;
|
|
1269
|
+
for (let i = 0; i < STAGNATION_THRESHOLD; i++) {
|
|
1270
|
+
stagnant = checkStagnation(pipeline);
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
expect(stagnant).toBe(true);
|
|
1274
|
+
expect(pipeline.lastSignatures!.length).toBe(STAGNATION_THRESHOLD);
|
|
1275
|
+
});
|
|
1276
|
+
|
|
1277
|
+
it('should not detect stagnation with fewer than threshold calls', () => {
|
|
1278
|
+
pipeline.pipelinePhase = 'REVIEW';
|
|
1279
|
+
pipeline.lastSignatures = [];
|
|
1280
|
+
|
|
1281
|
+
let stagnant = false;
|
|
1282
|
+
for (let i = 0; i < STAGNATION_THRESHOLD - 1; i++) {
|
|
1283
|
+
stagnant = checkStagnation(pipeline);
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
expect(stagnant).toBe(false);
|
|
1287
|
+
});
|
|
1288
|
+
|
|
1289
|
+
it('should not detect stagnation with different signatures', () => {
|
|
1290
|
+
pipeline.lastSignatures = [];
|
|
1291
|
+
|
|
1292
|
+
let stagnant = false;
|
|
1293
|
+
for (let i = 0; i < STAGNATION_THRESHOLD; i++) {
|
|
1294
|
+
pipeline.pipelinePhase = (['REVIEW', 'QA_VALIDATION', 'AUDIT'] as const)[i % 3];
|
|
1295
|
+
stagnant = checkStagnation(pipeline);
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
expect(stagnant).toBe(false);
|
|
1299
|
+
});
|
|
1300
|
+
|
|
1301
|
+
it('should transition to STUCK on stagnation', () => {
|
|
1302
|
+
pipeline.pipelinePhase = 'REVIEW';
|
|
1303
|
+
pipeline.lastSignatures = [];
|
|
1304
|
+
|
|
1305
|
+
// Simulate the orchestrator's stagnation → STUCK transition
|
|
1306
|
+
let phase = pipeline.pipelinePhase;
|
|
1307
|
+
for (let i = 0; i < STAGNATION_THRESHOLD; i++) {
|
|
1308
|
+
if (checkStagnation(pipeline)) {
|
|
1309
|
+
phase = 'STUCK';
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
expect(phase).toBe('STUCK');
|
|
1314
|
+
});
|
|
1315
|
+
|
|
1316
|
+
it('should produce a 16-char hex signature', () => {
|
|
1317
|
+
pipeline.pipelinePhase = 'REVIEW';
|
|
1318
|
+
const sig = computeLoopSignature(pipeline);
|
|
1319
|
+
expect(sig).toMatch(/^[0-9a-f]{16}$/);
|
|
1320
|
+
});
|
|
1321
|
+
|
|
1322
|
+
it('should produce same signature regardless of pending CR count (uses boolean)', () => {
|
|
1323
|
+
pipeline.pipelinePhase = 'REVIEW';
|
|
1324
|
+
|
|
1325
|
+
// 1 pending CR
|
|
1326
|
+
pipeline.pendingChangeRequests = [
|
|
1327
|
+
{ cr_id: 'CR-1', change_type: 'config', target_phase: 'QA_VALIDATION', status: 'proposed' },
|
|
1328
|
+
];
|
|
1329
|
+
const sig1 = computeLoopSignature(pipeline);
|
|
1330
|
+
|
|
1331
|
+
// 5 pending CRs — signature should be the same (boolean, not count)
|
|
1332
|
+
pipeline.pendingChangeRequests = [
|
|
1333
|
+
{ cr_id: 'CR-1', change_type: 'config', target_phase: 'QA_VALIDATION', status: 'proposed' },
|
|
1334
|
+
{ cr_id: 'CR-2', change_type: 'config', target_phase: 'QA_VALIDATION', status: 'approved' },
|
|
1335
|
+
{ cr_id: 'CR-3', change_type: 'config', target_phase: 'QA_VALIDATION', status: 'proposed' },
|
|
1336
|
+
{ cr_id: 'CR-4', change_type: 'config', target_phase: 'QA_VALIDATION', status: 'approved' },
|
|
1337
|
+
{ cr_id: 'CR-5', change_type: 'config', target_phase: 'QA_VALIDATION', status: 'proposed' },
|
|
1338
|
+
];
|
|
1339
|
+
const sig5 = computeLoopSignature(pipeline);
|
|
1340
|
+
|
|
1341
|
+
expect(sig1).toBe(sig5);
|
|
1342
|
+
});
|
|
1343
|
+
});
|
|
1344
|
+
|
|
1345
|
+
// ─── v2.4.9: activeChangeRequestId tracking ──────────────
|
|
1346
|
+
|
|
1347
|
+
describe('v2.4.9: activeChangeRequestId tracking', () => {
|
|
1348
|
+
it('should set activeChangeRequestId when routing CR', () => {
|
|
1349
|
+
pipeline.pendingChangeRequests = [
|
|
1350
|
+
{ cr_id: 'CR-TRACK', change_type: 'config', target_phase: 'QA_VALIDATION', status: 'proposed' },
|
|
1351
|
+
];
|
|
1352
|
+
|
|
1353
|
+
// Simulate getNextCRRoute + activeChangeRequestId assignment
|
|
1354
|
+
const nextCR = pipeline.pendingChangeRequests.find((cr) => cr.status === 'proposed');
|
|
1355
|
+
expect(nextCR).toBeDefined();
|
|
1356
|
+
nextCR!.status = 'approved';
|
|
1357
|
+
pipeline.activeChangeRequestId = nextCR!.cr_id;
|
|
1358
|
+
|
|
1359
|
+
expect(pipeline.activeChangeRequestId).toBe('CR-TRACK');
|
|
1360
|
+
expect(nextCR!.status).toBe('approved');
|
|
1361
|
+
});
|
|
1362
|
+
|
|
1363
|
+
it('should clear activeChangeRequestId on resolution', () => {
|
|
1364
|
+
pipeline.pendingChangeRequests = [
|
|
1365
|
+
{ cr_id: 'CR-CLEAR', change_type: 'scope', target_phase: 'CONSENSUS_MASTER_PLAN', status: 'approved', drift_key: 'dk-x' },
|
|
1366
|
+
];
|
|
1367
|
+
pipeline.activeChangeRequestId = 'CR-CLEAR';
|
|
1368
|
+
|
|
1369
|
+
resolveActiveCR(pipeline);
|
|
1370
|
+
|
|
1371
|
+
expect(pipeline.activeChangeRequestId).toBeUndefined();
|
|
1372
|
+
expect(pipeline.pendingChangeRequests![0].status).toBe('resolved');
|
|
1373
|
+
});
|
|
1374
|
+
});
|
|
1375
|
+
|
|
1376
|
+
// ─── v2.5.1: Scope drift guard (first pass vs revision) ──────
|
|
1377
|
+
|
|
1378
|
+
describe('v2.5.1: Scope drift guard', () => {
|
|
1379
|
+
it('should NOT create scope CR on first REVIEW (no baselineSnapshotOverride)', () => {
|
|
1380
|
+
// Simulate: first REVIEW pass, baseline is CONSENSUS_ROLE_PLANS, large line delta
|
|
1381
|
+
pipeline.baselineSnapshotOverride = undefined;
|
|
1382
|
+
const isRevisionComparison = !!pipeline.baselineSnapshotOverride;
|
|
1383
|
+
const linesDelta = 5000; // huge, but expected from implementation
|
|
1384
|
+
|
|
1385
|
+
// The guard in review.ts: `if (isRevisionComparison && Math.abs(diff.lines_delta) > 1000)`
|
|
1386
|
+
const shouldCreateScopeCR = isRevisionComparison && Math.abs(linesDelta) > 1000;
|
|
1387
|
+
|
|
1388
|
+
expect(isRevisionComparison).toBe(false);
|
|
1389
|
+
expect(shouldCreateScopeCR).toBe(false);
|
|
1390
|
+
});
|
|
1391
|
+
|
|
1392
|
+
it('should create scope CR on revision pass (baselineSnapshotOverride set)', () => {
|
|
1393
|
+
// Simulate: subsequent REVIEW, baseline override set, large line delta
|
|
1394
|
+
pipeline.baselineSnapshotOverride = {
|
|
1395
|
+
artifact_id: 'snap-override',
|
|
1396
|
+
path: 'docs/snapshot.json',
|
|
1397
|
+
sha256: 'abc',
|
|
1398
|
+
version: 1,
|
|
1399
|
+
type: 'repo_snapshot',
|
|
1400
|
+
};
|
|
1401
|
+
const isRevisionComparison = !!pipeline.baselineSnapshotOverride;
|
|
1402
|
+
const linesDelta = 2000;
|
|
1403
|
+
|
|
1404
|
+
const shouldCreateScopeCR = isRevisionComparison && Math.abs(linesDelta) > 1000;
|
|
1405
|
+
|
|
1406
|
+
expect(isRevisionComparison).toBe(true);
|
|
1407
|
+
expect(shouldCreateScopeCR).toBe(true);
|
|
1408
|
+
});
|
|
1409
|
+
|
|
1410
|
+
it('should NOT create scope CR on revision pass if lines_delta <= 1000', () => {
|
|
1411
|
+
pipeline.baselineSnapshotOverride = {
|
|
1412
|
+
artifact_id: 'snap-override',
|
|
1413
|
+
path: 'docs/snapshot.json',
|
|
1414
|
+
sha256: 'abc',
|
|
1415
|
+
version: 1,
|
|
1416
|
+
type: 'repo_snapshot',
|
|
1417
|
+
};
|
|
1418
|
+
const isRevisionComparison = !!pipeline.baselineSnapshotOverride;
|
|
1419
|
+
const linesDelta = 500;
|
|
1420
|
+
|
|
1421
|
+
const shouldCreateScopeCR = isRevisionComparison && Math.abs(linesDelta) > 1000;
|
|
1422
|
+
|
|
1423
|
+
expect(isRevisionComparison).toBe(true);
|
|
1424
|
+
expect(shouldCreateScopeCR).toBe(false);
|
|
1425
|
+
});
|
|
1426
|
+
});
|
|
1427
|
+
|
|
1428
|
+
// ─── v2.5.4: Recovery budget reset on phase change ──────
|
|
1429
|
+
|
|
1430
|
+
describe('v2.5.4: Recovery budget reset on phase change', () => {
|
|
1431
|
+
it('should reset recoveryCount when advancing to next phase', () => {
|
|
1432
|
+
// CONSENSUS_ROLE_PLANS fails 4 times (consuming budget), then passes.
|
|
1433
|
+
// When it advances to IMPLEMENTATION, budget should reset.
|
|
1434
|
+
let consensusAttempts = 0;
|
|
1435
|
+
const result = simulateOrchestratorLoop(
|
|
1436
|
+
'CONSENSUS_ROLE_PLANS',
|
|
1437
|
+
pipeline,
|
|
1438
|
+
(phase, attempt) => {
|
|
1439
|
+
if (phase === 'CONSENSUS_ROLE_PLANS') {
|
|
1440
|
+
consensusAttempts++;
|
|
1441
|
+
return consensusAttempts >= 5; // Pass on 5th attempt
|
|
1442
|
+
}
|
|
1443
|
+
if (phase === 'RECOVERY_LOOP') return true;
|
|
1444
|
+
// After advancing, fail IMPLEMENTATION once to verify budget was reset
|
|
1445
|
+
if (phase === 'IMPLEMENTATION') return attempt > 1;
|
|
1446
|
+
return true;
|
|
1447
|
+
},
|
|
1448
|
+
);
|
|
1449
|
+
|
|
1450
|
+
// Should reach DONE, not STUCK
|
|
1451
|
+
expect(result.finalPhase).toBe('DONE');
|
|
1452
|
+
// recoveryCount should be 1 (from IMPLEMENTATION's single failure), not 5
|
|
1453
|
+
expect(result.recoveryCount).toBeLessThanOrEqual(1);
|
|
1454
|
+
// Verify progress log shows budget resets
|
|
1455
|
+
expect(result.progressLog.some(m => m.includes('Recovery budget reset'))).toBe(true);
|
|
1456
|
+
});
|
|
1457
|
+
|
|
1458
|
+
it('should reset recoveryCount when CR routes to a new phase', () => {
|
|
1459
|
+
// REVIEW passes with pending CR routing to CONSENSUS_ARCHITECTURE
|
|
1460
|
+
// Pipeline had previous recovery attempts — budget should reset on CR route
|
|
1461
|
+
pipeline.recoveryCount = 3;
|
|
1462
|
+
pipeline.lastRewindTarget = 'IMPLEMENTATION';
|
|
1463
|
+
|
|
1464
|
+
const result = simulateOrchestratorLoop(
|
|
1465
|
+
'REVIEW',
|
|
1466
|
+
pipeline,
|
|
1467
|
+
new Map<PipelinePhase, boolean>([
|
|
1468
|
+
['REVIEW', true],
|
|
1469
|
+
['CONSENSUS_ARCHITECTURE', true],
|
|
1470
|
+
['ROLE_PLANNING', true],
|
|
1471
|
+
['CONSENSUS_ROLE_PLANS', true],
|
|
1472
|
+
['IMPLEMENTATION', true],
|
|
1473
|
+
['QA_VALIDATION', true],
|
|
1474
|
+
['AUDIT', true],
|
|
1475
|
+
['PRODUCTION_GATE', true],
|
|
1476
|
+
]),
|
|
1477
|
+
{
|
|
1478
|
+
pendingCRs: [
|
|
1479
|
+
{ cr_id: 'CR-ARCH', target_phase: 'CONSENSUS_ARCHITECTURE' as PipelinePhase, status: 'proposed' },
|
|
1480
|
+
],
|
|
1481
|
+
},
|
|
1482
|
+
);
|
|
1483
|
+
|
|
1484
|
+
expect(result.finalPhase).toBe('DONE');
|
|
1485
|
+
expect(result.recoveryCount).toBe(0);
|
|
1486
|
+
// Verify CR routing triggered budget reset
|
|
1487
|
+
expect(result.progressLog.some(m => m.includes('CR routing'))).toBe(true);
|
|
1488
|
+
});
|
|
1489
|
+
|
|
1490
|
+
it('should NOT reset recoveryCount if phase does not change (RECOVERY_LOOP rewind)', () => {
|
|
1491
|
+
// Simulate: QA fails, RECOVERY_LOOP succeeds, rewinds back to QA
|
|
1492
|
+
// Budget should NOT reset because QA→RECOVERY→QA is not forward progress
|
|
1493
|
+
let qaAttempts = 0;
|
|
1494
|
+
const result = simulateOrchestratorLoop(
|
|
1495
|
+
'QA_VALIDATION',
|
|
1496
|
+
pipeline,
|
|
1497
|
+
(phase, _attempt) => {
|
|
1498
|
+
if (phase === 'QA_VALIDATION') {
|
|
1499
|
+
qaAttempts++;
|
|
1500
|
+
return qaAttempts >= 2; // Fail first, pass second
|
|
1501
|
+
}
|
|
1502
|
+
if (phase === 'RECOVERY_LOOP') return true;
|
|
1503
|
+
return true;
|
|
1504
|
+
},
|
|
1505
|
+
);
|
|
1506
|
+
|
|
1507
|
+
expect(result.finalPhase).toBe('DONE');
|
|
1508
|
+
// recoveryCount should be 1 (from the QA failure), then reset to 0 when
|
|
1509
|
+
// QA passes and advances to REVIEW
|
|
1510
|
+
expect(result.progressLog.some(m => m.includes('advancing QA_VALIDATION -> REVIEW'))).toBe(true);
|
|
1511
|
+
});
|
|
1512
|
+
|
|
1513
|
+
it('should reproduce Gateco scenario: CONSENSUS_ROLE_PLANS exhausts budget, CONSENSUS_ARCHITECTURE gets fresh budget', () => {
|
|
1514
|
+
// Gateco regression guard: CONSENSUS_ROLE_PLANS consumes max iterations,
|
|
1515
|
+
// passes (via arbitration), pipeline advances through IMPL/QA/REVIEW/AUDIT,
|
|
1516
|
+
// CR routes to CONSENSUS_ARCHITECTURE, gate fails
|
|
1517
|
+
// → should enter RECOVERY_LOOP (not STUCK)
|
|
1518
|
+
pipeline.maxRecoveryIterations = 5;
|
|
1519
|
+
|
|
1520
|
+
let consensusRolePlansAttempts = 0;
|
|
1521
|
+
let consensusArchAttempts = 0;
|
|
1522
|
+
|
|
1523
|
+
const result = simulateOrchestratorLoop(
|
|
1524
|
+
'CONSENSUS_ROLE_PLANS',
|
|
1525
|
+
pipeline,
|
|
1526
|
+
(phase, _attempt) => {
|
|
1527
|
+
if (phase === 'CONSENSUS_ROLE_PLANS') {
|
|
1528
|
+
consensusRolePlansAttempts++;
|
|
1529
|
+
// Fail 4 times, pass on 5th (via arbitration)
|
|
1530
|
+
return consensusRolePlansAttempts >= 5;
|
|
1531
|
+
}
|
|
1532
|
+
if (phase === 'CONSENSUS_ARCHITECTURE') {
|
|
1533
|
+
consensusArchAttempts++;
|
|
1534
|
+
// Fail first time, pass on 2nd
|
|
1535
|
+
return consensusArchAttempts >= 2;
|
|
1536
|
+
}
|
|
1537
|
+
if (phase === 'RECOVERY_LOOP') return true;
|
|
1538
|
+
return true; // IMPL, QA, REVIEW, AUDIT all pass
|
|
1539
|
+
},
|
|
1540
|
+
{
|
|
1541
|
+
// After AUDIT, CR routes to CONSENSUS_ARCHITECTURE
|
|
1542
|
+
pendingCRs: [
|
|
1543
|
+
{ cr_id: 'CR-GATECO', target_phase: 'CONSENSUS_ARCHITECTURE' as PipelinePhase, status: 'proposed' },
|
|
1544
|
+
],
|
|
1545
|
+
},
|
|
1546
|
+
);
|
|
1547
|
+
|
|
1548
|
+
// Key assertion: pipeline should reach DONE, NOT STUCK
|
|
1549
|
+
// Without v2.5.4 fix, CONSENSUS_ARCHITECTURE would immediately STUCK
|
|
1550
|
+
// because recoveryCount(5) >= maxRecoveryIterations(5)
|
|
1551
|
+
expect(result.finalPhase).toBe('DONE');
|
|
1552
|
+
// Budget should have been reset when advancing past CONSENSUS_ROLE_PLANS
|
|
1553
|
+
expect(result.phaseLog).toContain('RECOVERY_LOOP');
|
|
1554
|
+
expect(result.phaseLog).not.toContain('STUCK');
|
|
1555
|
+
});
|
|
1556
|
+
|
|
1557
|
+
it('should NOT reset budget when same phase loops back to itself', () => {
|
|
1558
|
+
// Edge case: phase passes but getNextPhase returns same phase (shouldn't happen
|
|
1559
|
+
// in practice, but verifies the !== guard)
|
|
1560
|
+
pipeline.recoveryCount = 3;
|
|
1561
|
+
|
|
1562
|
+
// Manually test the condition
|
|
1563
|
+
const prevPhase: PipelinePhase = 'REVIEW';
|
|
1564
|
+
const nextPhase: PipelinePhase = 'REVIEW'; // hypothetical same-phase transition
|
|
1565
|
+
let budgetReset = false;
|
|
1566
|
+
|
|
1567
|
+
if (nextPhase !== prevPhase && pipeline.recoveryCount > 0) {
|
|
1568
|
+
pipeline.recoveryCount = 0;
|
|
1569
|
+
pipeline.lastRewindTarget = undefined;
|
|
1570
|
+
budgetReset = true;
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
expect(budgetReset).toBe(false);
|
|
1574
|
+
expect(pipeline.recoveryCount).toBe(3); // unchanged
|
|
1575
|
+
});
|
|
1576
|
+
|
|
1577
|
+
it('should clear lastRewindTarget when budget resets on forward progress', () => {
|
|
1578
|
+
pipeline.recoveryCount = 2;
|
|
1579
|
+
pipeline.lastRewindTarget = 'IMPLEMENTATION';
|
|
1580
|
+
|
|
1581
|
+
// Simulate forward phase change
|
|
1582
|
+
const prevPhase: PipelinePhase = 'QA_VALIDATION';
|
|
1583
|
+
const nextPhase: PipelinePhase = 'REVIEW';
|
|
1584
|
+
|
|
1585
|
+
if (nextPhase !== prevPhase && pipeline.recoveryCount > 0) {
|
|
1586
|
+
pipeline.recoveryCount = 0;
|
|
1587
|
+
pipeline.lastRewindTarget = undefined;
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
expect(pipeline.recoveryCount).toBe(0);
|
|
1591
|
+
expect(pipeline.lastRewindTarget).toBeUndefined();
|
|
1592
|
+
});
|
|
1593
|
+
});
|
|
1594
|
+
|
|
1595
|
+
// ─── v2.6.0: Auto-recovery from STUCK via arbitrator ──────
|
|
1596
|
+
|
|
1597
|
+
describe('v2.6.0: Auto-recovery from STUCK via arbitrator', () => {
|
|
1598
|
+
it('should attempt auto-recovery when budget exhausted and no prior attempt', () => {
|
|
1599
|
+
pipeline.maxRecoveryIterations = 5;
|
|
1600
|
+
pipeline.recoveryCount = 5;
|
|
1601
|
+
pipeline.failedPhase = 'QA_VALIDATION';
|
|
1602
|
+
pipeline.autoRecoveryResult = undefined;
|
|
1603
|
+
|
|
1604
|
+
const exhaustedPhase: PipelinePhase = 'QA_VALIDATION';
|
|
1605
|
+
const arbitratorConfigured = true;
|
|
1606
|
+
|
|
1607
|
+
// Simulate: budget exhausted + no prior attempt + arbitrator configured
|
|
1608
|
+
let phase: PipelinePhase = exhaustedPhase;
|
|
1609
|
+
let autoRecoveryAttempted = false;
|
|
1610
|
+
|
|
1611
|
+
if (pipeline.recoveryCount >= pipeline.maxRecoveryIterations) {
|
|
1612
|
+
if (!pipeline.autoRecoveryResult && arbitratorConfigured) {
|
|
1613
|
+
// Auto-recovery would be attempted here
|
|
1614
|
+
autoRecoveryAttempted = true;
|
|
1615
|
+
|
|
1616
|
+
// Simulate successful auto-recovery
|
|
1617
|
+
pipeline.autoRecoveryResult = 'success';
|
|
1618
|
+
pipeline.recoveryCount = 0;
|
|
1619
|
+
pipeline.lastRewindTarget = undefined;
|
|
1620
|
+
phase = exhaustedPhase;
|
|
1621
|
+
pipeline.pipelinePhase = phase;
|
|
1622
|
+
pipeline.failedPhase = exhaustedPhase;
|
|
1623
|
+
delete pipeline.gateResults[exhaustedPhase];
|
|
1624
|
+
delete pipeline.gateChecks[exhaustedPhase];
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
expect(autoRecoveryAttempted).toBe(true);
|
|
1629
|
+
expect(pipeline.recoveryCount).toBe(0);
|
|
1630
|
+
expect(pipeline.autoRecoveryResult).toBe('success');
|
|
1631
|
+
expect(phase).toBe('QA_VALIDATION');
|
|
1632
|
+
});
|
|
1633
|
+
|
|
1634
|
+
it('should only attempt auto-recovery once (autoRecoveryResult already set)', () => {
|
|
1635
|
+
pipeline.maxRecoveryIterations = 5;
|
|
1636
|
+
pipeline.recoveryCount = 5;
|
|
1637
|
+
pipeline.failedPhase = 'QA_VALIDATION';
|
|
1638
|
+
pipeline.autoRecoveryResult = 'invalid'; // already attempted
|
|
1639
|
+
|
|
1640
|
+
const exhaustedPhase: PipelinePhase = 'QA_VALIDATION';
|
|
1641
|
+
const arbitratorConfigured = true;
|
|
1642
|
+
|
|
1643
|
+
let phase: PipelinePhase = exhaustedPhase;
|
|
1644
|
+
let autoRecoveryAttempted = false;
|
|
1645
|
+
|
|
1646
|
+
if (pipeline.recoveryCount >= pipeline.maxRecoveryIterations) {
|
|
1647
|
+
if (!pipeline.autoRecoveryResult && arbitratorConfigured) {
|
|
1648
|
+
autoRecoveryAttempted = true;
|
|
1649
|
+
} else {
|
|
1650
|
+
phase = 'STUCK';
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
expect(autoRecoveryAttempted).toBe(false);
|
|
1655
|
+
expect(phase).toBe('STUCK');
|
|
1656
|
+
});
|
|
1657
|
+
|
|
1658
|
+
it('should not attempt auto-recovery when no arbitrator configured', () => {
|
|
1659
|
+
pipeline.maxRecoveryIterations = 5;
|
|
1660
|
+
pipeline.recoveryCount = 5;
|
|
1661
|
+
pipeline.failedPhase = 'QA_VALIDATION';
|
|
1662
|
+
pipeline.autoRecoveryResult = undefined;
|
|
1663
|
+
|
|
1664
|
+
const exhaustedPhase: PipelinePhase = 'QA_VALIDATION';
|
|
1665
|
+
const arbitratorConfigured = false; // no arbitrator
|
|
1666
|
+
|
|
1667
|
+
let phase: PipelinePhase = exhaustedPhase;
|
|
1668
|
+
|
|
1669
|
+
if (pipeline.recoveryCount >= pipeline.maxRecoveryIterations) {
|
|
1670
|
+
if (!pipeline.autoRecoveryResult && arbitratorConfigured) {
|
|
1671
|
+
// Would attempt auto-recovery
|
|
1672
|
+
} else {
|
|
1673
|
+
phase = 'STUCK';
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
expect(phase).toBe('STUCK');
|
|
1678
|
+
});
|
|
1679
|
+
|
|
1680
|
+
it('should not increment recoveryCount after successful auto-recovery', () => {
|
|
1681
|
+
pipeline.maxRecoveryIterations = 5;
|
|
1682
|
+
pipeline.recoveryCount = 5;
|
|
1683
|
+
pipeline.failedPhase = 'QA_VALIDATION';
|
|
1684
|
+
pipeline.autoRecoveryResult = undefined;
|
|
1685
|
+
|
|
1686
|
+
// Simulate successful auto-recovery
|
|
1687
|
+
pipeline.autoRecoveryResult = 'success';
|
|
1688
|
+
pipeline.recoveryCount = 0;
|
|
1689
|
+
|
|
1690
|
+
// After auto-recovery, recoveryCount should be 0 (not 1)
|
|
1691
|
+
expect(pipeline.recoveryCount).toBe(0);
|
|
1692
|
+
});
|
|
1693
|
+
|
|
1694
|
+
it('should reset autoRecoveryResult on user-guided resume from STUCK', () => {
|
|
1695
|
+
pipeline.pipelinePhase = 'STUCK';
|
|
1696
|
+
pipeline.failedPhase = 'QA_VALIDATION';
|
|
1697
|
+
pipeline.recoveryCount = 5;
|
|
1698
|
+
pipeline.autoRecoveryResult = 'success'; // from prior auto-recovery
|
|
1699
|
+
|
|
1700
|
+
const guidance = 'User provided new guidance';
|
|
1701
|
+
|
|
1702
|
+
// Simulate resumePipeline STUCK-with-guidance logic (v2.6.0)
|
|
1703
|
+
if (pipeline.pipelinePhase === 'STUCK' && pipeline.failedPhase && guidance.length > 0) {
|
|
1704
|
+
pipeline.pipelinePhase = pipeline.failedPhase;
|
|
1705
|
+
pipeline.recoveryCount = 0;
|
|
1706
|
+
pipeline.lastRewindTarget = undefined;
|
|
1707
|
+
pipeline.autoRecoveryResult = undefined; // v2.6.0
|
|
1708
|
+
delete pipeline.gateResults[pipeline.failedPhase];
|
|
1709
|
+
delete pipeline.gateChecks[pipeline.failedPhase];
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
expect(pipeline.autoRecoveryResult).toBeUndefined();
|
|
1713
|
+
expect(pipeline.pipelinePhase).toBe('QA_VALIDATION');
|
|
1714
|
+
expect(pipeline.recoveryCount).toBe(0);
|
|
1715
|
+
});
|
|
1716
|
+
|
|
1717
|
+
it('should preserve exhaustedPhase correctly on auto-recovery', () => {
|
|
1718
|
+
pipeline.maxRecoveryIterations = 5;
|
|
1719
|
+
pipeline.recoveryCount = 5;
|
|
1720
|
+
pipeline.failedPhase = 'CONSENSUS_ROLE_PLANS';
|
|
1721
|
+
|
|
1722
|
+
const exhaustedPhase: PipelinePhase = 'CONSENSUS_ROLE_PLANS';
|
|
1723
|
+
|
|
1724
|
+
// Simulate auto-recovery: capture exhaustedPhase before reassignment
|
|
1725
|
+
let phase: PipelinePhase = exhaustedPhase;
|
|
1726
|
+
pipeline.autoRecoveryResult = 'success';
|
|
1727
|
+
pipeline.recoveryCount = 0;
|
|
1728
|
+
phase = exhaustedPhase;
|
|
1729
|
+
pipeline.pipelinePhase = phase;
|
|
1730
|
+
pipeline.failedPhase = exhaustedPhase;
|
|
1731
|
+
|
|
1732
|
+
expect(phase).toBe('CONSENSUS_ROLE_PLANS');
|
|
1733
|
+
expect(pipeline.failedPhase).toBe('CONSENSUS_ROLE_PLANS');
|
|
1734
|
+
expect(pipeline.pipelinePhase).toBe('CONSENSUS_ROLE_PLANS');
|
|
1735
|
+
});
|
|
1736
|
+
|
|
1737
|
+
it('should clear stale gate data on auto-recovery success', () => {
|
|
1738
|
+
pipeline.maxRecoveryIterations = 5;
|
|
1739
|
+
pipeline.recoveryCount = 5;
|
|
1740
|
+
pipeline.failedPhase = 'QA_VALIDATION';
|
|
1741
|
+
|
|
1742
|
+
// Pre-fill gate data
|
|
1743
|
+
pipeline.gateResults['QA_VALIDATION'] = {
|
|
1744
|
+
phase: 'QA_VALIDATION',
|
|
1745
|
+
pass: false,
|
|
1746
|
+
blockers: ['test failed'],
|
|
1747
|
+
missingArtifacts: [],
|
|
1748
|
+
failedChecks: [],
|
|
1749
|
+
timestamp: new Date().toISOString(),
|
|
1750
|
+
};
|
|
1751
|
+
pipeline.gateChecks['QA_VALIDATION'] = [
|
|
1752
|
+
{ check_type: 'test', status: 'fail', command: 'npm test', exit_code: 1, duration_ms: 500, timestamp: '' },
|
|
1753
|
+
];
|
|
1754
|
+
|
|
1755
|
+
// Keep other phases' data
|
|
1756
|
+
pipeline.gateResults['IMPLEMENTATION'] = {
|
|
1757
|
+
phase: 'IMPLEMENTATION',
|
|
1758
|
+
pass: true,
|
|
1759
|
+
blockers: [],
|
|
1760
|
+
missingArtifacts: [],
|
|
1761
|
+
failedChecks: [],
|
|
1762
|
+
timestamp: new Date().toISOString(),
|
|
1763
|
+
};
|
|
1764
|
+
|
|
1765
|
+
// Simulate auto-recovery success: clear stale gate data
|
|
1766
|
+
const exhaustedPhase = pipeline.failedPhase!;
|
|
1767
|
+
delete pipeline.gateResults[exhaustedPhase];
|
|
1768
|
+
delete pipeline.gateChecks[exhaustedPhase];
|
|
1769
|
+
|
|
1770
|
+
expect(pipeline.gateResults['QA_VALIDATION']).toBeUndefined();
|
|
1771
|
+
expect(pipeline.gateChecks['QA_VALIDATION']).toBeUndefined();
|
|
1772
|
+
expect(pipeline.gateResults['IMPLEMENTATION']).toBeDefined();
|
|
1773
|
+
});
|
|
1774
|
+
|
|
1775
|
+
it('should attempt auto-recovery on resume from STUCK without guidance', () => {
|
|
1776
|
+
// v2.6.0: resumePipeline should try auto-recovery when no guidance is provided
|
|
1777
|
+
pipeline.pipelinePhase = 'STUCK';
|
|
1778
|
+
pipeline.failedPhase = 'CONSENSUS_ARCHITECTURE';
|
|
1779
|
+
pipeline.recoveryCount = 5;
|
|
1780
|
+
pipeline.autoRecoveryResult = undefined;
|
|
1781
|
+
|
|
1782
|
+
const arbitratorConfigured = true;
|
|
1783
|
+
const guidance = ''; // no guidance from user
|
|
1784
|
+
|
|
1785
|
+
let autoRecoveryAttempted = false;
|
|
1786
|
+
let finalState: 'running' | 'stuck' = 'stuck';
|
|
1787
|
+
|
|
1788
|
+
// Simulate the resume logic for no-guidance path
|
|
1789
|
+
if (guidance.length === 0 && !pipeline.autoRecoveryResult && arbitratorConfigured) {
|
|
1790
|
+
autoRecoveryAttempted = true;
|
|
1791
|
+
|
|
1792
|
+
// Simulate successful auto-recovery
|
|
1793
|
+
pipeline.autoRecoveryResult = 'success';
|
|
1794
|
+
pipeline.pipelinePhase = pipeline.failedPhase;
|
|
1795
|
+
pipeline.recoveryCount = 0;
|
|
1796
|
+
pipeline.lastRewindTarget = undefined;
|
|
1797
|
+
delete pipeline.gateResults[pipeline.failedPhase];
|
|
1798
|
+
delete pipeline.gateChecks[pipeline.failedPhase];
|
|
1799
|
+
finalState = 'running';
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
expect(autoRecoveryAttempted).toBe(true);
|
|
1803
|
+
expect(finalState).toBe('running');
|
|
1804
|
+
expect(pipeline.pipelinePhase).toBe('CONSENSUS_ARCHITECTURE');
|
|
1805
|
+
expect(pipeline.recoveryCount).toBe(0);
|
|
1806
|
+
expect(pipeline.autoRecoveryResult).toBe('success');
|
|
1807
|
+
});
|
|
1808
|
+
|
|
1809
|
+
it('should skip auto-recovery on resume if already attempted', () => {
|
|
1810
|
+
pipeline.pipelinePhase = 'STUCK';
|
|
1811
|
+
pipeline.failedPhase = 'CONSENSUS_ARCHITECTURE';
|
|
1812
|
+
pipeline.recoveryCount = 5;
|
|
1813
|
+
pipeline.autoRecoveryResult = 'invalid'; // already tried
|
|
1814
|
+
|
|
1815
|
+
const arbitratorConfigured = true;
|
|
1816
|
+
const guidance = '';
|
|
1817
|
+
|
|
1818
|
+
let autoRecoveryAttempted = false;
|
|
1819
|
+
|
|
1820
|
+
if (guidance.length === 0 && !pipeline.autoRecoveryResult && arbitratorConfigured) {
|
|
1821
|
+
autoRecoveryAttempted = true;
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
expect(autoRecoveryAttempted).toBe(false);
|
|
1825
|
+
expect(pipeline.pipelinePhase).toBe('STUCK'); // unchanged
|
|
1826
|
+
});
|
|
1827
|
+
});
|
|
1828
|
+
|
|
1829
|
+
// ─── v2.7.0: Recovery budget guard (infinite loop fix) ──────
|
|
1830
|
+
|
|
1831
|
+
describe('v2.7.0: Recovery budget guard (infinite loop fix)', () => {
|
|
1832
|
+
it('should preserve recoveryCount during recovery traversal (no budget reset)', () => {
|
|
1833
|
+
// PRODUCTION_GATE fails → recovery rewinds to IMPLEMENTATION → traverses forward
|
|
1834
|
+
// Budget must NOT reset during IMPL→QA→REVIEW→AUDIT traversal
|
|
1835
|
+
pipeline.maxRecoveryIterations = 5;
|
|
1836
|
+
|
|
1837
|
+
let prodGateAttempts = 0;
|
|
1838
|
+
const result = simulateOrchestratorLoop(
|
|
1839
|
+
'PRODUCTION_GATE',
|
|
1840
|
+
pipeline,
|
|
1841
|
+
(phase, _attempt) => {
|
|
1842
|
+
if (phase === 'PRODUCTION_GATE') {
|
|
1843
|
+
prodGateAttempts++;
|
|
1844
|
+
return prodGateAttempts >= 2; // Fail first, pass second
|
|
1845
|
+
}
|
|
1846
|
+
if (phase === 'RECOVERY_LOOP') return true;
|
|
1847
|
+
// All traversal phases pass (IMPLEMENTATION→QA→REVIEW→AUDIT)
|
|
1848
|
+
return true;
|
|
1849
|
+
},
|
|
1850
|
+
);
|
|
1851
|
+
|
|
1852
|
+
expect(result.finalPhase).toBe('DONE');
|
|
1853
|
+
// Budget should have been 1 when PRODUCTION_GATE was re-entered (not reset to 0)
|
|
1854
|
+
// After PRODUCTION_GATE passes, failedPhase cleared, then budget resets on advance to DONE
|
|
1855
|
+
expect(result.progressLog.every(m => !m.includes('advancing IMPLEMENTATION'))).toBe(true);
|
|
1856
|
+
});
|
|
1857
|
+
|
|
1858
|
+
it('should reach STUCK when PRODUCTION_GATE keeps failing (no infinite loop)', () => {
|
|
1859
|
+
// The core infinite loop scenario: PRODUCTION_GATE always fails,
|
|
1860
|
+
// recovery always succeeds and rewinds to IMPLEMENTATION
|
|
1861
|
+
pipeline.maxRecoveryIterations = 3;
|
|
1862
|
+
|
|
1863
|
+
const result = simulateOrchestratorLoop(
|
|
1864
|
+
'PRODUCTION_GATE',
|
|
1865
|
+
pipeline,
|
|
1866
|
+
(phase, _attempt) => {
|
|
1867
|
+
if (phase === 'PRODUCTION_GATE') return false; // Always fails
|
|
1868
|
+
if (phase === 'RECOVERY_LOOP') return true;
|
|
1869
|
+
return true; // Traversal phases pass
|
|
1870
|
+
},
|
|
1871
|
+
{ maxIterations: 100 },
|
|
1872
|
+
);
|
|
1873
|
+
|
|
1874
|
+
expect(result.finalPhase).toBe('STUCK');
|
|
1875
|
+
expect(result.recoveryCount).toBe(3);
|
|
1876
|
+
// Should NOT have looped more than ~20 iterations (3 recovery cycles * ~6 phases)
|
|
1877
|
+
expect(result.phaseLog.length).toBeLessThan(30);
|
|
1878
|
+
});
|
|
1879
|
+
|
|
1880
|
+
it('should reset budget on genuine progress (failed phase passes then advances)', () => {
|
|
1881
|
+
// QA_VALIDATION fails once, recovery succeeds, QA passes, advances to REVIEW
|
|
1882
|
+
// REVIEW fails once — budget should be fresh (reset after QA→REVIEW advance)
|
|
1883
|
+
pipeline.maxRecoveryIterations = 3;
|
|
1884
|
+
|
|
1885
|
+
let qaAttempts = 0;
|
|
1886
|
+
let reviewAttempts = 0;
|
|
1887
|
+
const result = simulateOrchestratorLoop(
|
|
1888
|
+
'QA_VALIDATION',
|
|
1889
|
+
pipeline,
|
|
1890
|
+
(phase, _attempt) => {
|
|
1891
|
+
if (phase === 'QA_VALIDATION') {
|
|
1892
|
+
qaAttempts++;
|
|
1893
|
+
return qaAttempts >= 2; // Fail first, pass second
|
|
1894
|
+
}
|
|
1895
|
+
if (phase === 'REVIEW') {
|
|
1896
|
+
reviewAttempts++;
|
|
1897
|
+
return reviewAttempts >= 2; // Fail first, pass second
|
|
1898
|
+
}
|
|
1899
|
+
if (phase === 'RECOVERY_LOOP') return true;
|
|
1900
|
+
return true;
|
|
1901
|
+
},
|
|
1902
|
+
);
|
|
1903
|
+
|
|
1904
|
+
expect(result.finalPhase).toBe('DONE');
|
|
1905
|
+
// Budget should have reset when QA passed and advanced to REVIEW
|
|
1906
|
+
expect(result.progressLog.some(m => m.includes('advancing QA_VALIDATION -> REVIEW'))).toBe(true);
|
|
1907
|
+
});
|
|
1908
|
+
|
|
1909
|
+
it('should replace failedPhase and baseline when different phase fails during recovery', () => {
|
|
1910
|
+
// PRODUCTION_GATE fails (baseline=1) → recovery → rewind to IMPLEMENTATION
|
|
1911
|
+
// → QA_VALIDATION fails (new failure origin) → failedPhase should become QA
|
|
1912
|
+
pipeline.maxRecoveryIterations = 5;
|
|
1913
|
+
|
|
1914
|
+
let prodGateAttempts = 0;
|
|
1915
|
+
let qaAttempts = 0;
|
|
1916
|
+
const result = simulateOrchestratorLoop(
|
|
1917
|
+
'PRODUCTION_GATE',
|
|
1918
|
+
pipeline,
|
|
1919
|
+
(phase, _attempt) => {
|
|
1920
|
+
if (phase === 'PRODUCTION_GATE') {
|
|
1921
|
+
prodGateAttempts++;
|
|
1922
|
+
return prodGateAttempts >= 2; // Fail first time only
|
|
1923
|
+
}
|
|
1924
|
+
if (phase === 'QA_VALIDATION') {
|
|
1925
|
+
qaAttempts++;
|
|
1926
|
+
return qaAttempts >= 2; // Fail first, pass second
|
|
1927
|
+
}
|
|
1928
|
+
if (phase === 'RECOVERY_LOOP') return true;
|
|
1929
|
+
return true;
|
|
1930
|
+
},
|
|
1931
|
+
{
|
|
1932
|
+
gateResultOverrides: new Map([
|
|
1933
|
+
['PRODUCTION_GATE', (_attempt: number) => ({ failedChecks: ['lint' as any] })],
|
|
1934
|
+
['QA_VALIDATION', (_attempt: number) => ({ failedChecks: ['test' as any, 'build' as any] })],
|
|
1935
|
+
]),
|
|
1936
|
+
},
|
|
1937
|
+
);
|
|
1938
|
+
|
|
1939
|
+
// Pipeline should eventually reach DONE (both phases pass on retry)
|
|
1940
|
+
expect(result.finalPhase).toBe('DONE');
|
|
1941
|
+
// QA failure should have set a new baseline (2, not 1)
|
|
1942
|
+
expect(pipeline.recoveryBaselineFailedCheckCount).toBeUndefined(); // cleared on success
|
|
1943
|
+
});
|
|
1944
|
+
});
|
|
1945
|
+
|
|
1946
|
+
// ─── v2.7.0: Recovery regression detection ──────
|
|
1947
|
+
|
|
1948
|
+
describe('v2.7.0: Recovery regression detection', () => {
|
|
1949
|
+
it('should detect regression when failing checks increase', () => {
|
|
1950
|
+
// First PRODUCTION_GATE failure: 1 check. After recovery: 3 checks → regression
|
|
1951
|
+
pipeline.maxRecoveryIterations = 5;
|
|
1952
|
+
|
|
1953
|
+
let prodGateAttempts = 0;
|
|
1954
|
+
const result = simulateOrchestratorLoop(
|
|
1955
|
+
'PRODUCTION_GATE',
|
|
1956
|
+
pipeline,
|
|
1957
|
+
(phase, _attempt) => {
|
|
1958
|
+
if (phase === 'PRODUCTION_GATE') {
|
|
1959
|
+
prodGateAttempts++;
|
|
1960
|
+
return false; // Always fails
|
|
1961
|
+
}
|
|
1962
|
+
if (phase === 'RECOVERY_LOOP') return true;
|
|
1963
|
+
return true;
|
|
1964
|
+
},
|
|
1965
|
+
{
|
|
1966
|
+
gateResultOverrides: new Map([
|
|
1967
|
+
['PRODUCTION_GATE', (attempt: number) => {
|
|
1968
|
+
if (attempt === 1) return { failedChecks: ['lint' as any] };
|
|
1969
|
+
// After recovery: 3 failing checks (regression)
|
|
1970
|
+
return { failedChecks: ['build' as any, 'test' as any, 'lint' as any] };
|
|
1971
|
+
}],
|
|
1972
|
+
]),
|
|
1973
|
+
},
|
|
1974
|
+
);
|
|
1975
|
+
|
|
1976
|
+
expect(result.finalPhase).toBe('STUCK');
|
|
1977
|
+
// Should have detected regression and exhausted budget immediately
|
|
1978
|
+
expect(result.progressLog.some(m => m.includes('[regression]'))).toBe(true);
|
|
1979
|
+
expect(result.progressLog.some(m => m.includes('1 -> 3'))).toBe(true);
|
|
1980
|
+
});
|
|
1981
|
+
|
|
1982
|
+
it('should NOT detect regression when failing checks decrease (improvement)', () => {
|
|
1983
|
+
// First failure: 3 checks. After recovery: 1 check → improvement, continue normally
|
|
1984
|
+
pipeline.maxRecoveryIterations = 5;
|
|
1985
|
+
|
|
1986
|
+
let prodGateAttempts = 0;
|
|
1987
|
+
const result = simulateOrchestratorLoop(
|
|
1988
|
+
'PRODUCTION_GATE',
|
|
1989
|
+
pipeline,
|
|
1990
|
+
(phase, _attempt) => {
|
|
1991
|
+
if (phase === 'PRODUCTION_GATE') {
|
|
1992
|
+
prodGateAttempts++;
|
|
1993
|
+
return prodGateAttempts >= 3; // Fail twice, pass on third
|
|
1994
|
+
}
|
|
1995
|
+
if (phase === 'RECOVERY_LOOP') return true;
|
|
1996
|
+
return true;
|
|
1997
|
+
},
|
|
1998
|
+
{
|
|
1999
|
+
gateResultOverrides: new Map([
|
|
2000
|
+
['PRODUCTION_GATE', (attempt: number) => {
|
|
2001
|
+
if (attempt === 1) return { failedChecks: ['build' as any, 'test' as any, 'lint' as any] };
|
|
2002
|
+
if (attempt === 2) return { failedChecks: ['lint' as any] }; // Improvement
|
|
2003
|
+
return {}; // Pass
|
|
2004
|
+
}],
|
|
2005
|
+
]),
|
|
2006
|
+
},
|
|
2007
|
+
);
|
|
2008
|
+
|
|
2009
|
+
expect(result.finalPhase).toBe('DONE');
|
|
2010
|
+
expect(result.progressLog.every(m => !m.includes('[regression]'))).toBe(true);
|
|
2011
|
+
});
|
|
2012
|
+
|
|
2013
|
+
it('should NOT detect regression when check count stays the same', () => {
|
|
2014
|
+
// First failure: 2 checks. After recovery: 2 checks → same count, not regression
|
|
2015
|
+
pipeline.maxRecoveryIterations = 5;
|
|
2016
|
+
|
|
2017
|
+
let prodGateAttempts = 0;
|
|
2018
|
+
const result = simulateOrchestratorLoop(
|
|
2019
|
+
'PRODUCTION_GATE',
|
|
2020
|
+
pipeline,
|
|
2021
|
+
(phase, _attempt) => {
|
|
2022
|
+
if (phase === 'PRODUCTION_GATE') {
|
|
2023
|
+
prodGateAttempts++;
|
|
2024
|
+
return prodGateAttempts >= 3; // Fail twice, pass on third
|
|
2025
|
+
}
|
|
2026
|
+
if (phase === 'RECOVERY_LOOP') return true;
|
|
2027
|
+
return true;
|
|
2028
|
+
},
|
|
2029
|
+
{
|
|
2030
|
+
gateResultOverrides: new Map([
|
|
2031
|
+
['PRODUCTION_GATE', (attempt: number) => {
|
|
2032
|
+
if (attempt <= 2) return { failedChecks: ['build' as any, 'lint' as any] };
|
|
2033
|
+
return {}; // Pass
|
|
2034
|
+
}],
|
|
2035
|
+
]),
|
|
2036
|
+
},
|
|
2037
|
+
);
|
|
2038
|
+
|
|
2039
|
+
expect(result.finalPhase).toBe('DONE');
|
|
2040
|
+
expect(result.progressLog.every(m => !m.includes('[regression]'))).toBe(true);
|
|
2041
|
+
});
|
|
2042
|
+
|
|
2043
|
+
it('should clear baseline on success (failedPhase passes)', () => {
|
|
2044
|
+
// Phase fails, recovers, passes → baseline should be cleared
|
|
2045
|
+
pipeline.maxRecoveryIterations = 5;
|
|
2046
|
+
|
|
2047
|
+
let qaAttempts = 0;
|
|
2048
|
+
simulateOrchestratorLoop(
|
|
2049
|
+
'QA_VALIDATION',
|
|
2050
|
+
pipeline,
|
|
2051
|
+
(phase, _attempt) => {
|
|
2052
|
+
if (phase === 'QA_VALIDATION') {
|
|
2053
|
+
qaAttempts++;
|
|
2054
|
+
return qaAttempts >= 2;
|
|
2055
|
+
}
|
|
2056
|
+
if (phase === 'RECOVERY_LOOP') return true;
|
|
2057
|
+
return true;
|
|
2058
|
+
},
|
|
2059
|
+
{
|
|
2060
|
+
gateResultOverrides: new Map([
|
|
2061
|
+
['QA_VALIDATION', (attempt: number) => {
|
|
2062
|
+
if (attempt === 1) return { failedChecks: ['test' as any] };
|
|
2063
|
+
return {};
|
|
2064
|
+
}],
|
|
2065
|
+
]),
|
|
2066
|
+
},
|
|
2067
|
+
);
|
|
2068
|
+
|
|
2069
|
+
expect(pipeline.recoveryBaselineFailedCheckCount).toBeUndefined();
|
|
2070
|
+
expect(pipeline.failedPhase).toBeUndefined();
|
|
2071
|
+
});
|
|
2072
|
+
|
|
2073
|
+
it('should preserve baseline across retries and detect regression against original', () => {
|
|
2074
|
+
// First failure: 1 check (baseline). Second failure: 1 check (same, no regression).
|
|
2075
|
+
// Third failure: 2 checks → regression detected against original baseline of 1
|
|
2076
|
+
pipeline.maxRecoveryIterations = 5;
|
|
2077
|
+
|
|
2078
|
+
let prodGateAttempts = 0;
|
|
2079
|
+
const result = simulateOrchestratorLoop(
|
|
2080
|
+
'PRODUCTION_GATE',
|
|
2081
|
+
pipeline,
|
|
2082
|
+
(phase, _attempt) => {
|
|
2083
|
+
if (phase === 'PRODUCTION_GATE') {
|
|
2084
|
+
prodGateAttempts++;
|
|
2085
|
+
return false; // Always fails
|
|
2086
|
+
}
|
|
2087
|
+
if (phase === 'RECOVERY_LOOP') return true;
|
|
2088
|
+
return true;
|
|
2089
|
+
},
|
|
2090
|
+
{
|
|
2091
|
+
gateResultOverrides: new Map([
|
|
2092
|
+
['PRODUCTION_GATE', (attempt: number) => {
|
|
2093
|
+
if (attempt <= 2) return { failedChecks: ['lint' as any] }; // 1 check
|
|
2094
|
+
return { failedChecks: ['lint' as any, 'build' as any] }; // 2 checks → regression
|
|
2095
|
+
}],
|
|
2096
|
+
]),
|
|
2097
|
+
},
|
|
2098
|
+
);
|
|
2099
|
+
|
|
2100
|
+
expect(result.finalPhase).toBe('STUCK');
|
|
2101
|
+
expect(result.progressLog.some(m => m.includes('[regression]'))).toBe(true);
|
|
2102
|
+
expect(result.progressLog.some(m => m.includes('1 -> 2'))).toBe(true);
|
|
2103
|
+
});
|
|
2104
|
+
});
|
|
614
2105
|
});
|