@nathapp/nax 0.36.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nathapp/nax",
3
- "version": "0.36.1",
3
+ "version": "0.36.2",
4
4
  "description": "AI Coding Agent Orchestrator \u2014 loops until done",
5
5
  "type": "module",
6
6
  "bin": {
@@ -66,7 +66,7 @@ export async function handleDryRun(ctx: DryRunContext): Promise<DryRunResult> {
66
66
  storyId: s.id,
67
67
  story: s,
68
68
  passed: true,
69
- durationMs: 0,
69
+ runElapsedMs: 0,
70
70
  cost: 0,
71
71
  modelTier: ctx.routing.modelTier,
72
72
  testStrategy: ctx.routing.testStrategy,
@@ -22,18 +22,20 @@ import type { TierConfig } from "../../config";
22
22
  * ```
23
23
  */
24
24
  export function escalateTier(currentTier: string, tierOrder: TierConfig[]): string | null {
25
- const currentIndex = tierOrder.findIndex((t) => t.tier === currentTier);
25
+ const getName = (t: TierConfig) => t.tier ?? (t as unknown as { name?: string }).name ?? null;
26
+ const currentIndex = tierOrder.findIndex((t) => getName(t) === currentTier);
26
27
  if (currentIndex === -1 || currentIndex === tierOrder.length - 1) {
27
28
  return null;
28
29
  }
29
- return tierOrder[currentIndex + 1].tier;
30
+ return getName(tierOrder[currentIndex + 1]);
30
31
  }
31
32
 
32
33
  /**
33
34
  * Get the tier config for a given tier name.
34
35
  */
35
36
  export function getTierConfig(tierName: string, tierOrder: TierConfig[]): TierConfig | undefined {
36
- return tierOrder.find((t) => t.tier === tierName);
37
+ const getName = (t: TierConfig) => t.tier ?? (t as unknown as { name?: string }).name ?? null;
38
+ return tierOrder.find((t) => getName(t) === tierName);
37
39
  }
38
40
 
39
41
  /**
@@ -24,6 +24,7 @@ function buildEscalationFailure(
24
24
  story: UserStory,
25
25
  currentTier: string,
26
26
  reviewFindings?: import("../../plugins/types").ReviewFinding[],
27
+ cost?: number,
27
28
  ): StructuredFailure {
28
29
  return {
29
30
  attempt: (story.attempts ?? 0) + 1,
@@ -31,6 +32,7 @@ function buildEscalationFailure(
31
32
  stage: "escalation" as const,
32
33
  summary: `Failed with tier ${currentTier}, escalating to next tier`,
33
34
  reviewFindings: reviewFindings && reviewFindings.length > 0 ? reviewFindings : undefined,
35
+ cost: cost ?? 0,
34
36
  timestamp: new Date().toISOString(),
35
37
  };
36
38
  }
@@ -54,6 +56,8 @@ export function resolveMaxAttemptsOutcome(failureCategory?: FailureCategory): "p
54
56
  case "verifier-rejected":
55
57
  case "greenfield-no-tests":
56
58
  return "pause";
59
+ case "runtime-crash":
60
+ return "pause";
57
61
  case "session-failure":
58
62
  case "tests-failing":
59
63
  return "fail";
@@ -208,14 +212,38 @@ export interface EscalationHandlerContext {
208
212
  feature: string;
209
213
  totalCost: number;
210
214
  workdir: string;
215
+ /** Verify result from the pipeline verify stage — used to detect RUNTIME_CRASH (BUG-070) */
216
+ verifyResult?: { status: string; success: boolean };
217
+ /** Cost of the failed attempt being escalated (BUG-067: accumulated across escalations) */
218
+ attemptCost?: number;
211
219
  }
212
220
 
213
221
  export interface EscalationHandlerResult {
214
- outcome: "escalated" | "paused" | "failed";
222
+ outcome: "escalated" | "paused" | "failed" | "retry-same";
215
223
  prdDirty: boolean;
216
224
  prd: PRD;
217
225
  }
218
226
 
227
+ /**
228
+ * Determine if the pipeline should retry the same tier due to a transient runtime crash.
229
+ *
230
+ * Returns true when the verify result status is RUNTIME_CRASH — these are Bun
231
+ * runtime-level failures, not code quality issues, so escalating the model tier
232
+ * would not help. Instead the same tier should be retried.
233
+ *
234
+ * @param verifyResult - Verify result from the pipeline verify stage
235
+ */
236
+ export function shouldRetrySameTier(verifyResult: { status: string; success: boolean } | undefined): boolean {
237
+ return verifyResult?.status === "RUNTIME_CRASH";
238
+ }
239
+
240
+ /**
241
+ * Swappable dependencies for testing (avoids mock.module() which leaks in Bun 1.x).
242
+ */
243
+ export const _tierEscalationDeps = {
244
+ savePRD,
245
+ };
246
+
219
247
  /**
220
248
  * Handle tier escalation after pipeline escalation action
221
249
  *
@@ -223,6 +251,15 @@ export interface EscalationHandlerResult {
223
251
  */
224
252
  export async function handleTierEscalation(ctx: EscalationHandlerContext): Promise<EscalationHandlerResult> {
225
253
  const logger = getSafeLogger();
254
+
255
+ // BUG-070: Runtime crashes are transient — retry same tier, do NOT escalate
256
+ if (shouldRetrySameTier(ctx.verifyResult)) {
257
+ logger?.warn("escalation", "Runtime crash detected — retrying same tier (transient, not a code issue)", {
258
+ storyId: ctx.story.id,
259
+ });
260
+ return { outcome: "retry-same", prdDirty: false, prd: ctx.prd };
261
+ }
262
+
226
263
  const nextTier = escalateTier(ctx.routing.modelTier, ctx.config.autoMode.escalation.tierOrder);
227
264
  const escalateWholeBatch = ctx.config.autoMode.escalation.escalateEntireBatch ?? true;
228
265
  const storiesToEscalate = ctx.isBatchExecution && escalateWholeBatch ? ctx.storiesToExecute : [ctx.story];
@@ -294,8 +331,8 @@ export async function handleTierEscalation(ctx: EscalationHandlerContext): Promi
294
331
  const isChangingTier = currentStoryTier !== nextTier;
295
332
  const shouldResetAttempts = isChangingTier || shouldSwitchToTestAfter;
296
333
 
297
- // Build escalation failure
298
- const escalationFailure = buildEscalationFailure(s, currentStoryTier, escalateReviewFindings);
334
+ // Build escalation failure (BUG-067: include cost for accumulatedAttemptCost in metrics)
335
+ const escalationFailure = buildEscalationFailure(s, currentStoryTier, escalateReviewFindings, ctx.attemptCost);
299
336
 
300
337
  return {
301
338
  ...s,
@@ -307,7 +344,7 @@ export async function handleTierEscalation(ctx: EscalationHandlerContext): Promi
307
344
  }) as PRD["userStories"],
308
345
  } as PRD;
309
346
 
310
- await savePRD(updatedPrd, ctx.prdPath);
347
+ await _tierEscalationDeps.savePRD(updatedPrd, ctx.prdPath);
311
348
 
312
349
  // Clear routing cache for all escalated stories to avoid returning old cached decisions
313
350
  for (const story of storiesToEscalate) {
@@ -60,6 +60,10 @@ export async function runIteration(
60
60
 
61
61
  const storyStartTime = Date.now();
62
62
  const storyGitRef = await captureGitRef(ctx.workdir);
63
+
64
+ // BUG-067: Accumulate cost from all prior failed attempts (stored in priorFailures by handleTierEscalation)
65
+ const accumulatedAttemptCost = (story.priorFailures || []).reduce((sum, f) => sum + (f.cost || 0), 0);
66
+
63
67
  const pipelineContext: PipelineContext = {
64
68
  config: ctx.config,
65
69
  prd,
@@ -74,6 +78,7 @@ export async function runIteration(
74
78
  storyStartTime: new Date().toISOString(),
75
79
  storyGitRef: storyGitRef ?? undefined,
76
80
  interaction: ctx.interactionChain ?? undefined,
81
+ accumulatedAttemptCost: accumulatedAttemptCost > 0 ? accumulatedAttemptCost : undefined,
77
82
  };
78
83
 
79
84
  ctx.statusWriter.setPrd(prd);
@@ -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 { prd, totalCost, storiesCompleted, completed: false };
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
- logger?.info("parallel", "Parallel execution complete", {
137
- storiesCompleted: parallelResult.storiesCompleted,
138
- totalCost: parallelResult.totalCost,
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
- // Check if all stories are complete after parallel execution
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
  }