taskplane 0.10.2 → 0.12.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.
@@ -11,7 +11,7 @@ import type { MonitorUpdateCallback } from "./execution.ts";
11
11
  // classifyExit no longer called directly — Tier 0 uses exitDiagnostic.classification
12
12
  // from the diagnostic-reports pipeline (populated by assembleDiagnosticInput).
13
13
  import { getCurrentBranch, runGit } from "./git.ts";
14
- import { mergeWaveByRepo } from "./merge.ts";
14
+ import { mergeWaveByRepo, MergeHealthMonitor } from "./merge.ts";
15
15
  import { applyMergeRetryLoop, computeCleanupGatePolicy, computeMergeFailurePolicy, extractFailedRepoId, formatRepoMergeSummary, ORCH_MESSAGES } from "./messages.ts";
16
16
  import type { CleanupGateRepoFailure } from "./messages.ts";
17
17
  import { assembleDiagnosticInput, emitDiagnosticReports } from "./diagnostic-reports.ts";
@@ -85,6 +85,7 @@ async function attemptWorkerCrashRetry(
85
85
  allTaskOutcomes: LaneTaskOutcome[],
86
86
  onNotify: (message: string, level: "info" | "warning" | "error") => void,
87
87
  stateRoot: string,
88
+ runnerConfig?: TaskRunnerConfig,
88
89
  ): Promise<{ retriedCount: number; succeededRetries: string[]; failedRetries: string[] }> {
89
90
  if (!batchState.resilience) {
90
91
  batchState.resilience = defaultResilienceState();
@@ -134,6 +135,14 @@ async function attemptWorkerCrashRetry(
134
135
  continue;
135
136
  }
136
137
 
138
+ // model_access_error is handled by attemptModelFallbackRetry() — skip here
139
+ if (classification === "model_access_error") {
140
+ execLog("batch", batchState.batchId,
141
+ `tier0: task ${taskId} classified as model_access_error — deferring to model fallback handler`,
142
+ );
143
+ continue;
144
+ }
145
+
137
146
  // Check retry budget
138
147
  const scopeKey = tier0ScopeKey("worker_crash", taskId, waveIdx);
139
148
  const currentCount = batchState.resilience.retryCountByScope[scopeKey] ?? 0;
@@ -333,6 +342,263 @@ async function attemptWorkerCrashRetry(
333
342
  return { retriedCount, succeededRetries, failedRetries };
334
343
  }
335
344
 
345
+ /**
346
+ * Attempt model fallback retry for tasks that failed with `model_access_error`.
347
+ *
348
+ * When a configured agent model becomes unavailable mid-batch (API key expired,
349
+ * rate limit, model deprecated, provider outage), this function retries the task
350
+ * with the session model by setting `TASKPLANE_MODEL_FALLBACK=1` env var. The
351
+ * task-runner reads this var and omits the explicit `--model` flag, causing pi
352
+ * to use the session's default model.
353
+ *
354
+ * Only runs when `runnerConfig.model_fallback === "inherit"` (the default). When
355
+ * set to `"fail"`, model access errors fall through to normal failure handling.
356
+ *
357
+ * Separate from `attemptWorkerCrashRetry()` because:
358
+ * - Uses a different recovery pattern (`model_fallback` vs `worker_crash`)
359
+ * - Requires env var injection to change the model behavior
360
+ * - Has its own retry budget
361
+ *
362
+ * @since TP-055
363
+ */
364
+ async function attemptModelFallbackRetry(
365
+ waveResult: WaveExecutionResult,
366
+ waveIdx: number,
367
+ batchState: OrchBatchRuntimeState,
368
+ orchConfig: OrchestratorConfig,
369
+ repoRoot: string,
370
+ workspaceConfig: WorkspaceConfig | null | undefined,
371
+ allTaskOutcomes: LaneTaskOutcome[],
372
+ onNotify: (message: string, level: "info" | "warning" | "error") => void,
373
+ stateRoot: string,
374
+ runnerConfig?: TaskRunnerConfig,
375
+ ): Promise<{ retriedCount: number; succeededRetries: string[]; failedRetries: string[] }> {
376
+ // Short-circuit: if model fallback is disabled, skip entirely
377
+ const modelFallbackMode = runnerConfig?.model_fallback ?? "inherit";
378
+ if (modelFallbackMode !== "inherit") {
379
+ return { retriedCount: 0, succeededRetries: [], failedRetries: [] };
380
+ }
381
+
382
+ if (!batchState.resilience) {
383
+ batchState.resilience = defaultResilienceState();
384
+ }
385
+
386
+ const budget = TIER0_RETRY_BUDGETS.model_fallback;
387
+ const succeededRetries: string[] = [];
388
+ const failedRetries: string[] = [];
389
+ let retriedCount = 0;
390
+
391
+ // Build a map from taskId → lane for re-execution
392
+ const taskToLane = new Map<string, AllocatedLane>();
393
+ for (const lane of waveResult.allocatedLanes) {
394
+ for (const task of lane.tasks) {
395
+ taskToLane.set(task.taskId, lane);
396
+ }
397
+ }
398
+
399
+ // Process only model_access_error tasks
400
+ for (const taskId of [...waveResult.failedTaskIds]) {
401
+ const lane = taskToLane.get(taskId);
402
+ if (!lane) continue;
403
+
404
+ const outcome = allTaskOutcomes.find(o => o.taskId === taskId);
405
+ if (!outcome) continue;
406
+
407
+ const classification = outcome.exitDiagnostic?.classification;
408
+ if (classification !== "model_access_error") continue;
409
+
410
+ // Check retry budget
411
+ const scopeKey = tier0ScopeKey("model_fallback", taskId, waveIdx);
412
+ const currentCount = batchState.resilience.retryCountByScope[scopeKey] ?? 0;
413
+ if (currentCount >= budget.maxRetries) {
414
+ execLog("batch", batchState.batchId,
415
+ `tier0: task ${taskId} model fallback retry budget exhausted (${currentCount}/${budget.maxRetries})`,
416
+ { scopeKey },
417
+ );
418
+ emitTier0Event(stateRoot, {
419
+ ...buildTier0EventBase("tier0_recovery_exhausted", batchState.batchId, waveIdx, "model_fallback", currentCount, budget.maxRetries),
420
+ taskId,
421
+ laneNumber: lane.laneNumber,
422
+ repoId: lane.repoId ?? null,
423
+ classification,
424
+ error: `Model fallback retry budget exhausted for task ${taskId}`,
425
+ scopeKey,
426
+ affectedTaskIds: [taskId],
427
+ suggestion: `Task ${taskId} failed with model_access_error and model fallback retry exhausted. Check API key validity and model availability.`,
428
+ });
429
+ emitTier0Escalation(stateRoot, batchState.batchId, waveIdx, "model_fallback", currentCount, budget.maxRetries,
430
+ `Model fallback retry budget exhausted for task ${taskId}`, [taskId],
431
+ `Task ${taskId} failed with model_access_error and model fallback retry exhausted. Check API key validity and model availability.`,
432
+ { taskId, laneNumber: lane.laneNumber, repoId: lane.repoId ?? null, classification, scopeKey },
433
+ );
434
+ continue;
435
+ }
436
+
437
+ // Increment retry counter
438
+ batchState.resilience.retryCountByScope[scopeKey] = currentCount + 1;
439
+ retriedCount++;
440
+
441
+ const failedModel = outcome.exitDiagnostic?.errorMessage || "configured model";
442
+ execLog("batch", batchState.batchId,
443
+ `tier0: model fallback — retrying task ${taskId} without explicit model (${failedModel} unavailable)`,
444
+ { scopeKey, classification },
445
+ );
446
+ onNotify(
447
+ `🔄 Model fallback: Retrying task ${taskId} with session model (${failedModel} unavailable)`,
448
+ "info",
449
+ );
450
+
451
+ // Emit attempt event
452
+ emitTier0Event(stateRoot, {
453
+ ...buildTier0EventBase("tier0_recovery_attempt", batchState.batchId, waveIdx, "model_fallback", currentCount + 1, budget.maxRetries),
454
+ taskId,
455
+ laneNumber: lane.laneNumber,
456
+ repoId: lane.repoId ?? null,
457
+ classification,
458
+ cooldownMs: budget.cooldownMs,
459
+ scopeKey,
460
+ });
461
+
462
+ // Cooldown before retry
463
+ if (budget.cooldownMs > 0) {
464
+ sleepSync(budget.cooldownMs);
465
+ }
466
+
467
+ // Find the specific AllocatedTask
468
+ const allocatedTask = lane.tasks.find(t => t.taskId === taskId);
469
+ if (!allocatedTask) continue;
470
+
471
+ // Re-execute with model fallback env var
472
+ const retryLane: AllocatedLane = {
473
+ ...lane,
474
+ tasks: [allocatedTask],
475
+ };
476
+
477
+ const isWsMode = !!workspaceConfig;
478
+ const wsRoot = workspaceConfig
479
+ ? resolve(workspaceConfig.configPath, "..", "..")
480
+ : undefined;
481
+
482
+ try {
483
+ const retryPauseSignal = { paused: false };
484
+ // Pass TASKPLANE_MODEL_FALLBACK=1 as extra env var to signal
485
+ // the task-runner to use the session model instead of configured model.
486
+ const modelFallbackEnv = { TASKPLANE_MODEL_FALLBACK: "1" };
487
+ const retryResult = await executeLane(
488
+ retryLane,
489
+ orchConfig,
490
+ repoRoot,
491
+ retryPauseSignal,
492
+ wsRoot,
493
+ isWsMode,
494
+ modelFallbackEnv,
495
+ );
496
+
497
+ const retryOutcome = retryResult.tasks[0];
498
+ if (retryOutcome && retryOutcome.status === "succeeded") {
499
+ succeededRetries.push(taskId);
500
+
501
+ // Update waveResult: move from failed to succeeded
502
+ const failIdx = waveResult.failedTaskIds.indexOf(taskId);
503
+ if (failIdx !== -1) waveResult.failedTaskIds.splice(failIdx, 1);
504
+ waveResult.succeededTaskIds.push(taskId);
505
+
506
+ // Update lane results
507
+ for (const lr of waveResult.laneResults) {
508
+ const taskIdx = lr.tasks.findIndex(t => t.taskId === taskId);
509
+ if (taskIdx !== -1) {
510
+ lr.tasks[taskIdx] = retryOutcome;
511
+ break;
512
+ }
513
+ }
514
+
515
+ upsertTaskOutcome(allTaskOutcomes, retryOutcome);
516
+
517
+ execLog("batch", batchState.batchId,
518
+ `tier0: task ${taskId} model fallback retry succeeded`,
519
+ { scopeKey },
520
+ );
521
+ onNotify(
522
+ `✅ Model fallback: Task ${taskId} succeeded with session model`,
523
+ "info",
524
+ );
525
+
526
+ emitTier0Event(stateRoot, {
527
+ ...buildTier0EventBase("tier0_recovery_success", batchState.batchId, waveIdx, "model_fallback", currentCount + 1, budget.maxRetries),
528
+ taskId,
529
+ laneNumber: lane.laneNumber,
530
+ repoId: lane.repoId ?? null,
531
+ classification,
532
+ resolution: `Task ${taskId} succeeded after falling back to session model`,
533
+ scopeKey,
534
+ });
535
+ } else {
536
+ failedRetries.push(taskId);
537
+ if (retryOutcome) {
538
+ upsertTaskOutcome(allTaskOutcomes, retryOutcome);
539
+ }
540
+ execLog("batch", batchState.batchId,
541
+ `tier0: task ${taskId} model fallback retry failed`,
542
+ { scopeKey, exitReason: retryOutcome?.exitReason },
543
+ );
544
+
545
+ const retryFailError = retryOutcome?.exitReason ?? `Task ${taskId} model fallback retry failed`;
546
+ emitTier0Event(stateRoot, {
547
+ ...buildTier0EventBase("tier0_recovery_exhausted", batchState.batchId, waveIdx, "model_fallback", currentCount + 1, budget.maxRetries),
548
+ taskId,
549
+ laneNumber: lane.laneNumber,
550
+ repoId: lane.repoId ?? null,
551
+ classification,
552
+ error: retryFailError,
553
+ scopeKey,
554
+ affectedTaskIds: [taskId],
555
+ suggestion: `Task ${taskId} failed even with session model fallback. Investigate task logs.`,
556
+ });
557
+ emitTier0Escalation(stateRoot, batchState.batchId, waveIdx, "model_fallback", currentCount + 1, budget.maxRetries,
558
+ retryFailError, [taskId],
559
+ `Task ${taskId} failed even with session model fallback. Investigate task logs.`,
560
+ { taskId, laneNumber: lane.laneNumber, repoId: lane.repoId ?? null, classification, scopeKey },
561
+ );
562
+ }
563
+ } catch (err: unknown) {
564
+ failedRetries.push(taskId);
565
+ const errMsg = err instanceof Error ? err.message : String(err);
566
+ execLog("batch", batchState.batchId,
567
+ `tier0: task ${taskId} model fallback retry threw error: ${errMsg}`,
568
+ { scopeKey },
569
+ );
570
+ emitTier0Event(stateRoot, {
571
+ ...buildTier0EventBase("tier0_recovery_exhausted", batchState.batchId, waveIdx, "model_fallback", currentCount + 1, budget.maxRetries),
572
+ taskId,
573
+ laneNumber: lane.laneNumber,
574
+ repoId: lane.repoId ?? null,
575
+ classification,
576
+ error: errMsg,
577
+ scopeKey,
578
+ affectedTaskIds: [taskId],
579
+ suggestion: `Model fallback retry for task ${taskId} threw an exception: ${errMsg}`,
580
+ });
581
+ emitTier0Escalation(stateRoot, batchState.batchId, waveIdx, "model_fallback", currentCount + 1, budget.maxRetries,
582
+ errMsg, [taskId],
583
+ `Model fallback retry for task ${taskId} threw an exception: ${errMsg}`,
584
+ { taskId, laneNumber: lane.laneNumber, repoId: lane.repoId ?? null, classification, scopeKey },
585
+ );
586
+ }
587
+ }
588
+
589
+ // Recalculate wave-level status if retries changed outcomes
590
+ if (succeededRetries.length > 0) {
591
+ if (waveResult.failedTaskIds.length === 0) {
592
+ waveResult.overallStatus = "succeeded";
593
+ waveResult.stoppedEarly = false;
594
+ } else if (waveResult.succeededTaskIds.length > 0) {
595
+ waveResult.overallStatus = "partial";
596
+ }
597
+ }
598
+
599
+ return { retriedCount, succeededRetries, failedRetries };
600
+ }
601
+
336
602
  /**
337
603
  * Attempt stale worktree recovery when lane allocation fails with ALLOC_WORKTREE_FAILED.
338
604
  *
@@ -885,6 +1151,40 @@ export async function executeOrchBatch(
885
1151
  }
886
1152
  }
887
1153
 
1154
+ // ── TP-055: Tier 0 — Model fallback retry ───────────────
1155
+ // Run model fallback BEFORE worker crash retry so that model_access_error
1156
+ // tasks are retried with session model first. Worker crash retry skips
1157
+ // model_access_error tasks (handled here instead).
1158
+ if (waveResult.failedTaskIds.length > 0) {
1159
+ const modelFallbackOutcome = await attemptModelFallbackRetry(
1160
+ waveResult,
1161
+ waveIdx,
1162
+ batchState,
1163
+ orchConfig,
1164
+ repoRoot,
1165
+ workspaceConfig,
1166
+ allTaskOutcomes,
1167
+ onNotify,
1168
+ stateRoot,
1169
+ runnerConfig,
1170
+ );
1171
+ if (modelFallbackOutcome.succeededRetries.length > 0) {
1172
+ // Recompute blocked tasks after model fallback successes
1173
+ if (waveResult.policyApplied === "skip-dependents" && waveResult.failedTaskIds.length > 0) {
1174
+ const recomputed = computeTransitiveDependents(
1175
+ new Set(waveResult.failedTaskIds),
1176
+ depGraph,
1177
+ );
1178
+ waveResult.blockedTaskIds = [...recomputed].sort();
1179
+ } else if (waveResult.failedTaskIds.length === 0) {
1180
+ waveResult.blockedTaskIds = [];
1181
+ }
1182
+ }
1183
+ if (modelFallbackOutcome.retriedCount > 0) {
1184
+ persistRuntimeState("tier0-model-fallback", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot);
1185
+ }
1186
+ }
1187
+
888
1188
  // ── TP-039: Tier 0 — Worker crash retry ─────────────────
889
1189
  // Run retry BEFORE accumulating counts and blocked tasks so that
890
1190
  // successfully retried tasks don't inflate failedTasks count and
@@ -1058,19 +1358,41 @@ export async function executeOrchBatch(
1058
1358
  laneCount: mergeableLaneCount,
1059
1359
  }, onEngineEvent);
1060
1360
 
1061
- mergeResult = await mergeWaveByRepo(
1062
- waveResult.allocatedLanes,
1063
- waveResult,
1064
- waveIdx + 1,
1065
- orchConfig,
1066
- repoRoot,
1067
- batchState.batchId,
1068
- batchState.orchBranch,
1069
- workspaceConfig,
1361
+ // TP-056: Start merge health monitor during merge phase
1362
+ const mergeHealthMonitor = new MergeHealthMonitor({
1070
1363
  stateRoot,
1071
- agentRoot,
1072
- runnerConfig.testing_commands,
1073
- );
1364
+ batchId: batchState.batchId,
1365
+ waveIndex: waveIdx,
1366
+ phase: batchState.phase,
1367
+ onDeadSession: (sessionName, laneNumber) => {
1368
+ execLog("batch", batchState.batchId, `merge health monitor detected dead session`, {
1369
+ sessionName,
1370
+ laneNumber,
1371
+ waveIndex: waveIdx,
1372
+ });
1373
+ },
1374
+ });
1375
+ mergeHealthMonitor.start();
1376
+
1377
+ try {
1378
+ mergeResult = await mergeWaveByRepo(
1379
+ waveResult.allocatedLanes,
1380
+ waveResult,
1381
+ waveIdx + 1,
1382
+ orchConfig,
1383
+ repoRoot,
1384
+ batchState.batchId,
1385
+ batchState.orchBranch,
1386
+ workspaceConfig,
1387
+ stateRoot,
1388
+ agentRoot,
1389
+ runnerConfig.testing_commands,
1390
+ mergeHealthMonitor,
1391
+ );
1392
+ } finally {
1393
+ // TP-056: Always stop the health monitor when merge phase ends
1394
+ mergeHealthMonitor.stop();
1395
+ }
1074
1396
  allMergeResults.push(mergeResult);
1075
1397
  batchState.mergeResults.push(mergeResult);
1076
1398
 
@@ -683,6 +683,7 @@ export function spawnLaneSession(
683
683
  config: OrchestratorConfig,
684
684
  repoRoot: string,
685
685
  workspaceRoot?: string,
686
+ extraEnvVars?: Record<string, string>,
686
687
  ): void {
687
688
  const sessionName = lane.tmuxSessionName;
688
689
  const laneId = lane.laneId;
@@ -706,6 +707,9 @@ export function spawnLaneSession(
706
707
 
707
708
  // Build env vars
708
709
  const envVars = buildLaneEnvVars(lane, task.task.promptPath, repoRoot, workspaceRoot);
710
+ if (extraEnvVars) {
711
+ Object.assign(envVars, extraEnvVars);
712
+ }
709
713
 
710
714
  // Prepare per-task lane log path for post-mortem diagnostics
711
715
  const laneLogPath = resolveLaneLogPath(lane, task);
@@ -1017,6 +1021,7 @@ export async function executeLane(
1017
1021
  pauseSignal: { paused: boolean },
1018
1022
  workspaceRoot?: string,
1019
1023
  isWorkspaceMode?: boolean,
1024
+ extraEnvVars?: Record<string, string>,
1020
1025
  ): Promise<LaneExecutionResult> {
1021
1026
  const laneId = lane.laneId;
1022
1027
  const laneStartTime = Date.now();
@@ -1053,7 +1058,7 @@ export async function executeLane(
1053
1058
 
1054
1059
  try {
1055
1060
  // Spawn TMUX session
1056
- spawnLaneSession(lane, task, config, repoRoot, workspaceRoot);
1061
+ spawnLaneSession(lane, task, config, repoRoot, workspaceRoot, extraEnvVars);
1057
1062
 
1058
1063
  // Poll until completion
1059
1064
  const pollResult = await pollUntilTaskComplete(