taskplane 0.29.2 → 0.30.1

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.
Files changed (43) hide show
  1. package/bin/gitignore-patterns.mjs +11 -8
  2. package/bin/rpc-wrapper.mjs +410 -357
  3. package/bin/taskplane.mjs +533 -250
  4. package/dashboard/public/app.js +124 -15
  5. package/dashboard/public/style.css +83 -2
  6. package/extensions/reviewer-extension.ts +17 -11
  7. package/extensions/taskplane/abort.ts +50 -18
  8. package/extensions/taskplane/agent-bridge-extension.ts +232 -105
  9. package/extensions/taskplane/agent-host.ts +224 -97
  10. package/extensions/taskplane/cleanup.ts +71 -42
  11. package/extensions/taskplane/config-loader.ts +142 -58
  12. package/extensions/taskplane/config-schema.ts +6 -13
  13. package/extensions/taskplane/config.ts +10 -2
  14. package/extensions/taskplane/diagnostic-reports.ts +59 -47
  15. package/extensions/taskplane/diagnostics.ts +13 -13
  16. package/extensions/taskplane/discovery.ts +78 -63
  17. package/extensions/taskplane/engine-worker.ts +53 -46
  18. package/extensions/taskplane/engine.ts +1760 -602
  19. package/extensions/taskplane/execution.ts +469 -207
  20. package/extensions/taskplane/extension.ts +1073 -598
  21. package/extensions/taskplane/formatting.ts +136 -124
  22. package/extensions/taskplane/git.ts +0 -2
  23. package/extensions/taskplane/lane-runner.ts +652 -319
  24. package/extensions/taskplane/mailbox.ts +57 -49
  25. package/extensions/taskplane/merge.ts +662 -383
  26. package/extensions/taskplane/messages.ts +109 -51
  27. package/extensions/taskplane/migrations.ts +1 -1
  28. package/extensions/taskplane/path-resolver.ts +8 -9
  29. package/extensions/taskplane/persistence.ts +425 -262
  30. package/extensions/taskplane/process-registry.ts +36 -7
  31. package/extensions/taskplane/quality-gate.ts +107 -55
  32. package/extensions/taskplane/resume.ts +832 -280
  33. package/extensions/taskplane/sessions.ts +1 -1
  34. package/extensions/taskplane/settings-tui.ts +505 -164
  35. package/extensions/taskplane/sidecar-telemetry.ts +25 -10
  36. package/extensions/taskplane/supervisor.ts +477 -270
  37. package/extensions/taskplane/task-executor-core.ts +178 -53
  38. package/extensions/taskplane/types.ts +209 -108
  39. package/extensions/taskplane/verification.ts +27 -22
  40. package/extensions/taskplane/waves.ts +59 -43
  41. package/extensions/taskplane/workspace.ts +14 -12
  42. package/extensions/taskplane/worktree.ts +218 -196
  43. package/package.json +14 -2
@@ -382,6 +382,53 @@ function taskSegmentProgress(task, segmentStatusMap, forcedActiveSegmentId) {
382
382
  };
383
383
  }
384
384
 
385
+ // TP-197 (#464): Render a horizontal pill row of per-segment status badges for a
386
+ // multi-segment task. Each pill shows an icon + repoId for one segment. The icon
387
+ // reflects the segment's status (succeeded / running / pending / failed / stalled /
388
+ // skipped). The current segment (the one actively executing on its lane) gets an
389
+ // emphasis class. Returns "" for single-segment tasks so the rendered DOM is
390
+ // byte-identical to today for the non-segmented common case (no regression).
391
+ //
392
+ // Consumes:
393
+ // - task.segmentIds: string[] (ordered, from PersistedTaskRecord)
394
+ // - segmentStatusMap: Map<segmentId, PersistedSegmentStatus> built by
395
+ // buildSegmentStatusMap() from batch.segments[]
396
+ // - activeSegmentId: string|null — current executing segment (from V2 lane
397
+ // snapshot's segmentId, or the task's activeSegmentId field)
398
+ function taskSegmentPillRow(task, segmentStatusMap, activeSegmentId) {
399
+ const segmentIds = Array.isArray(task?.segmentIds)
400
+ ? task.segmentIds.filter(id => typeof id === "string")
401
+ : [];
402
+ if (segmentIds.length <= 1) return "";
403
+
404
+ // Status -> { icon, className } table. Keep emoji simple/monospace-friendly.
405
+ // ✅ succeeded, ⏳ running, ⬚ pending, ❌ failed, ⏸ stalled, ↷ skipped.
406
+ const styles = {
407
+ succeeded: { icon: "\u2705", cls: "seg-succeeded" },
408
+ running: { icon: "\u23F3", cls: "seg-running" },
409
+ pending: { icon: "\u2B1A", cls: "seg-pending" },
410
+ failed: { icon: "\u274C", cls: "seg-failed" },
411
+ stalled: { icon: "\u23F8", cls: "seg-stalled" },
412
+ skipped: { icon: "\u21B7", cls: "seg-skipped" },
413
+ };
414
+
415
+ const pills = segmentIds.map((segId) => {
416
+ const status = segmentStatusMap.get(segId) || "pending";
417
+ const style = styles[status] || styles.pending;
418
+ const parsed = parseSegmentId(segId);
419
+ const repoLabel = parsed?.repoId || segId;
420
+ const isCurrent = activeSegmentId && segId === activeSegmentId;
421
+ const currentCls = isCurrent ? " seg-pill-current" : "";
422
+ const title = `${segId} \u00b7 ${status}`;
423
+ return `<span class="seg-pill ${style.cls}${currentCls}" title="${escapeHtml(title)}">`
424
+ + `<span class="seg-pill-icon">${style.icon}</span>`
425
+ + `<span class="seg-pill-label">${escapeHtml(repoLabel)}</span>`
426
+ + `</span>`;
427
+ }).join("");
428
+
429
+ return `<div class="task-segment-row">${pills}</div>`;
430
+ }
431
+
385
432
  function laneActiveSegmentInfo(v2snap, laneTasks, segmentStatusMap) {
386
433
  if (!v2snap || !v2snap.segmentId) return null;
387
434
  const parsed = parseSegmentId(v2snap.segmentId);
@@ -620,7 +667,13 @@ function renderSummary(batch) {
620
667
  // their assigned lane: tasks on the same lane render with `→` (serial),
621
668
  // tasks on different lanes render with ` | ` (parallel). Tooltip shows
622
669
  // the expanded lane breakdown.
623
- const { compact, tooltip } = formatWaveLaneBreakdown(taskIds, batch.lanes || [], i + 1);
670
+ // TP-197 post-merge fold: pass `batch.tasks` as the task→lane source.
671
+ // The previous arg `batch.lanes` only carries live Runtime V2 lane
672
+ // state for the *currently active* wave — past/future wave chips
673
+ // would fall back to comma-separated. `batch.tasks[].laneNumber` is
674
+ // persisted for the entire batch lifecycle, so all waves render with
675
+ // the correct parallelization separator regardless of active state.
676
+ const { compact, tooltip } = formatWaveLaneBreakdown(taskIds, batch.lanes || [], batch.tasks || [], i + 1);
624
677
  const titleAttr = tooltip ? ` title="${escapeHtml(tooltip)}"` : "";
625
678
  wavesHtml += `<span class="wave-chip ${cls}"${titleAttr}>W${i + 1} [${compact}]</span>`;
626
679
  });
@@ -648,16 +701,46 @@ function renderSummary(batch) {
648
701
  * are shown with the previous flat formatting and no tooltip is generated
649
702
  * — this preserves backward compatibility with future-wave display.
650
703
  */
651
- function formatWaveLaneBreakdown(taskIds, lanes, waveNumber) {
704
+ function formatWaveLaneBreakdown(taskIds, lanes, tasks, waveNumber) {
652
705
  if (!Array.isArray(taskIds) || taskIds.length === 0) {
653
706
  return { compact: "", tooltip: "" };
654
707
  }
655
- // Build taskId → laneNumber map for the lanes that have any of these tasks.
708
+ // Build taskId → laneNumber map. Prefer the persisted-per-task
709
+ // `tasks[i].laneNumber` (covers all waves, lifecycle-stable). Fall back
710
+ // to live `lanes[]` only when tasks data is missing or doesn't carry
711
+ // laneNumber for a given task.
712
+ //
713
+ // TP-197 post-merge fold: the previous implementation read ONLY from
714
+ // `lanes`, which is Runtime V2 live state and only populated for the
715
+ // currently active wave. That caused inactive waves' chips to fall back
716
+ // to comma-separated display (no parallelization indicator), giving the
717
+ // impression that the separator changed as the batch progressed. Using
718
+ // the persisted `tasks[].laneNumber` makes the indicator stable across
719
+ // all waves regardless of active state.
656
720
  const taskToLane = new Map();
721
+ if (Array.isArray(tasks)) {
722
+ for (const t of tasks) {
723
+ // Persistence assigns `laneNumber: 0` as a sentinel meaning
724
+ // "unallocated" (see persistence.ts:1378 — `lane?.laneNumber ??
725
+ // outcome?.laneNumber ?? 0`). Real lane numbers start at 1. We must
726
+ // skip 0 here so future-wave tasks (which all have the 0 sentinel
727
+ // until their wave starts) don't get falsely grouped under a fake
728
+ // "lane 0" and rendered as serial.
729
+ if (
730
+ t &&
731
+ t.taskId &&
732
+ typeof t.laneNumber === "number" &&
733
+ t.laneNumber >= 1 &&
734
+ !taskToLane.has(t.taskId)
735
+ ) {
736
+ taskToLane.set(t.taskId, t.laneNumber);
737
+ }
738
+ }
739
+ }
740
+ // Fallback: anything `tasks` didn't cover, try `lanes` (live state).
657
741
  for (const lane of lanes) {
658
742
  if (!lane || !Array.isArray(lane.taskIds)) continue;
659
743
  for (const tid of lane.taskIds) {
660
- // First lane to claim a task wins (lanes shouldn't overlap, but be defensive).
661
744
  if (!taskToLane.has(tid)) taskToLane.set(tid, lane.laneNumber);
662
745
  }
663
746
  }
@@ -859,8 +942,24 @@ function renderLanesTasks(batch, sessions) {
859
942
  stepHtml = `<span style="color:var(--text-faint)">${escapeHtml(task.exitReason || "—")}</span>`;
860
943
  }
861
944
 
945
+ // TP-197 (#464): Compute the per-segment pill row for multi-segment tasks.
946
+ // Returns "" for single-segment tasks (no DOM regression for the common case).
947
+ // For multi-segment tasks we render the pill row in the task-row's grid row 3
948
+ // (via .task-segment-row CSS) and suppress the inline "Segment N/T: repo" text
949
+ // in detailBits to avoid duplicating signal — the pill row already shows the
950
+ // current segment (via seg-pill-current) and total count (via pill count).
951
+ const segmentPillRowHtml = taskSegmentPillRow(
952
+ task,
953
+ segmentStatusMap,
954
+ v2snap && v2snap.taskId === task.taskId ? v2snap.segmentId : (segmentInfo?.segmentId || null),
955
+ );
956
+ const hasSegmentPillRow = segmentPillRowHtml !== "";
957
+
862
958
  const detailBits = [];
863
- if (segmentInfo) {
959
+ if (segmentInfo && !hasSegmentPillRow) {
960
+ // Single-segment + non-segmented tasks: existing inline text (unchanged).
961
+ // Multi-segment tasks: suppressed because the new pill row carries the same
962
+ // information more legibly.
864
963
  detailBits.push(`<span class="task-segment-progress" title="${escapeHtml(segmentInfo.segmentId || segmentProgressText(segmentInfo))}">${escapeHtml(segmentProgressText(segmentInfo))}</span>`);
865
964
  }
866
965
  if (showPacketHome) {
@@ -950,8 +1049,17 @@ function renderLanesTasks(batch, sessions) {
950
1049
  const titleHtml = task.taskTitle
951
1050
  ? `<div class="task-title-subtitle">${escapeHtml(task.taskTitle)}</div>`
952
1051
  : "";
1052
+ // TP-197 (#464): segmentPillRowHtml is empty for single-segment tasks so
1053
+ // the rendered DOM is byte-identical to today for non-segmented tasks.
1054
+ // For multi-segment tasks it renders as grid-row 3 of .task-row.
1055
+ // Sage post-merge fold: the .has-segments class opts the .task-row
1056
+ // grid into a 3-row template only when we actually have a pill row;
1057
+ // otherwise the default 2-row template preserves single-segment task
1058
+ // spacing exactly (an unconditional 3-row template would add an 8px
1059
+ // row-gap even when row 3 is empty, breaking the no-regression contract).
1060
+ const taskRowClass = hasSegmentPillRow ? "task-row has-segments" : "task-row";
953
1061
  html += `
954
- <div class="task-row">
1062
+ <div class="${taskRowClass}">
955
1063
  <span class="task-icon"><span class="status-dot ${task.status}"></span></span>
956
1064
  <span class="task-actions">${eyeHtml}</span>
957
1065
  <span class="task-id status-${task.status}">${escapeHtml(task.taskId)}${showRepos ? repoBadgeHtml(tRepo, "repo-badge-task") : ""}</span>
@@ -960,6 +1068,7 @@ function renderLanesTasks(batch, sessions) {
960
1068
  <span>${progressHtml}</span>
961
1069
  <span class="task-step">${stepHtml}${workerHtml}</span>
962
1070
  ${titleHtml}
1071
+ ${segmentPillRowHtml}
963
1072
  </div>`;
964
1073
  html += reviewerRowHtml;
965
1074
  }
@@ -1054,7 +1163,15 @@ function renderMergeAgents(batch, sessions) {
1054
1163
  }
1055
1164
 
1056
1165
  let html = '<table class="merge-table"><thead><tr>';
1057
- html += '<th>Wave</th><th>Status</th><th>Session</th><th>Telemetry</th><th>Session ID</th><th>Details</th>';
1166
+ // TP-197 post-merge fold: removed 'Session ID' and 'Details' columns.
1167
+ // SESSION ID was hardcoded to '—' in every row — dead weight.
1168
+ // DETAILS only populated for `mr.failureReason` (rare failure cases);
1169
+ // for the common all-merges-succeeded case it's always '—' too.
1170
+ // When a real failure happens, the operator sees status='failed' in
1171
+ // the Status column and can dig into engine logs for the reason —
1172
+ // we'll re-add a focused DETAILS column if/when we have meaningful
1173
+ // structured failure-reason data to surface in the dashboard table.
1174
+ html += '<th>Wave</th><th>Status</th><th>Session</th><th>Telemetry</th>';
1058
1175
  html += '</tr></thead><tbody>';
1059
1176
 
1060
1177
  // Track sessions shown in wave result rows so we don't duplicate them below
@@ -1135,10 +1252,6 @@ function renderMergeAgents(batch, sessions) {
1135
1252
  html += `<td class="merge-session-cell">${effectiveAlive ? escapeHtml(effectiveSession) : "—"}</td>`;
1136
1253
  // Full telemetry cell
1137
1254
  html += `<td class="merge-telemetry-cell">${mergeTelemetryHtml(mergeTel, effectiveAlive)}</td>`;
1138
- html += `<td>`;
1139
- html += '<span class="merge-no-data">—</span>';
1140
- html += `</td>`;
1141
- html += `<td class="merge-detail-cell">${mr.failureReason ? escapeHtml(mr.failureReason) : "—"}</td>`;
1142
1255
  html += `</tr>`;
1143
1256
 
1144
1257
  // Per-repo sub-rows: show when workspace mode has repo results
@@ -1159,8 +1272,6 @@ function renderMergeAgents(batch, sessions) {
1159
1272
  html += `<td><span class="status-badge ${rrStatusCls}">${rr.status}</span></td>`;
1160
1273
  html += `<td class="merge-session-cell">${rrLanes}</td>`;
1161
1274
  html += `<td></td>`; /* telemetry placeholder */
1162
- html += `<td></td>`; /* attach placeholder */
1163
- html += `<td class="merge-detail-cell">${rrDetail}</td>`;
1164
1275
  html += `</tr>`;
1165
1276
  }
1166
1277
  }
@@ -1177,8 +1288,6 @@ function renderMergeAgents(batch, sessions) {
1177
1288
  html += `<td class="merge-session-cell">${escapeHtml(sess)}</td>`;
1178
1289
  // Full telemetry cell for active merge session
1179
1290
  html += `<td class="merge-telemetry-cell">${mergeTelemetryHtml(sessTel, true)}</td>`;
1180
- html += `<td>—</td>`;
1181
- html += `<td>—</td>`;
1182
1291
  html += `</tr>`;
1183
1292
  }
1184
1293
 
@@ -608,7 +608,13 @@ body {
608
608
  display: grid;
609
609
  grid-template-columns: 36px 24px 100px 90px 80px 200px 1fr;
610
610
  /* #485 (revised): row 1 holds the primary cells; row 2 (auto, collapses to
611
- * 0 when empty) holds the optional task-title-subtitle spanning cols 3–6. */
611
+ * 0 when empty) holds the optional task-title-subtitle spanning cols 3–6.
612
+ * Default: 2 rows. TP-197 (#464) adds the optional per-segment pill row in
613
+ * grid-row 3 ONLY when the .has-segments class is set (multi-segment tasks).
614
+ * Non-segmented tasks keep the pre-TP-197 2-row layout exactly — 'auto'
615
+ * row tracks don't fully collapse with row-gap declared, so adding a third
616
+ * row track unconditionally would introduce an 8px visible gap for every
617
+ * single-segment task (sage post-merge fold). */
612
618
  grid-template-rows: auto auto;
613
619
  align-items: center;
614
620
  gap: 8px 8px;
@@ -617,6 +623,13 @@ body {
617
623
  transition: background 0.15s;
618
624
  }
619
625
 
626
+ /* TP-197 (#464): multi-segment tasks opt-in to a 3-row grid for the segment-
627
+ * pill row. JS adds .has-segments to .task-row only when the pill row is
628
+ * non-empty (see app.js taskSegmentPillRow + task-row template). */
629
+ .task-row.has-segments {
630
+ grid-template-rows: auto auto auto;
631
+ }
632
+
620
633
  .task-row:last-child { border-bottom: none; }
621
634
  .task-row:hover { background: var(--bg-surface-hover); }
622
635
 
@@ -652,6 +665,71 @@ body {
652
665
  margin-top: -2px;
653
666
  }
654
667
 
668
+ /* ─── TP-197 (#464): Per-segment pill row for multi-segment tasks ─────── */
669
+
670
+ .task-segment-row {
671
+ /* Sub-row beneath the primary task row, mirroring the title-subtitle pattern
672
+ * (TP-485). Spans cols 3 → 7 so it shares the title area's horizontal space.
673
+ * Placed at grid row 3 so it sits *below* the optional title-subtitle.
674
+ * Container collapses to 0 height when not rendered (single-segment tasks),
675
+ * so non-segmented tasks render with identical row height to today.
676
+ * Lives OUTSIDE .task-step intentionally so the @media (max-width: 900px)
677
+ * rule that hides .task-step does NOT hide the pill row — keeps segment
678
+ * context visible at narrow viewports. */
679
+ grid-column: 3 / 7;
680
+ grid-row: 3;
681
+ display: flex;
682
+ flex-wrap: wrap;
683
+ align-items: center;
684
+ gap: 4px;
685
+ margin-top: 2px;
686
+ min-width: 0;
687
+ }
688
+
689
+ .seg-pill {
690
+ display: inline-flex;
691
+ align-items: center;
692
+ gap: 3px;
693
+ font-family: var(--font-mono);
694
+ font-size: 0.68rem;
695
+ font-weight: 500;
696
+ line-height: 1.4;
697
+ padding: 1px 7px;
698
+ border-radius: 8px;
699
+ border: 1px solid transparent;
700
+ max-width: 140px;
701
+ white-space: nowrap;
702
+ }
703
+
704
+ .seg-pill .seg-pill-icon {
705
+ font-size: 0.72rem;
706
+ line-height: 1;
707
+ flex-shrink: 0;
708
+ }
709
+
710
+ .seg-pill .seg-pill-label {
711
+ overflow: hidden;
712
+ text-overflow: ellipsis;
713
+ min-width: 0;
714
+ }
715
+
716
+ /* Status variants — reuse the existing status-badge color tokens for
717
+ * consistency with .status-badge.status-{succeeded,running,failed,…}. */
718
+ .seg-pill.seg-succeeded { background: var(--badge-succeeded-bg); color: var(--green); }
719
+ .seg-pill.seg-running { background: var(--badge-running-bg); color: var(--accent); }
720
+ .seg-pill.seg-pending { background: var(--bg-surface); color: var(--text-muted); border-color: var(--border-default, var(--border-subtle)); }
721
+ .seg-pill.seg-failed { background: var(--badge-failed-bg); color: var(--red); }
722
+ .seg-pill.seg-stalled { background: var(--badge-failed-bg); color: var(--yellow, var(--red)); opacity: 0.85; }
723
+ .seg-pill.seg-skipped { background: var(--bg-surface); color: var(--text-muted); opacity: 0.7; }
724
+
725
+ /* Current-segment emphasis: brighter border + slight weight bump so operator
726
+ * can spot "we're here right now" at a glance independent of icon. */
727
+ .seg-pill.seg-pill-current {
728
+ border-color: var(--accent);
729
+ font-weight: 600;
730
+ box-shadow: 0 0 0 1px var(--accent-dim, transparent);
731
+ }
732
+
655
733
  .task-duration {
656
734
  font-family: var(--font-mono);
657
735
  font-size: 0.8rem;
@@ -783,7 +861,10 @@ body {
783
861
  font-weight: 600;
784
862
  text-transform: uppercase;
785
863
  letter-spacing: 0.05em;
786
- color: var(--text-faint);
864
+ /* TP-197 post-merge fold: bumped --text-faint → --text-muted for
865
+ * readability. Matches other dashboard section headers (lines 224, 248,
866
+ * 458 of this file already use --text-muted for the same role). */
867
+ color: var(--text-muted);
787
868
  border-bottom: 1px solid var(--border);
788
869
  background: var(--bg-surface);
789
870
  }
@@ -48,7 +48,8 @@ export default function reviewerExtension(pi: ExtensionAPI) {
48
48
  "Block until the next review request is available, then return its content. " +
49
49
  "Call this after completing each review to wait for the next one. " +
50
50
  "Returns 'SHUTDOWN' when the task is complete and you should exit.",
51
- promptSnippet: "wait_for_review() — block until the next review request arrives (persistent reviewer mode)",
51
+ promptSnippet:
52
+ "wait_for_review() — block until the next review request arrives (persistent reviewer mode)",
52
53
  promptGuidelines: [
53
54
  "Call wait_for_review() to receive each review request.",
54
55
  "After writing your review to the specified output file, call wait_for_review() again.",
@@ -82,11 +83,14 @@ export default function reviewerExtension(pi: ExtensionAPI) {
82
83
  if (!existsSync(requestPath)) {
83
84
  // Signal fired but request file doesn't exist (race condition or error)
84
85
  return {
85
- content: [{
86
- type: "text" as const,
87
- text: `ERROR Signal file ${REVIEWER_SIGNAL_PREFIX}${signalNum} found but ` +
88
- `${signalContent} does not exist. Waiting for next signal.`,
89
- }],
86
+ content: [
87
+ {
88
+ type: "text" as const,
89
+ text:
90
+ `ERROR — Signal file ${REVIEWER_SIGNAL_PREFIX}${signalNum} found but ` +
91
+ `${signalContent} does not exist. Waiting for next signal.`,
92
+ },
93
+ ],
90
94
  details: undefined,
91
95
  };
92
96
  }
@@ -103,16 +107,18 @@ export default function reviewerExtension(pi: ExtensionAPI) {
103
107
  // Check timeout
104
108
  if (Date.now() - startTime > REVIEWER_WAIT_TIMEOUT_MS) {
105
109
  return {
106
- content: [{
107
- type: "text" as const,
108
- text: "TIMEOUT No review request received within the timeout period. Exit cleanly.",
109
- }],
110
+ content: [
111
+ {
112
+ type: "text" as const,
113
+ text: "TIMEOUT — No review request received within the timeout period. Exit cleanly.",
114
+ },
115
+ ],
110
116
  details: undefined,
111
117
  };
112
118
  }
113
119
 
114
120
  // Wait before next poll
115
- await new Promise(resolve => setTimeout(resolve, REVIEWER_POLL_INTERVAL_MS));
121
+ await new Promise((resolve) => setTimeout(resolve, REVIEWER_POLL_INTERVAL_MS));
116
122
  }
117
123
  },
118
124
  });
@@ -8,7 +8,18 @@ import { join } from "path";
8
8
  import { execLog, killV2LaneAgents, resolveCanonicalTaskPaths } from "./execution.ts";
9
9
  import { killMergeAgentV2, killAllMergeAgentsV2 } from "./merge.ts";
10
10
  import { deleteBatchState, persistRuntimeState } from "./persistence.ts";
11
- import type { AbortActionStep, AbortErrorCode, AbortLaneResult, AbortMode, AbortResult, AbortTargetSession, AllocatedLane, OrchBatchRuntimeState, PersistedBatchState, PersistedLaneRecord } from "./types.ts";
11
+ import type {
12
+ AbortActionStep,
13
+ AbortErrorCode,
14
+ AbortLaneResult,
15
+ AbortMode,
16
+ AbortResult,
17
+ AbortTargetSession,
18
+ AllocatedLane,
19
+ OrchBatchRuntimeState,
20
+ PersistedBatchState,
21
+ PersistedLaneRecord,
22
+ } from "./types.ts";
12
23
 
13
24
  // ── Abort Pure Functions ─────────────────────────────────────────────
14
25
 
@@ -37,7 +48,7 @@ export function selectAbortTargetSessions(
37
48
  // Filter to only lane and merge sessions for the exact orchestrator prefix.
38
49
  // Handles both repo-mode (`<prefix>-lane-<N>`) and workspace-mode
39
50
  // (`<prefix>-<repoId>-lane-<N>`) session name formats.
40
- const targetNames = allSessionNames.filter(name => {
51
+ const targetNames = allSessionNames.filter((name) => {
41
52
  const prefixWithDash = `${prefix}-`;
42
53
  if (!name.startsWith(prefixWithDash)) return false;
43
54
  const suffix = name.slice(prefixWithDash.length);
@@ -78,7 +89,10 @@ export function selectAbortTargetSessions(
78
89
  }
79
90
 
80
91
  // Build lookup from runtime lanes
81
- const runtimeLookup = new Map<string, { laneId: string; taskId: string | null; worktreePath: string; taskFolder: string | null }>();
92
+ const runtimeLookup = new Map<
93
+ string,
94
+ { laneId: string; taskId: string | null; worktreePath: string; taskFolder: string | null }
95
+ >();
82
96
  for (const lane of runtimeLanes) {
83
97
  const currentTask = lane.tasks.length > 0 ? lane.tasks[0] : null;
84
98
  runtimeLookup.set(lane.laneSessionId, {
@@ -90,7 +104,7 @@ export function selectAbortTargetSessions(
90
104
  });
91
105
  }
92
106
 
93
- return targetNames.map(sessionName => {
107
+ return targetNames.map((sessionName) => {
94
108
  const runtime = runtimeLookup.get(sessionName);
95
109
  const persisted = persistedLookup.get(sessionName);
96
110
 
@@ -184,7 +198,6 @@ export function discoverAbortSessionNames(
184
198
  return [...names];
185
199
  }
186
200
 
187
-
188
201
  // ── Abort Orchestration Functions ────────────────────────────────────
189
202
 
190
203
  /**
@@ -207,10 +220,18 @@ export function writeWrapUpFiles(
207
220
  if (!target.taskFolderInWorktree) {
208
221
  // Skip child sessions (workers, reviewers) — only main lane sessions have task folders
209
222
  // Also skip merge sessions (no task folder)
210
- if (target.sessionName.endsWith("-worker") || target.sessionName.endsWith("-reviewer") || target.sessionName.includes("merge")) {
223
+ if (
224
+ target.sessionName.endsWith("-worker") ||
225
+ target.sessionName.endsWith("-reviewer") ||
226
+ target.sessionName.includes("merge")
227
+ ) {
211
228
  results.push({ sessionName: target.sessionName, written: false, error: null });
212
229
  } else {
213
- results.push({ sessionName: target.sessionName, written: false, error: "No task folder resolved" });
230
+ results.push({
231
+ sessionName: target.sessionName,
232
+ written: false,
233
+ error: "No task folder resolved",
234
+ });
214
235
  }
215
236
  continue;
216
237
  }
@@ -220,7 +241,11 @@ export function writeWrapUpFiles(
220
241
 
221
242
  // Ensure directory exists
222
243
  if (!existsSync(target.taskFolderInWorktree)) {
223
- results.push({ sessionName: target.sessionName, written: false, error: `Task folder does not exist: ${target.taskFolderInWorktree}` });
244
+ results.push({
245
+ sessionName: target.sessionName,
246
+ written: false,
247
+ error: `Task folder does not exist: ${target.taskFolderInWorktree}`,
248
+ });
224
249
  continue;
225
250
  }
226
251
 
@@ -262,7 +287,7 @@ export async function waitForSessionExit(
262
287
  const deadline = Date.now() + gracePeriodMs;
263
288
  while (Date.now() < deadline) {
264
289
  const sleepMs = Math.max(1, Math.min(pollIntervalMs, deadline - Date.now()));
265
- await new Promise(r => setTimeout(r, sleepMs));
290
+ await new Promise((r) => setTimeout(r, sleepMs));
266
291
  }
267
292
 
268
293
  return { exited: [], remaining: [...sessionNames] };
@@ -357,7 +382,11 @@ export async function executeAbort(
357
382
  repoRoot,
358
383
  );
359
384
  } catch (err) {
360
- execLog("abort", batchState.batchId, `Failed to persist state during abort: ${err instanceof Error ? err.message : String(err)}`);
385
+ execLog(
386
+ "abort",
387
+ batchState.batchId,
388
+ `Failed to persist state during abort: ${err instanceof Error ? err.message : String(err)}`,
389
+ );
361
390
  }
362
391
 
363
392
  // TP-108: Kill all V2 merge agents (process-owned, not TMUX)
@@ -370,7 +399,11 @@ export async function executeAbort(
370
399
  // Step 3: Discover target sessions from Runtime V2 state sources.
371
400
  const allSessionNames = discoverAbortSessionNames(prefix, persistedState, batchState.currentLanes);
372
401
  if (allSessionNames.length === 0) {
373
- execLog("abort", batchState.batchId, `No abort targets discovered for prefix "${prefix}" from runtime/persisted state.`);
402
+ execLog(
403
+ "abort",
404
+ batchState.batchId,
405
+ `No abort targets discovered for prefix "${prefix}" from runtime/persisted state.`,
406
+ );
374
407
  }
375
408
 
376
409
  // Step 4: Select and enrich target sessions
@@ -400,7 +433,7 @@ export async function executeAbort(
400
433
  }
401
434
 
402
435
  // Step 5b: Wait for sessions to exit
403
- const allTargetNames = targets.map(t => t.sessionName);
436
+ const allTargetNames = targets.map((t) => t.sessionName);
404
437
  const waitResult = await waitForSessionExit(allTargetNames, gracePeriodMs, pollIntervalMs);
405
438
  gracefulExits = waitResult.exited.length;
406
439
 
@@ -414,7 +447,7 @@ export async function executeAbort(
414
447
  for (const kr of killResults) {
415
448
  killResultBySession.set(kr.sessionName, { killed: kr.killed, error: kr.error });
416
449
  }
417
- const killFailures = killResults.filter(kr => !kr.killed);
450
+ const killFailures = killResults.filter((kr) => !kr.killed);
418
451
  if (killFailures.length > 0) {
419
452
  errors.push({
420
453
  code: "ABORT_KILL_FAILED",
@@ -426,7 +459,7 @@ export async function executeAbort(
426
459
  // Build lane results
427
460
  const exitedSet = new Set(waitResult.exited);
428
461
  for (const target of targets) {
429
- const wrapUp = wrapUpResults.find(wr => wr.sessionName === target.sessionName);
462
+ const wrapUp = wrapUpResults.find((wr) => wr.sessionName === target.sessionName);
430
463
  const wasGraceful = exitedSet.has(target.sessionName);
431
464
  const killResult = killResultBySession.get(target.sessionName);
432
465
  const sessionKilled = wasGraceful || killResult?.killed === true;
@@ -443,7 +476,7 @@ export async function executeAbort(
443
476
  }
444
477
  } else {
445
478
  // Hard mode: kill all immediately
446
- const allTargetNames = targets.map(t => t.sessionName);
479
+ const allTargetNames = targets.map((t) => t.sessionName);
447
480
  const killResults = killOrchSessions(allTargetNames, {
448
481
  stateRoot: repoRoot,
449
482
  batchId: batchState.batchId,
@@ -452,7 +485,7 @@ export async function executeAbort(
452
485
  for (const kr of killResults) {
453
486
  killResultBySession.set(kr.sessionName, { killed: kr.killed, error: kr.error });
454
487
  }
455
- const killFailures = killResults.filter(kr => !kr.killed);
488
+ const killFailures = killResults.filter((kr) => !kr.killed);
456
489
  if (killFailures.length > 0) {
457
490
  errors.push({
458
491
  code: "ABORT_KILL_FAILED",
@@ -490,7 +523,7 @@ export async function executeAbort(
490
523
  return {
491
524
  mode,
492
525
  sessionsFound: targets.length,
493
- sessionsKilled: laneResults.filter(lr => lr.sessionKilled).length,
526
+ sessionsKilled: laneResults.filter((lr) => lr.sessionKilled).length,
494
527
  gracefulExits,
495
528
  laneResults,
496
529
  wrapUpFailures,
@@ -499,4 +532,3 @@ export async function executeAbort(
499
532
  durationMs: Date.now() - startTime,
500
533
  };
501
534
  }
502
-