taskplane 0.6.0 → 0.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.
@@ -11,7 +11,7 @@ import { executeOrchBatch } from "./engine.ts";
11
11
  import { computeTransitiveDependents, execLog, executeWave, pollUntilTaskComplete, spawnLaneSession, tmuxHasSession } from "./execution.ts";
12
12
  import type { MonitorUpdateCallback } from "./execution.ts";
13
13
  import { getCurrentBranch, runGit } from "./git.ts";
14
- import { attemptAutoIntegration, mergeWaveByRepo } from "./merge.ts";
14
+ import { mergeWaveByRepo } from "./merge.ts";
15
15
  import { applyMergeRetryLoop, computeCleanupGatePolicy, computeMergeFailurePolicy, formatRepoMergeSummary, ORCH_MESSAGES } from "./messages.ts";
16
16
  import type { CleanupGateRepoFailure } from "./messages.ts";
17
17
  import { resolveOperatorId } from "./naming.ts";
@@ -285,6 +285,14 @@ export function checkResumeEligibility(state: PersistedBatchState, force: boolea
285
285
  batchId,
286
286
  };
287
287
 
288
+ case "launching":
289
+ return {
290
+ eligible: false,
291
+ reason: `Batch ${batchId} is currently launching. Wait for it to start or use /orch-abort.`,
292
+ phase,
293
+ batchId,
294
+ };
295
+
288
296
  case "planning":
289
297
  return {
290
298
  eligible: false,
@@ -388,10 +396,14 @@ export function reconcileTaskStates(
388
396
  };
389
397
  }
390
398
 
391
- // Precedence 5: Never-started task (pending + no session assigned) → remain pending
392
- // These are future-wave tasks that were never allocated to a lane.
393
- // They should be re-queued for execution, not failed.
394
- if (task.status === "pending" && !task.sessionName) {
399
+ // Precedence 5: Pending task that was never started → remain pending
400
+ // Matches two cases:
401
+ // (a) No session assigned at all (future-wave task never allocated)
402
+ // (b) Session assigned from a prior failed resume, but session is dead
403
+ // and worktree doesn't exist — task was allocated but never actually
404
+ // started (TP-037 bug #102b fix)
405
+ // In both cases the task should be re-queued for execution, not failed.
406
+ if (task.status === "pending" && (!task.sessionName || (!sessionAlive && !worktreeExists))) {
395
407
  return {
396
408
  taskId: task.taskId,
397
409
  persistedStatus: task.status,
@@ -417,11 +429,42 @@ export function reconcileTaskStates(
417
429
  });
418
430
  }
419
431
 
432
+ /**
433
+ * Get the latest merge status for a specific wave index (0-based).
434
+ *
435
+ * Persisted merge results may contain multiple entries for the same wave
436
+ * (e.g., re-exec sentinel merges clamped to wave 0, or retry attempts).
437
+ * This helper returns the latest entry's status for the given wave,
438
+ * preferring the last entry in array order (which is the most recent).
439
+ *
440
+ * @param mergeResults - Persisted merge results array
441
+ * @param waveIndex - 0-based wave index to look up
442
+ * @returns The merge status ("succeeded" | "failed" | "partial") or null if no entry exists
443
+ */
444
+ export function getMergeStatusForWave(
445
+ mergeResults: ReadonlyArray<{ waveIndex: number; status: "succeeded" | "failed" | "partial" }>,
446
+ waveIndex: number,
447
+ ): "succeeded" | "failed" | "partial" | null {
448
+ // Walk in reverse to find the latest entry for this wave
449
+ for (let i = mergeResults.length - 1; i >= 0; i--) {
450
+ if (mergeResults[i].waveIndex === waveIndex) {
451
+ return mergeResults[i].status;
452
+ }
453
+ }
454
+ return null;
455
+ }
456
+
420
457
  /**
421
458
  * Compute the resume point from reconciled task states and wave plan.
422
459
  *
423
460
  * Determines which wave to resume from by finding the first wave that
424
- * has any incomplete tasks. Skips fully completed waves.
461
+ * has any incomplete tasks. Skips fully completed waves only when
462
+ * their merge also succeeded.
463
+ *
464
+ * TP-037 (Bug #102): A wave where all tasks are terminal but the merge
465
+ * is missing or failed is NOT skipped — it is flagged for merge retry
466
+ * via `mergeRetryWaveIndexes`. The `resumeWaveIndex` is set to the
467
+ * earliest such wave so the resume loop can process it.
425
468
  *
426
469
  * Pure function — no process or filesystem access.
427
470
  *
@@ -477,8 +520,11 @@ export function computeResumePoint(
477
520
  }
478
521
  }
479
522
 
480
- // Find resume wave: first wave with any non-completed tasks
523
+ // Find resume wave: first wave with any non-completed tasks OR missing/failed merge.
524
+ // TP-037 (Bug #102): A wave where all tasks are terminal but the merge
525
+ // hasn't succeeded is flagged for merge retry, not skipped.
481
526
  let resumeWaveIndex = persistedState.wavePlan.length; // default: past end = all done
527
+ const mergeRetryWaveIndexes: number[] = [];
482
528
 
483
529
  for (let i = 0; i < persistedState.wavePlan.length; i++) {
484
530
  const waveTasks = persistedState.wavePlan[i];
@@ -499,9 +545,36 @@ export function computeResumePoint(
499
545
  });
500
546
 
501
547
  if (!allDone) {
502
- resumeWaveIndex = i;
548
+ // Only set resumeWaveIndex if not already set by a merge retry
549
+ // (merge retry at an earlier wave takes precedence)
550
+ if (resumeWaveIndex === persistedState.wavePlan.length) {
551
+ resumeWaveIndex = i;
552
+ }
503
553
  break;
504
554
  }
555
+
556
+ // TP-037 (Bug #102): All tasks are terminal — but did the merge succeed?
557
+ // Only check merge status if the wave had any succeeded tasks (waves with
558
+ // only failures/skips don't produce merges and can be safely skipped).
559
+ const hasSucceededTasks = waveTasks.some((taskId) => {
560
+ const reconciled = reconciledMap.get(taskId);
561
+ if (!reconciled) return false;
562
+ if (reconciled.action === "mark-complete") return true;
563
+ if (reconciled.action === "skip" && (reconciled.liveStatus === "succeeded" || reconciled.persistedStatus === "succeeded")) return true;
564
+ return false;
565
+ });
566
+
567
+ if (hasSucceededTasks && persistedState.mergeResults) {
568
+ const mergeStatus = getMergeStatusForWave(persistedState.mergeResults, i);
569
+ if (mergeStatus !== "succeeded") {
570
+ // Merge missing or failed — flag for retry, don't skip past this wave
571
+ mergeRetryWaveIndexes.push(i);
572
+ if (resumeWaveIndex === persistedState.wavePlan.length) {
573
+ // This is the first wave needing attention — set resume point here
574
+ resumeWaveIndex = i;
575
+ }
576
+ }
577
+ }
505
578
  }
506
579
 
507
580
  // Determine pending tasks: tasks in resume wave and later that need execution
@@ -539,6 +612,7 @@ export function computeResumePoint(
539
612
  failedTaskIds,
540
613
  reconnectTaskIds,
541
614
  reExecuteTaskIds,
615
+ mergeRetryWaveIndexes,
542
616
  };
543
617
  }
544
618
 
@@ -692,6 +766,11 @@ export async function resumeOrchBatch(
692
766
  `❌ Cannot resume: ${err.message}`,
693
767
  "error",
694
768
  );
769
+ // ── TP-040 R006: Reset phase on pre-execution early return ──
770
+ // The caller may have set batchState.phase = "launching" before
771
+ // calling this function. Since we're returning without starting
772
+ // any work, reset to "idle" so the batch isn't stuck.
773
+ batchState.phase = "idle";
695
774
  return;
696
775
  }
697
776
  throw err;
@@ -702,6 +781,8 @@ export async function resumeOrchBatch(
702
781
  ORCH_MESSAGES.resumeNoState(),
703
782
  "error",
704
783
  );
784
+ // TP-040 R006: Reset phase on pre-execution early return
785
+ batchState.phase = "idle";
705
786
  return;
706
787
  }
707
788
 
@@ -712,6 +793,8 @@ export async function resumeOrchBatch(
712
793
  ORCH_MESSAGES.resumePhaseNotResumable(persistedState.batchId, persistedState.phase, eligibility.reason),
713
794
  "error",
714
795
  );
796
+ // TP-040 R006: Reset phase on pre-execution early return
797
+ batchState.phase = "idle";
715
798
  return;
716
799
  }
717
800
 
@@ -732,6 +815,8 @@ export async function resumeOrchBatch(
732
815
  ORCH_MESSAGES.forceResumeDiagnosticsFailed(persistedState.batchId),
733
816
  "error",
734
817
  );
818
+ // TP-040 R006: Reset phase on pre-execution early return
819
+ batchState.phase = "idle";
735
820
  return;
736
821
  }
737
822
 
@@ -780,6 +865,33 @@ export async function resumeOrchBatch(
780
865
  // ── 4. Reconcile task states ─────────────────────────────────
781
866
  const reconciledTasks = reconcileTaskStates(persistedState, aliveSessions, doneTaskIds, existingWorktreeTaskIds);
782
867
 
868
+ // ── 4b. Clear stale session allocation for tasks reconciled as pending ──
869
+ // TP-037 (Bug #102b): Pending tasks that had a sessionName from a prior
870
+ // failed resume but were never actually started need their allocation
871
+ // metadata cleared so they can be freshly assigned to new lanes.
872
+ // We also prune these tasks from persisted lane records so that
873
+ // serializeBatchState() doesn't reintroduce stale sessionName via the
874
+ // `outcome?.sessionName || lane?.tmuxSessionName` fallback path.
875
+ const stalePendingTaskIds = new Set<string>();
876
+ for (const reconciled of reconciledTasks) {
877
+ if (reconciled.action === "pending") {
878
+ const persistedTask = persistedState.tasks.find(t => t.taskId === reconciled.taskId);
879
+ if (persistedTask && persistedTask.sessionName) {
880
+ execLog("resume", persistedState.batchId, `clear-stale-session: ${reconciled.taskId} had stale session "${persistedTask.sessionName}" (lane ${persistedTask.laneNumber})`);
881
+ stalePendingTaskIds.add(reconciled.taskId);
882
+ persistedTask.sessionName = "";
883
+ persistedTask.laneNumber = 0;
884
+ }
885
+ }
886
+ }
887
+ // Prune stale-pending tasks from lane records so reconstructAllocatedLanes()
888
+ // (and subsequent serializeBatchState()) won't map them back to the old lane.
889
+ if (stalePendingTaskIds.size > 0) {
890
+ for (const lane of persistedState.lanes) {
891
+ lane.taskIds = lane.taskIds.filter(id => !stalePendingTaskIds.has(id));
892
+ }
893
+ }
894
+
783
895
  // ── 5. Compute resume point ──────────────────────────────────
784
896
  const resumePoint = computeResumePoint(persistedState, reconciledTasks);
785
897
  const completedTaskSet = new Set(resumePoint.completedTaskIds);
@@ -813,6 +925,13 @@ export async function resumeOrchBatch(
813
925
  );
814
926
  }
815
927
 
928
+ if (resumePoint.mergeRetryWaveIndexes.length > 0) {
929
+ onNotify(
930
+ `🔀 ${resumePoint.mergeRetryWaveIndexes.length} wave(s) need merge retry: ${resumePoint.mergeRetryWaveIndexes.map(i => `W${i + 1}`).join(", ")}`,
931
+ "warning",
932
+ );
933
+ }
934
+
816
935
  // ── 6. Reconstruct runtime state ─────────────────────────────
817
936
 
818
937
  // Guard: orchBranch must be present for routing. Persisted states from
@@ -826,6 +945,8 @@ export async function resumeOrchBatch(
826
945
  `Use /orch-abort to clean up, then start a new batch.`,
827
946
  "error",
828
947
  );
948
+ // TP-040 R006: Reset phase on pre-execution early return
949
+ batchState.phase = "idle";
829
950
  return;
830
951
  }
831
952
 
@@ -836,7 +957,8 @@ export async function resumeOrchBatch(
836
957
 
837
958
  batchState.mode = persistedState.mode;
838
959
  batchState.startedAt = persistedState.startedAt;
839
- batchState.pauseSignal = { paused: false };
960
+ // Preserve pauseSignal if already set during "launching" phase (TP-040)
961
+ if (!batchState.pauseSignal?.paused) batchState.pauseSignal = { paused: false };
840
962
  batchState.totalWaves = persistedState.totalWaves;
841
963
  batchState.totalTasks = persistedState.totalTasks;
842
964
  batchState.succeededTasks = resumePoint.completedTaskIds.length;
@@ -1275,7 +1397,105 @@ export async function resumeOrchBatch(
1275
1397
  }
1276
1398
 
1277
1399
  if (waveTasks.length === 0) {
1278
- execLog("resume", batchState.batchId, `wave ${waveIdx + 1}: no tasks to execute (all completed/blocked)`);
1400
+ // TP-037 Bug #102: Check if this wave needs merge retry.
1401
+ // All tasks are terminal but the merge may have failed/been interrupted.
1402
+ if (resumePoint.mergeRetryWaveIndexes.includes(waveIdx)) {
1403
+ execLog("resume", batchState.batchId, `wave ${waveIdx + 1}: all tasks done but merge needs retry`);
1404
+ onNotify(`🔀 Wave ${waveIdx + 1}: retrying merge (tasks already complete, merge was missing/failed)`, "info");
1405
+
1406
+ // Reconstruct lanes for this wave from persisted state
1407
+ const waveTaskIds = new Set(persistedState.wavePlan[waveIdx]);
1408
+ const waveLaneRecords = persistedState.lanes.filter(
1409
+ lane => lane.taskIds.some(tid => waveTaskIds.has(tid)),
1410
+ );
1411
+ const mergeRetryLanes = reconstructAllocatedLanes(waveLaneRecords, persistedState.tasks);
1412
+
1413
+ // Build synthetic WaveExecutionResult with succeeded tasks
1414
+ const succeededTaskIds = persistedState.wavePlan[waveIdx].filter(
1415
+ taskId => completedTaskSet.has(taskId),
1416
+ );
1417
+ const syntheticLaneResults: LaneExecutionResult[] = mergeRetryLanes.map(lane => ({
1418
+ laneNumber: lane.laneNumber,
1419
+ laneId: lane.laneId,
1420
+ tasks: lane.tasks.map(t => ({
1421
+ taskId: t.taskId,
1422
+ status: (completedTaskSet.has(t.taskId) ? "succeeded" : "failed") as LaneTaskStatus,
1423
+ startTime: Date.now(),
1424
+ endTime: Date.now(),
1425
+ exitReason: completedTaskSet.has(t.taskId) ? "Task completed (merge retry)" : "Task failed (merge retry)",
1426
+ sessionName: lane.tmuxSessionName,
1427
+ doneFileFound: completedTaskSet.has(t.taskId),
1428
+ })),
1429
+ overallStatus: lane.tasks.every(t => completedTaskSet.has(t.taskId)) ? "succeeded" as const : "partial" as const,
1430
+ startTime: Date.now(),
1431
+ endTime: Date.now(),
1432
+ }));
1433
+
1434
+ const syntheticWaveResult: WaveExecutionResult = {
1435
+ waveIndex: waveIdx + 1,
1436
+ startedAt: Date.now(),
1437
+ endedAt: Date.now(),
1438
+ laneResults: syntheticLaneResults,
1439
+ policyApplied: orchConfig.failure.on_task_failure,
1440
+ stoppedEarly: false,
1441
+ failedTaskIds: [],
1442
+ skippedTaskIds: [],
1443
+ succeededTaskIds,
1444
+ blockedTaskIds: [],
1445
+ laneCount: mergeRetryLanes.length,
1446
+ overallStatus: "succeeded",
1447
+ finalMonitorState: null,
1448
+ allocatedLanes: mergeRetryLanes,
1449
+ };
1450
+
1451
+ batchState.phase = "merging";
1452
+ persistRuntimeState("merge-retry-start", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, stateRoot);
1453
+
1454
+ const mergeRetryResult = mergeWaveByRepo(
1455
+ mergeRetryLanes,
1456
+ syntheticWaveResult,
1457
+ waveIdx + 1,
1458
+ orchConfig,
1459
+ repoRoot,
1460
+ batchState.batchId,
1461
+ batchState.orchBranch,
1462
+ workspaceConfig,
1463
+ stateRoot,
1464
+ agentRoot,
1465
+ runnerConfig.testing_commands,
1466
+ );
1467
+ batchState.mergeResults.push(mergeRetryResult);
1468
+
1469
+ if (mergeRetryResult.status === "succeeded") {
1470
+ onNotify(`✅ Wave ${waveIdx + 1} merge retry succeeded`, "info");
1471
+ // Clean up merged branches
1472
+ for (const lr of mergeRetryResult.laneResults) {
1473
+ if (!lr.error && (lr.result?.status === "SUCCESS" || lr.result?.status === "CONFLICT_RESOLVED")) {
1474
+ const laneRepoRoot = resolveRepoRoot(lr.repoId, repoRoot, workspaceConfig);
1475
+ deleteBranchBestEffort(lr.sourceBranch, laneRepoRoot);
1476
+ }
1477
+ }
1478
+ } else {
1479
+ onNotify(
1480
+ `⚠️ Wave ${waveIdx + 1} merge retry ${mergeRetryResult.status}: ${mergeRetryResult.failureReason || "unknown"}`,
1481
+ "warning",
1482
+ );
1483
+ // Apply merge failure policy (same as normal wave merge failure)
1484
+ const policyResult = computeMergeFailurePolicy(mergeRetryResult, waveIdx, orchConfig);
1485
+ execLog("batch", batchState.batchId, `merge retry failure — applying ${policyResult.policy} policy`, policyResult.logDetails);
1486
+ batchState.phase = policyResult.targetPhase;
1487
+ batchState.errors.push(policyResult.errorMessage);
1488
+ persistRuntimeState(policyResult.persistTrigger, batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, stateRoot);
1489
+ onNotify(policyResult.notifyMessage, policyResult.notifyLevel);
1490
+ preserveWorktreesForResume = true;
1491
+ break;
1492
+ }
1493
+
1494
+ batchState.phase = "executing";
1495
+ persistRuntimeState("merge-retry-complete", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, stateRoot);
1496
+ } else {
1497
+ execLog("resume", batchState.batchId, `wave ${waveIdx + 1}: no tasks to execute (all completed/blocked)`);
1498
+ }
1279
1499
  continue;
1280
1500
  }
1281
1501
 
@@ -1891,21 +2111,18 @@ export async function resumeOrchBatch(
1891
2111
  // Parity with engine.ts: auto-integrate if configured, else show manual guidance.
1892
2112
  // Gate: only run for terminal phases (completed/failed). Paused/stopped batches
1893
2113
  // are not yet done — integration would mutate refs prematurely.
1894
- let autoIntegrated = false;
2114
+ //
2115
+ // TP-043: "supervised" and "auto" integration modes are now owned by the
2116
+ // supervisor agent. Legacy engine fast-forward is removed — supervisor
2117
+ // handles all non-manual integration after batch_complete event.
1895
2118
  const mergedTaskCount = batchState.succeededTasks;
1896
2119
  const isTerminalPhase = batchState.phase === "completed" || batchState.phase === "failed";
1897
2120
  if (isTerminalPhase && !preserveWorktreesForResume && batchState.orchBranch && mergedTaskCount > 0) {
1898
- if (orchConfig.orchestrator.integration === "auto") {
1899
- autoIntegrated = attemptAutoIntegration(
1900
- batchState.orchBranch,
1901
- batchState.baseBranch,
1902
- repoRoot,
1903
- batchState.batchId,
1904
- "resume",
1905
- onNotify,
1906
- );
1907
- }
1908
- if (!autoIntegrated) {
2121
+ if (orchConfig.orchestrator.integration === "supervised" || orchConfig.orchestrator.integration === "auto") {
2122
+ // TP-043: Supervisor-managed integration modes. Defer to supervisor.
2123
+ execLog("resume", batchState.batchId, `integration deferred to supervisor (mode: ${orchConfig.orchestrator.integration})`);
2124
+ } else {
2125
+ // Manual mode (default): show integration guidance
1909
2126
  onNotify(
1910
2127
  ORCH_MESSAGES.orchIntegrationManual(batchState.orchBranch, batchState.baseBranch, mergedTaskCount),
1911
2128
  "info",
@@ -1946,6 +2163,8 @@ export async function resumeOrchBatch(
1946
2163
  }
1947
2164
 
1948
2165
 
1949
- // attemptAutoIntegration is now a shared helper in merge.ts (TP-022 Step 4).
1950
- // Both engine.ts and resume.ts import it from there to eliminate parity drift.
2166
+ // TP-043: attemptAutoIntegration is no longer called from engine.ts or resume.ts.
2167
+ // Supervisor-managed integration ("supervised" and "auto" modes) is handled by
2168
+ // the supervisor agent after batch_complete. The helper remains in merge.ts for
2169
+ // use by the supervisor's integration flow.
1951
2170
 
@@ -2,7 +2,7 @@
2
2
  * Settings TUI — interactive configuration viewer and editor.
3
3
  *
4
4
  * Provides a `/taskplane-settings` command that renders a two-level navigation:
5
- * 1. Section selector (12 sections)
5
+ * 1. Section selector (13 sections)
6
6
  * 2. Per-section SettingsList with field display, source badges,
7
7
  * and inline editing for enum/boolean/string/number fields
8
8
  *
@@ -87,7 +87,7 @@ export interface SectionDef {
87
87
  // ── Section & Field Definitions ──────────────────────────────────────
88
88
 
89
89
  /**
90
- * Canonical navigation map — 12 sections.
90
+ * Canonical navigation map — 13 sections.
91
91
  * Order matches the Step 1 design in STATUS.md.
92
92
  */
93
93
  export const SECTIONS: SectionDef[] = [
@@ -102,7 +102,7 @@ export const SECTIONS: SectionDef[] = [
102
102
  // The user-facing spawn mode setting is under Worker (controls /task behavior).
103
103
  { configPath: "orchestrator.orchestrator.tmuxPrefix", label: "Tmux Prefix", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "tmuxPrefix", description: "Prefix for orchestrator tmux sessions" },
104
104
  { configPath: "orchestrator.orchestrator.operatorId", label: "Operator ID", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "operatorId", description: "Operator identifier (empty = auto-detect)" },
105
- { configPath: "orchestrator.orchestrator.integration", label: "Integration", control: "toggle", layer: "L1", fieldType: "enum", values: ["manual", "auto"], description: "How completed batches are integrated. manual = user runs /orch-integrate. auto = fast-forward on completion." },
105
+ { configPath: "orchestrator.orchestrator.integration", label: "Integration", control: "toggle", layer: "L1", fieldType: "enum", values: ["manual", "supervised", "auto"], description: "How completed batches are integrated. manual = user runs /orch-integrate. supervised = supervisor proposes plan, asks confirmation. auto = supervisor executes without asking." },
106
106
  ],
107
107
  },
108
108
  {
@@ -149,6 +149,13 @@ export const SECTIONS: SectionDef[] = [
149
149
  { configPath: "orchestrator.monitoring.pollInterval", label: "Poll Interval (sec)", control: "input", layer: "L1", fieldType: "number", description: "Poll interval for lane/task monitoring (seconds)" },
150
150
  ],
151
151
  },
152
+ {
153
+ name: "Supervisor",
154
+ fields: [
155
+ { configPath: "orchestrator.supervisor.model", label: "Supervisor Model", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "supervisorModel", description: "Supervisor model (empty = inherit session)" },
156
+ { configPath: "orchestrator.supervisor.autonomy", label: "Autonomy Level", control: "toggle", layer: "L1", fieldType: "enum", values: ["interactive", "supervised", "autonomous"], description: "Recovery action confirmation behavior" },
157
+ ],
158
+ },
152
159
  {
153
160
  name: "Worker",
154
161
  fields: [