@nathapp/nax 0.36.0 → 0.36.2
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/nax.js +543 -154
- package/package.json +1 -1
- package/src/agents/claude-decompose.ts +3 -3
- package/src/cli/constitution.ts +0 -92
- package/src/constitution/generator.ts +0 -33
- package/src/constitution/index.ts +2 -1
- package/src/constitution/loader.ts +1 -13
- package/src/context/builder.ts +1 -2
- package/src/context/elements.ts +1 -12
- package/src/context/index.ts +2 -1
- package/src/context/test-scanner.ts +1 -1
- package/src/execution/dry-run.ts +1 -1
- package/src/execution/escalation/escalation.ts +5 -3
- package/src/execution/escalation/tier-escalation.ts +41 -4
- package/src/execution/iteration-runner.ts +5 -0
- package/src/execution/parallel-executor.ts +293 -9
- package/src/execution/parallel.ts +40 -21
- package/src/execution/pipeline-result-handler.ts +3 -2
- package/src/execution/runner.ts +13 -3
- package/src/interaction/chain.ts +17 -1
- package/src/metrics/tracker.ts +8 -4
- package/src/metrics/types.ts +2 -0
- package/src/pipeline/event-bus.ts +1 -1
- package/src/pipeline/stages/completion.ts +1 -1
- package/src/pipeline/stages/execution.ts +23 -1
- package/src/pipeline/stages/verify.ts +8 -1
- package/src/pipeline/subscribers/reporters.ts +3 -3
- package/src/pipeline/types.ts +4 -0
- package/src/plugins/types.ts +1 -1
- package/src/prd/types.ts +2 -0
- package/src/prompts/builder.ts +13 -6
- package/src/prompts/sections/conventions.ts +5 -7
- package/src/prompts/sections/isolation.ts +7 -7
- package/src/prompts/sections/role-task.ts +64 -64
- package/src/review/orchestrator.ts +11 -1
- package/src/routing/strategies/llm-prompts.ts +1 -1
- package/src/routing/strategies/llm.ts +3 -3
- package/src/tdd/index.ts +2 -3
- package/src/tdd/isolation.ts +0 -13
- package/src/tdd/orchestrator.ts +5 -0
- package/src/tdd/prompts.ts +1 -231
- package/src/tdd/session-runner.ts +2 -0
- package/src/tdd/types.ts +2 -1
- package/src/tdd/verdict.ts +20 -2
- package/src/verification/crash-detector.ts +34 -0
- package/src/verification/orchestrator-types.ts +8 -1
- package/src/verification/parser.ts +0 -10
- package/src/verification/rectification-loop.ts +2 -51
- package/src/worktree/dispatcher.ts +0 -59
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* Handles the full parallel execution flow:
|
|
5
5
|
* - Status updates with parallel info
|
|
6
6
|
* - Execute parallel stories
|
|
7
|
+
* - Rectify merge conflicts (MFX-005): re-run conflicted stories sequentially
|
|
8
|
+
* on the updated base branch so each sees all previously merged stories
|
|
7
9
|
* - Handle completion or continue to sequential
|
|
8
10
|
*/
|
|
9
11
|
|
|
@@ -17,17 +19,147 @@ import type { StoryMetrics } from "../metrics";
|
|
|
17
19
|
import type { PipelineEventEmitter } from "../pipeline/events";
|
|
18
20
|
import type { PluginRegistry } from "../plugins/registry";
|
|
19
21
|
import type { PRD } from "../prd";
|
|
20
|
-
import { countStories, isComplete } from "../prd";
|
|
22
|
+
import { countStories, isComplete, markStoryPassed } from "../prd";
|
|
21
23
|
import { getAllReadyStories, hookCtx } from "./helpers";
|
|
22
24
|
import { executeParallel } from "./parallel";
|
|
23
25
|
import type { StatusWriter } from "./status-writer";
|
|
24
26
|
|
|
27
|
+
/** StoryMetrics extended with execution-path source */
|
|
28
|
+
export type ParallelStoryMetrics = StoryMetrics & {
|
|
29
|
+
source: "parallel" | "sequential" | "rectification";
|
|
30
|
+
rectifiedFromConflict?: boolean;
|
|
31
|
+
originalCost?: number;
|
|
32
|
+
rectificationCost?: number;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** A story that conflicted during the initial parallel merge pass */
|
|
36
|
+
export interface ConflictedStoryInfo {
|
|
37
|
+
storyId: string;
|
|
38
|
+
conflictFiles: string[];
|
|
39
|
+
originalCost: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Result from attempting to rectify a single conflicted story */
|
|
43
|
+
export type RectificationResult =
|
|
44
|
+
| { success: true; storyId: string; cost: number }
|
|
45
|
+
| {
|
|
46
|
+
success: false;
|
|
47
|
+
storyId: string;
|
|
48
|
+
cost: number;
|
|
49
|
+
finalConflict: boolean;
|
|
50
|
+
pipelineFailure?: boolean;
|
|
51
|
+
conflictFiles?: string[];
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Options passed to rectifyConflictedStory */
|
|
55
|
+
export interface RectifyConflictedStoryOptions extends ConflictedStoryInfo {
|
|
56
|
+
workdir: string;
|
|
57
|
+
config: NaxConfig;
|
|
58
|
+
hooks: LoadedHooksConfig;
|
|
59
|
+
pluginRegistry: PluginRegistry;
|
|
60
|
+
prd: PRD;
|
|
61
|
+
eventEmitter?: PipelineEventEmitter;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Actual implementation of rectifyConflictedStory.
|
|
66
|
+
*
|
|
67
|
+
* Steps:
|
|
68
|
+
* 1. Remove the old worktree
|
|
69
|
+
* 2. Create a fresh worktree from current HEAD (post-merge state)
|
|
70
|
+
* 3. Re-run the full story pipeline
|
|
71
|
+
* 4. Attempt merge on the updated base
|
|
72
|
+
* 5. Return success/finalConflict
|
|
73
|
+
*/
|
|
74
|
+
async function rectifyConflictedStory(options: RectifyConflictedStoryOptions): Promise<RectificationResult> {
|
|
75
|
+
const { storyId, workdir, config, hooks, pluginRegistry, prd, eventEmitter } = options;
|
|
76
|
+
const logger = getSafeLogger();
|
|
77
|
+
|
|
78
|
+
logger?.info("parallel", "Rectifying story on updated base", { storyId, attempt: "rectification" });
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const { WorktreeManager } = await import("../worktree/manager");
|
|
82
|
+
const { MergeEngine } = await import("../worktree/merge");
|
|
83
|
+
const { runPipeline } = await import("../pipeline/runner");
|
|
84
|
+
const { defaultPipeline } = await import("../pipeline/stages");
|
|
85
|
+
const { routeTask } = await import("../routing");
|
|
86
|
+
|
|
87
|
+
const worktreeManager = new WorktreeManager();
|
|
88
|
+
const mergeEngine = new MergeEngine(worktreeManager);
|
|
89
|
+
|
|
90
|
+
// Step 1: Remove old worktree
|
|
91
|
+
try {
|
|
92
|
+
await worktreeManager.remove(workdir, storyId);
|
|
93
|
+
} catch {
|
|
94
|
+
// Ignore — worktree may have already been removed
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Step 2: Create fresh worktree from current HEAD
|
|
98
|
+
await worktreeManager.create(workdir, storyId);
|
|
99
|
+
const worktreePath = path.join(workdir, ".nax-wt", storyId);
|
|
100
|
+
|
|
101
|
+
// Step 3: Re-run the story pipeline
|
|
102
|
+
const story = prd.userStories.find((s) => s.id === storyId);
|
|
103
|
+
if (!story) {
|
|
104
|
+
return { success: false, storyId, cost: 0, finalConflict: false, pipelineFailure: true };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const routing = routeTask(story.title, story.description, story.acceptanceCriteria, story.tags, config);
|
|
108
|
+
|
|
109
|
+
const pipelineContext = {
|
|
110
|
+
config,
|
|
111
|
+
prd,
|
|
112
|
+
story,
|
|
113
|
+
stories: [story],
|
|
114
|
+
workdir: worktreePath,
|
|
115
|
+
featureDir: undefined,
|
|
116
|
+
hooks,
|
|
117
|
+
plugins: pluginRegistry,
|
|
118
|
+
storyStartTime: new Date().toISOString(),
|
|
119
|
+
routing: routing as import("../pipeline/types").RoutingResult,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const pipelineResult = await runPipeline(defaultPipeline, pipelineContext, eventEmitter);
|
|
123
|
+
const cost = pipelineResult.context.agentResult?.estimatedCost ?? 0;
|
|
124
|
+
|
|
125
|
+
if (!pipelineResult.success) {
|
|
126
|
+
logger?.info("parallel", "Rectification failed - preserving worktree", { storyId });
|
|
127
|
+
return { success: false, storyId, cost, finalConflict: false, pipelineFailure: true };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Step 4: Attempt merge on updated base
|
|
131
|
+
const mergeResults = await mergeEngine.mergeAll(workdir, [storyId], { [storyId]: [] });
|
|
132
|
+
const mergeResult = mergeResults[0];
|
|
133
|
+
|
|
134
|
+
if (!mergeResult || !mergeResult.success) {
|
|
135
|
+
const conflictFiles = mergeResult?.conflictFiles ?? [];
|
|
136
|
+
logger?.info("parallel", "Rectification failed - preserving worktree", { storyId });
|
|
137
|
+
return { success: false, storyId, cost, finalConflict: true, conflictFiles };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
logger?.info("parallel", "Rectification succeeded - story merged", {
|
|
141
|
+
storyId,
|
|
142
|
+
originalCost: options.originalCost,
|
|
143
|
+
rectificationCost: cost,
|
|
144
|
+
});
|
|
145
|
+
return { success: true, storyId, cost };
|
|
146
|
+
} catch (error) {
|
|
147
|
+
logger?.error("parallel", "Rectification failed - preserving worktree", {
|
|
148
|
+
storyId,
|
|
149
|
+
error: error instanceof Error ? error.message : String(error),
|
|
150
|
+
});
|
|
151
|
+
return { success: false, storyId, cost: 0, finalConflict: false, pipelineFailure: true };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
25
155
|
/**
|
|
26
156
|
* Injectable dependencies for testing (avoids mock.module() which leaks in Bun 1.x).
|
|
27
157
|
* @internal - test use only.
|
|
28
158
|
*/
|
|
29
159
|
export const _parallelExecutorDeps = {
|
|
30
160
|
fireHook,
|
|
161
|
+
executeParallel,
|
|
162
|
+
rectifyConflictedStory,
|
|
31
163
|
};
|
|
32
164
|
|
|
33
165
|
export interface ParallelExecutorOptions {
|
|
@@ -52,12 +184,101 @@ export interface ParallelExecutorOptions {
|
|
|
52
184
|
headless: boolean;
|
|
53
185
|
}
|
|
54
186
|
|
|
187
|
+
export interface RectificationStats {
|
|
188
|
+
rectified: number;
|
|
189
|
+
stillConflicting: number;
|
|
190
|
+
}
|
|
191
|
+
|
|
55
192
|
export interface ParallelExecutorResult {
|
|
56
193
|
prd: PRD;
|
|
57
194
|
totalCost: number;
|
|
58
195
|
storiesCompleted: number;
|
|
59
196
|
completed: boolean;
|
|
60
197
|
durationMs?: number;
|
|
198
|
+
/** Per-story metrics for stories completed via the parallel path */
|
|
199
|
+
storyMetrics: ParallelStoryMetrics[];
|
|
200
|
+
/** Stats from the merge-conflict rectification pass (MFX-005) */
|
|
201
|
+
rectificationStats: RectificationStats;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Run the rectification pass: sequentially re-run each conflicted story on
|
|
206
|
+
* the updated base (which already includes all clean merges from the first pass).
|
|
207
|
+
*/
|
|
208
|
+
async function runRectificationPass(
|
|
209
|
+
conflictedStories: ConflictedStoryInfo[],
|
|
210
|
+
options: ParallelExecutorOptions,
|
|
211
|
+
prd: PRD,
|
|
212
|
+
): Promise<{
|
|
213
|
+
rectifiedCount: number;
|
|
214
|
+
stillConflictingCount: number;
|
|
215
|
+
additionalCost: number;
|
|
216
|
+
updatedPrd: PRD;
|
|
217
|
+
rectificationMetrics: ParallelStoryMetrics[];
|
|
218
|
+
}> {
|
|
219
|
+
const logger = getSafeLogger();
|
|
220
|
+
const { workdir, config, hooks, pluginRegistry, eventEmitter } = options;
|
|
221
|
+
const rectificationMetrics: ParallelStoryMetrics[] = [];
|
|
222
|
+
let rectifiedCount = 0;
|
|
223
|
+
let stillConflictingCount = 0;
|
|
224
|
+
let additionalCost = 0;
|
|
225
|
+
|
|
226
|
+
logger?.info("parallel", "Starting merge conflict rectification", {
|
|
227
|
+
stories: conflictedStories.map((s) => s.storyId),
|
|
228
|
+
totalConflicts: conflictedStories.length,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// Sequential — each story sees all previously rectified stories in the base
|
|
232
|
+
for (const conflictInfo of conflictedStories) {
|
|
233
|
+
const result = await _parallelExecutorDeps.rectifyConflictedStory({
|
|
234
|
+
...conflictInfo,
|
|
235
|
+
workdir,
|
|
236
|
+
config,
|
|
237
|
+
hooks,
|
|
238
|
+
pluginRegistry,
|
|
239
|
+
prd,
|
|
240
|
+
eventEmitter,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
additionalCost += result.cost;
|
|
244
|
+
|
|
245
|
+
if (result.success) {
|
|
246
|
+
markStoryPassed(prd, result.storyId);
|
|
247
|
+
rectifiedCount++;
|
|
248
|
+
|
|
249
|
+
rectificationMetrics.push({
|
|
250
|
+
storyId: result.storyId,
|
|
251
|
+
complexity: "unknown",
|
|
252
|
+
modelTier: "parallel",
|
|
253
|
+
modelUsed: "parallel",
|
|
254
|
+
attempts: 1,
|
|
255
|
+
finalTier: "parallel",
|
|
256
|
+
success: true,
|
|
257
|
+
cost: result.cost,
|
|
258
|
+
durationMs: 0,
|
|
259
|
+
firstPassSuccess: false,
|
|
260
|
+
startedAt: new Date().toISOString(),
|
|
261
|
+
completedAt: new Date().toISOString(),
|
|
262
|
+
source: "rectification" as const,
|
|
263
|
+
rectifiedFromConflict: true,
|
|
264
|
+
originalCost: conflictInfo.originalCost,
|
|
265
|
+
rectificationCost: result.cost,
|
|
266
|
+
});
|
|
267
|
+
} else {
|
|
268
|
+
const isFinalConflict = result.finalConflict === true;
|
|
269
|
+
if (isFinalConflict) {
|
|
270
|
+
stillConflictingCount++;
|
|
271
|
+
}
|
|
272
|
+
// pipelineFailure — not counted as structural conflict, story remains failed
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
logger?.info("parallel", "Rectification complete", {
|
|
277
|
+
rectified: rectifiedCount,
|
|
278
|
+
stillConflicting: stillConflictingCount,
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
return { rectifiedCount, stillConflictingCount, additionalCost, updatedPrd: prd, rectificationMetrics };
|
|
61
282
|
}
|
|
62
283
|
|
|
63
284
|
/**
|
|
@@ -92,7 +313,14 @@ export async function runParallelExecution(
|
|
|
92
313
|
const readyStories = getAllReadyStories(prd);
|
|
93
314
|
if (readyStories.length === 0) {
|
|
94
315
|
logger?.info("parallel", "No stories ready for parallel execution");
|
|
95
|
-
return {
|
|
316
|
+
return {
|
|
317
|
+
prd,
|
|
318
|
+
totalCost,
|
|
319
|
+
storiesCompleted,
|
|
320
|
+
completed: false,
|
|
321
|
+
storyMetrics: [],
|
|
322
|
+
rectificationStats: { rectified: 0, stillConflicting: 0 },
|
|
323
|
+
};
|
|
96
324
|
}
|
|
97
325
|
|
|
98
326
|
const maxConcurrency = parallelCount === 0 ? os.cpus().length : Math.max(1, parallelCount);
|
|
@@ -115,8 +343,16 @@ export async function runParallelExecution(
|
|
|
115
343
|
},
|
|
116
344
|
});
|
|
117
345
|
|
|
346
|
+
// Track which stories were already passed before this batch
|
|
347
|
+
const initialPassedIds = new Set(initialPrd.userStories.filter((s) => s.status === "passed").map((s) => s.id));
|
|
348
|
+
const batchStartedAt = new Date().toISOString();
|
|
349
|
+
const batchStartMs = Date.now();
|
|
350
|
+
const batchStoryMetrics: ParallelStoryMetrics[] = [];
|
|
351
|
+
|
|
352
|
+
let conflictedStories: ConflictedStoryInfo[] = [];
|
|
353
|
+
|
|
118
354
|
try {
|
|
119
|
-
const parallelResult = await executeParallel(
|
|
355
|
+
const parallelResult = await _parallelExecutorDeps.executeParallel(
|
|
120
356
|
readyStories,
|
|
121
357
|
prdPath,
|
|
122
358
|
workdir,
|
|
@@ -129,14 +365,44 @@ export async function runParallelExecution(
|
|
|
129
365
|
eventEmitter,
|
|
130
366
|
);
|
|
131
367
|
|
|
368
|
+
const batchDurationMs = Date.now() - batchStartMs;
|
|
369
|
+
const batchCompletedAt = new Date().toISOString();
|
|
370
|
+
|
|
132
371
|
prd = parallelResult.updatedPrd;
|
|
133
372
|
storiesCompleted += parallelResult.storiesCompleted;
|
|
134
373
|
totalCost += parallelResult.totalCost;
|
|
374
|
+
conflictedStories = parallelResult.mergeConflicts ?? [];
|
|
135
375
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
376
|
+
// BUG-066: Build per-story metrics for stories newly completed by this parallel batch
|
|
377
|
+
const newlyPassedStories = prd.userStories.filter((s) => s.status === "passed" && !initialPassedIds.has(s.id));
|
|
378
|
+
const costPerStory = newlyPassedStories.length > 0 ? parallelResult.totalCost / newlyPassedStories.length : 0;
|
|
379
|
+
for (const story of newlyPassedStories) {
|
|
380
|
+
batchStoryMetrics.push({
|
|
381
|
+
storyId: story.id,
|
|
382
|
+
complexity: "unknown",
|
|
383
|
+
modelTier: "parallel",
|
|
384
|
+
modelUsed: "parallel",
|
|
385
|
+
attempts: 1,
|
|
386
|
+
finalTier: "parallel",
|
|
387
|
+
success: true,
|
|
388
|
+
cost: costPerStory,
|
|
389
|
+
durationMs: batchDurationMs,
|
|
390
|
+
firstPassSuccess: true,
|
|
391
|
+
startedAt: batchStartedAt,
|
|
392
|
+
completedAt: batchCompletedAt,
|
|
393
|
+
source: "parallel" as const,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
allStoryMetrics.push(...batchStoryMetrics);
|
|
398
|
+
|
|
399
|
+
// Log each conflict before scheduling rectification
|
|
400
|
+
for (const conflict of conflictedStories) {
|
|
401
|
+
logger?.info("parallel", "Merge conflict detected - scheduling for rectification", {
|
|
402
|
+
storyId: conflict.storyId,
|
|
403
|
+
conflictFiles: conflict.conflictFiles,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
140
406
|
|
|
141
407
|
// Clear parallel status
|
|
142
408
|
statusWriter.setPrd(prd);
|
|
@@ -160,7 +426,23 @@ export async function runParallelExecution(
|
|
|
160
426
|
throw error;
|
|
161
427
|
}
|
|
162
428
|
|
|
163
|
-
//
|
|
429
|
+
// ── MFX-005: Rectification pass ────────────────────────────────────────────
|
|
430
|
+
let rectificationStats: RectificationStats = { rectified: 0, stillConflicting: 0 };
|
|
431
|
+
|
|
432
|
+
if (conflictedStories.length > 0) {
|
|
433
|
+
const rectResult = await runRectificationPass(conflictedStories, options, prd);
|
|
434
|
+
prd = rectResult.updatedPrd;
|
|
435
|
+
storiesCompleted += rectResult.rectifiedCount;
|
|
436
|
+
totalCost += rectResult.additionalCost;
|
|
437
|
+
rectificationStats = {
|
|
438
|
+
rectified: rectResult.rectifiedCount,
|
|
439
|
+
stillConflicting: rectResult.stillConflictingCount,
|
|
440
|
+
};
|
|
441
|
+
batchStoryMetrics.push(...rectResult.rectificationMetrics);
|
|
442
|
+
allStoryMetrics.push(...rectResult.rectificationMetrics);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Check if all stories are complete after parallel execution + rectification
|
|
164
446
|
if (isComplete(prd)) {
|
|
165
447
|
logger?.info("execution", "All stories complete!", {
|
|
166
448
|
feature,
|
|
@@ -228,8 +510,10 @@ export async function runParallelExecution(
|
|
|
228
510
|
storiesCompleted,
|
|
229
511
|
completed: true,
|
|
230
512
|
durationMs,
|
|
513
|
+
storyMetrics: batchStoryMetrics,
|
|
514
|
+
rectificationStats,
|
|
231
515
|
};
|
|
232
516
|
}
|
|
233
517
|
|
|
234
|
-
return { prd, totalCost, storiesCompleted, completed: false };
|
|
518
|
+
return { prd, totalCost, storiesCompleted, completed: false, storyMetrics: batchStoryMetrics, rectificationStats };
|
|
235
519
|
}
|
|
@@ -26,14 +26,18 @@ import { MergeEngine, type StoryDependencies } from "../worktree/merge";
|
|
|
26
26
|
* Result from parallel execution of a batch of stories
|
|
27
27
|
*/
|
|
28
28
|
export interface ParallelBatchResult {
|
|
29
|
-
/** Stories that
|
|
30
|
-
|
|
31
|
-
/** Stories that
|
|
32
|
-
|
|
29
|
+
/** Stories that passed the TDD pipeline (pre-merge) */
|
|
30
|
+
pipelinePassed: UserStory[];
|
|
31
|
+
/** Stories that were actually merged to the base branch */
|
|
32
|
+
merged: UserStory[];
|
|
33
|
+
/** Stories that failed the pipeline */
|
|
34
|
+
failed: Array<{ story: UserStory; error: string }>;
|
|
33
35
|
/** Total cost accumulated */
|
|
34
36
|
totalCost: number;
|
|
35
|
-
/** Stories with merge conflicts */
|
|
36
|
-
|
|
37
|
+
/** Stories with merge conflicts (includes per-story original cost for rectification) */
|
|
38
|
+
mergeConflicts: Array<{ storyId: string; conflictFiles: string[]; originalCost: number }>;
|
|
39
|
+
/** Per-story execution costs for successful stories */
|
|
40
|
+
storyCosts: Map<string, number>;
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
/**
|
|
@@ -148,10 +152,12 @@ async function executeParallelBatch(
|
|
|
148
152
|
const logger = getSafeLogger();
|
|
149
153
|
const worktreeManager = new WorktreeManager();
|
|
150
154
|
const results: ParallelBatchResult = {
|
|
151
|
-
|
|
152
|
-
|
|
155
|
+
pipelinePassed: [],
|
|
156
|
+
merged: [],
|
|
157
|
+
failed: [],
|
|
153
158
|
totalCost: 0,
|
|
154
|
-
|
|
159
|
+
mergeConflicts: [],
|
|
160
|
+
storyCosts: new Map(),
|
|
155
161
|
};
|
|
156
162
|
|
|
157
163
|
// Create worktrees for all stories in batch
|
|
@@ -168,7 +174,7 @@ async function executeParallelBatch(
|
|
|
168
174
|
worktreePath,
|
|
169
175
|
});
|
|
170
176
|
} catch (error) {
|
|
171
|
-
results.
|
|
177
|
+
results.failed.push({
|
|
172
178
|
story,
|
|
173
179
|
error: `Failed to create worktree: ${error instanceof Error ? error.message : String(error)}`,
|
|
174
180
|
});
|
|
@@ -188,15 +194,16 @@ async function executeParallelBatch(
|
|
|
188
194
|
const executePromise = executeStoryInWorktree(story, worktreePath, context, routing as RoutingResult, eventEmitter)
|
|
189
195
|
.then((result) => {
|
|
190
196
|
results.totalCost += result.cost;
|
|
197
|
+
results.storyCosts.set(story.id, result.cost);
|
|
191
198
|
|
|
192
199
|
if (result.success) {
|
|
193
|
-
results.
|
|
200
|
+
results.pipelinePassed.push(story);
|
|
194
201
|
logger?.info("parallel", "Story execution succeeded", {
|
|
195
202
|
storyId: story.id,
|
|
196
203
|
cost: result.cost,
|
|
197
204
|
});
|
|
198
205
|
} else {
|
|
199
|
-
results.
|
|
206
|
+
results.failed.push({ story, error: result.error || "Unknown error" });
|
|
200
207
|
logger?.error("parallel", "Story execution failed", {
|
|
201
208
|
storyId: story.id,
|
|
202
209
|
error: result.error,
|
|
@@ -257,7 +264,12 @@ export async function executeParallel(
|
|
|
257
264
|
featureDir: string | undefined,
|
|
258
265
|
parallel: number,
|
|
259
266
|
eventEmitter?: PipelineEventEmitter,
|
|
260
|
-
): Promise<{
|
|
267
|
+
): Promise<{
|
|
268
|
+
storiesCompleted: number;
|
|
269
|
+
totalCost: number;
|
|
270
|
+
updatedPrd: PRD;
|
|
271
|
+
mergeConflicts: Array<{ storyId: string; conflictFiles: string[]; originalCost: number }>;
|
|
272
|
+
}> {
|
|
261
273
|
const logger = getSafeLogger();
|
|
262
274
|
const maxConcurrency = resolveMaxConcurrency(parallel);
|
|
263
275
|
const worktreeManager = new WorktreeManager();
|
|
@@ -278,6 +290,7 @@ export async function executeParallel(
|
|
|
278
290
|
let storiesCompleted = 0;
|
|
279
291
|
let totalCost = 0;
|
|
280
292
|
const currentPrd = prd;
|
|
293
|
+
const allMergeConflicts: Array<{ storyId: string; conflictFiles: string[]; originalCost: number }> = [];
|
|
281
294
|
|
|
282
295
|
// Execute each batch sequentially (stories within each batch run in parallel)
|
|
283
296
|
for (let batchIndex = 0; batchIndex < batches.length; batchIndex++) {
|
|
@@ -311,8 +324,8 @@ export async function executeParallel(
|
|
|
311
324
|
totalCost += batchResult.totalCost;
|
|
312
325
|
|
|
313
326
|
// Merge successful stories in topological order
|
|
314
|
-
if (batchResult.
|
|
315
|
-
const successfulIds = batchResult.
|
|
327
|
+
if (batchResult.pipelinePassed.length > 0) {
|
|
328
|
+
const successfulIds = batchResult.pipelinePassed.map((s) => s.id);
|
|
316
329
|
const deps = buildDependencyMap(batch);
|
|
317
330
|
|
|
318
331
|
logger?.info("parallel", "Merging successful stories", {
|
|
@@ -327,6 +340,8 @@ export async function executeParallel(
|
|
|
327
340
|
// Update PRD: mark story as passed
|
|
328
341
|
markStoryPassed(currentPrd, mergeResult.storyId);
|
|
329
342
|
storiesCompleted++;
|
|
343
|
+
const mergedStory = batchResult.pipelinePassed.find((s) => s.id === mergeResult.storyId);
|
|
344
|
+
if (mergedStory) batchResult.merged.push(mergedStory);
|
|
330
345
|
|
|
331
346
|
logger?.info("parallel", "Story merged successfully", {
|
|
332
347
|
storyId: mergeResult.storyId,
|
|
@@ -335,9 +350,10 @@ export async function executeParallel(
|
|
|
335
350
|
} else {
|
|
336
351
|
// Merge conflict — mark story as failed
|
|
337
352
|
markStoryFailed(currentPrd, mergeResult.storyId);
|
|
338
|
-
batchResult.
|
|
353
|
+
batchResult.mergeConflicts.push({
|
|
339
354
|
storyId: mergeResult.storyId,
|
|
340
355
|
conflictFiles: mergeResult.conflictFiles || [],
|
|
356
|
+
originalCost: batchResult.storyCosts.get(mergeResult.storyId) ?? 0,
|
|
341
357
|
});
|
|
342
358
|
|
|
343
359
|
logger?.error("parallel", "Merge conflict", {
|
|
@@ -355,7 +371,7 @@ export async function executeParallel(
|
|
|
355
371
|
}
|
|
356
372
|
|
|
357
373
|
// Mark failed stories in PRD and clean up their worktrees
|
|
358
|
-
for (const { story, error } of batchResult.
|
|
374
|
+
for (const { story, error } of batchResult.failed) {
|
|
359
375
|
markStoryFailed(currentPrd, story.id);
|
|
360
376
|
|
|
361
377
|
logger?.error("parallel", "Cleaning up failed story worktree", {
|
|
@@ -376,10 +392,13 @@ export async function executeParallel(
|
|
|
376
392
|
// Save PRD after each batch
|
|
377
393
|
await savePRD(currentPrd, prdPath);
|
|
378
394
|
|
|
395
|
+
allMergeConflicts.push(...batchResult.mergeConflicts);
|
|
396
|
+
|
|
379
397
|
logger?.info("parallel", `Batch ${batchIndex + 1} complete`, {
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
398
|
+
pipelinePassed: batchResult.pipelinePassed.length,
|
|
399
|
+
merged: batchResult.merged.length,
|
|
400
|
+
failed: batchResult.failed.length,
|
|
401
|
+
mergeConflicts: batchResult.mergeConflicts.length,
|
|
383
402
|
batchCost: batchResult.totalCost,
|
|
384
403
|
});
|
|
385
404
|
}
|
|
@@ -389,5 +408,5 @@ export async function executeParallel(
|
|
|
389
408
|
totalCost,
|
|
390
409
|
});
|
|
391
410
|
|
|
392
|
-
return { storiesCompleted, totalCost, updatedPrd: currentPrd };
|
|
411
|
+
return { storiesCompleted, totalCost, updatedPrd: currentPrd, mergeConflicts: allMergeConflicts };
|
|
393
412
|
}
|
|
@@ -68,7 +68,7 @@ export async function handlePipelineSuccess(
|
|
|
68
68
|
storyId: completedStory.id,
|
|
69
69
|
storyTitle: completedStory.title,
|
|
70
70
|
totalCost: ctx.totalCost + costDelta,
|
|
71
|
-
|
|
71
|
+
runElapsedMs: now - ctx.startTime,
|
|
72
72
|
storyDurationMs: ctx.storyStartTime ? now - ctx.storyStartTime : undefined,
|
|
73
73
|
});
|
|
74
74
|
|
|
@@ -77,7 +77,7 @@ export async function handlePipelineSuccess(
|
|
|
77
77
|
storyId: completedStory.id,
|
|
78
78
|
story: completedStory,
|
|
79
79
|
passed: true,
|
|
80
|
-
|
|
80
|
+
runElapsedMs: Date.now() - ctx.startTime,
|
|
81
81
|
cost: costDelta,
|
|
82
82
|
modelTier: ctx.routing.modelTier,
|
|
83
83
|
testStrategy: ctx.routing.testStrategy,
|
|
@@ -177,6 +177,7 @@ export async function handlePipelineFailure(
|
|
|
177
177
|
feature: ctx.feature,
|
|
178
178
|
totalCost: ctx.totalCost,
|
|
179
179
|
workdir: ctx.workdir,
|
|
180
|
+
attemptCost: pipelineResult.context.agentResult?.estimatedCost || 0,
|
|
180
181
|
});
|
|
181
182
|
prd = escalationResult.prd;
|
|
182
183
|
prdDirty = escalationResult.prdDirty;
|
package/src/execution/runner.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { clearCache as clearLlmCache, routeBatch as llmRouteBatch } from "../rou
|
|
|
21
21
|
import { precomputeBatchPlan } from "./batching";
|
|
22
22
|
import { stopHeartbeat, writeExitSummary } from "./crash-recovery";
|
|
23
23
|
import { getAllReadyStories } from "./helpers";
|
|
24
|
+
import type { ParallelExecutorOptions, ParallelExecutorResult } from "./parallel-executor";
|
|
24
25
|
import { hookCtx } from "./story-context";
|
|
25
26
|
|
|
26
27
|
/**
|
|
@@ -29,6 +30,10 @@ import { hookCtx } from "./story-context";
|
|
|
29
30
|
*/
|
|
30
31
|
export const _runnerDeps = {
|
|
31
32
|
fireHook,
|
|
33
|
+
// Injectable for tests — avoids dynamic-import module-cache issues in bun test (bun 1.3.9+)
|
|
34
|
+
runParallelExecution: null as
|
|
35
|
+
| null
|
|
36
|
+
| ((options: ParallelExecutorOptions, prd: import("../prd").PRD) => Promise<ParallelExecutorResult>),
|
|
32
37
|
};
|
|
33
38
|
|
|
34
39
|
// Re-export for backward compatibility
|
|
@@ -202,7 +207,8 @@ export async function run(options: RunOptions): Promise<RunResult> {
|
|
|
202
207
|
|
|
203
208
|
// ── Parallel Execution Path (when --parallel is set) ──────────────────────
|
|
204
209
|
if (options.parallel !== undefined) {
|
|
205
|
-
const
|
|
210
|
+
const runParallelExecution =
|
|
211
|
+
_runnerDeps.runParallelExecution ?? (await import("./parallel-executor")).runParallelExecution;
|
|
206
212
|
const parallelResult = await runParallelExecution(
|
|
207
213
|
{
|
|
208
214
|
prdPath,
|
|
@@ -231,6 +237,8 @@ export async function run(options: RunOptions): Promise<RunResult> {
|
|
|
231
237
|
prd = parallelResult.prd;
|
|
232
238
|
totalCost = parallelResult.totalCost;
|
|
233
239
|
storiesCompleted = parallelResult.storiesCompleted;
|
|
240
|
+
// BUG-066: merge parallel story metrics into the running accumulator
|
|
241
|
+
allStoryMetrics.push(...parallelResult.storyMetrics);
|
|
234
242
|
|
|
235
243
|
// If parallel execution completed everything, return early
|
|
236
244
|
if (parallelResult.completed && parallelResult.durationMs !== undefined) {
|
|
@@ -269,8 +277,10 @@ export async function run(options: RunOptions): Promise<RunResult> {
|
|
|
269
277
|
|
|
270
278
|
prd = sequentialResult.prd;
|
|
271
279
|
iterations = sequentialResult.iterations;
|
|
272
|
-
|
|
273
|
-
totalCost
|
|
280
|
+
// BUG-064: accumulate (not overwrite) totalCost from sequential path
|
|
281
|
+
totalCost += sequentialResult.totalCost;
|
|
282
|
+
// BUG-065: accumulate (not overwrite) storiesCompleted from sequential path
|
|
283
|
+
storiesCompleted += sequentialResult.storiesCompleted;
|
|
274
284
|
allStoryMetrics.push(...sequentialResult.allStoryMetrics);
|
|
275
285
|
|
|
276
286
|
// After main loop: Check if we need acceptance retry loop
|
package/src/interaction/chain.ts
CHANGED
|
@@ -86,11 +86,27 @@ export class InteractionChain {
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
/**
|
|
89
|
-
* Send and receive in one call (convenience method)
|
|
89
|
+
* Send and receive in one call (convenience method).
|
|
90
|
+
*
|
|
91
|
+
* Normalizes "choose" type responses: when the plugin returns
|
|
92
|
+
* `action: "choose"` + `value: "<key>"`, remaps action to the selected
|
|
93
|
+
* option key so all consumers can switch on action directly without
|
|
94
|
+
* needing to inspect value themselves.
|
|
90
95
|
*/
|
|
91
96
|
async prompt(request: InteractionRequest): Promise<InteractionResponse> {
|
|
92
97
|
await this.send(request);
|
|
93
98
|
const response = await this.receive(request.id, request.timeout);
|
|
99
|
+
|
|
100
|
+
// Normalize choose responses: action="choose" means the user picked an option;
|
|
101
|
+
// the actual selection is in value. Remap to the selected key if it matches
|
|
102
|
+
// one of the declared options.
|
|
103
|
+
if (response.action === "choose" && response.value && request.options) {
|
|
104
|
+
const matched = request.options.find((o) => o.key === response.value);
|
|
105
|
+
if (matched) {
|
|
106
|
+
return { ...response, action: matched.key as InteractionResponse["action"] };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
94
110
|
return response;
|
|
95
111
|
}
|
|
96
112
|
|
package/src/metrics/tracker.ts
CHANGED
|
@@ -44,14 +44,16 @@ export function collectStoryMetrics(ctx: PipelineContext, storyStartTime: string
|
|
|
44
44
|
const agentResult = ctx.agentResult;
|
|
45
45
|
|
|
46
46
|
// Calculate attempts (initial + escalations)
|
|
47
|
+
// BUG-067: priorFailures captures cross-tier attempts that story.escalations never records
|
|
47
48
|
const escalationCount = story.escalations?.length || 0;
|
|
48
|
-
const
|
|
49
|
+
const priorFailureCount = story.priorFailures?.length || 0;
|
|
50
|
+
const attempts = priorFailureCount + Math.max(1, story.attempts || 1);
|
|
49
51
|
|
|
50
52
|
// Determine final tier (from last escalation or initial routing)
|
|
51
53
|
const finalTier = escalationCount > 0 ? story.escalations[escalationCount - 1].toTier : routing.modelTier;
|
|
52
54
|
|
|
53
|
-
// First pass success = succeeded with no escalations
|
|
54
|
-
const firstPassSuccess = agentResult?.success === true && escalationCount === 0;
|
|
55
|
+
// First pass success = succeeded with no prior failures and no escalations (BUG-067)
|
|
56
|
+
const firstPassSuccess = agentResult?.success === true && escalationCount === 0 && priorFailureCount === 0;
|
|
55
57
|
|
|
56
58
|
// Extract model name from config
|
|
57
59
|
const modelEntry = ctx.config.models[routing.modelTier];
|
|
@@ -76,12 +78,13 @@ export function collectStoryMetrics(ctx: PipelineContext, storyStartTime: string
|
|
|
76
78
|
attempts,
|
|
77
79
|
finalTier,
|
|
78
80
|
success: agentResult?.success || false,
|
|
79
|
-
cost: agentResult?.estimatedCost || 0,
|
|
81
|
+
cost: (ctx.accumulatedAttemptCost ?? 0) + (agentResult?.estimatedCost || 0),
|
|
80
82
|
durationMs: agentResult?.durationMs || 0,
|
|
81
83
|
firstPassSuccess,
|
|
82
84
|
startedAt: storyStartTime,
|
|
83
85
|
completedAt: new Date().toISOString(),
|
|
84
86
|
fullSuiteGatePassed,
|
|
87
|
+
runtimeCrashes: ctx.storyRuntimeCrashes ?? 0,
|
|
85
88
|
};
|
|
86
89
|
}
|
|
87
90
|
|
|
@@ -139,6 +142,7 @@ export function collectBatchMetrics(ctx: PipelineContext, storyStartTime: string
|
|
|
139
142
|
startedAt: storyStartTime,
|
|
140
143
|
completedAt: new Date().toISOString(),
|
|
141
144
|
fullSuiteGatePassed: false, // batches are not TDD-gated
|
|
145
|
+
runtimeCrashes: 0, // batch stories don't have individual crash tracking
|
|
142
146
|
};
|
|
143
147
|
});
|
|
144
148
|
}
|
package/src/metrics/types.ts
CHANGED
|
@@ -34,6 +34,8 @@ export interface StoryMetrics {
|
|
|
34
34
|
startedAt: string;
|
|
35
35
|
/** Timestamp when completed */
|
|
36
36
|
completedAt: string;
|
|
37
|
+
/** Number of runtime crashes (RUNTIME_CRASH verify status) encountered for this story (BUG-070) */
|
|
38
|
+
runtimeCrashes?: number;
|
|
37
39
|
/** Whether TDD full-suite gate passed (only true for TDD strategies when gate passes) */
|
|
38
40
|
fullSuiteGatePassed?: boolean;
|
|
39
41
|
}
|