taskplane 0.30.0 → 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.
@@ -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
  }
@@ -809,6 +809,41 @@ export function parsePromptForOrchestrator(
809
809
 
810
810
  // ── Area Scanning ────────────────────────────────────────────────────
811
811
 
812
+ /**
813
+ * TP-196 / #462 — Discovery safeguard for `.DONE` authority drift.
814
+ *
815
+ * Discovery has no access to persisted segment state, so it cannot make a
816
+ * hard `.DONE` vs. segment-frontier authority decision (that lives in the
817
+ * monitor/resume guards). What it CAN do cheaply is detect the most common
818
+ * symptom of a stale or premature `.DONE`: a `.DONE` file exists alongside
819
+ * a STATUS.md that still has unchecked checkboxes. When that pattern is
820
+ * found, emit a one-line `console.warn` so operators see the inconsistency
821
+ * during scan. Behaviour of `scanAreaForTasks` is unchanged — the task is
822
+ * still skipped — this is a doctor-style warning only.
823
+ *
824
+ * Returns `true` when the safeguard issued a warning (used by tests).
825
+ */
826
+ export function checkDoneAuthoritySafeguard(
827
+ taskFolder: string,
828
+ logger: (msg: string) => void = console.warn,
829
+ ): boolean {
830
+ const statusPath = join(taskFolder, "STATUS.md");
831
+ if (!existsSync(statusPath)) return false;
832
+ let content: string;
833
+ try {
834
+ content = readFileSync(statusPath, "utf-8");
835
+ } catch {
836
+ return false;
837
+ }
838
+ // Look for any unchecked checkbox `- [ ]` on its own line.
839
+ const hasUnchecked = /^\s*-\s*\[\s\]\s+/m.test(content);
840
+ if (!hasUnchecked) return false;
841
+ logger(
842
+ `[discovery] WARN: .DONE present in ${taskFolder} but STATUS.md contains unchecked checkboxes — possible stale/premature .DONE (#462 safeguard).`,
843
+ );
844
+ return true;
845
+ }
846
+
812
847
  /**
813
848
  * Scan an area path for pending tasks.
814
849
  *
@@ -858,8 +893,14 @@ export function scanAreaForTasks(
858
893
  continue;
859
894
  }
860
895
 
861
- // Skip if .DONE exists (already complete)
862
- if (existsSync(join(entryPath, ".DONE"))) continue;
896
+ // Skip if .DONE exists (already complete).
897
+ // TP-196 / #462: doctor-style safeguard — if .DONE coexists with
898
+ // unchecked checkboxes in STATUS.md, warn so operators can investigate
899
+ // before the task is silently treated as complete.
900
+ if (existsSync(join(entryPath, ".DONE"))) {
901
+ checkDoneAuthoritySafeguard(entryPath);
902
+ continue;
903
+ }
863
904
 
864
905
  // Skip if no PROMPT.md
865
906
  const promptPath = join(entryPath, "PROMPT.md");
@@ -885,6 +885,13 @@ async function parseStatusMdContent(
885
885
  * @param tracker - Mtime tracker for stall detection
886
886
  * @param stallTimeoutMs - Stall timeout in milliseconds
887
887
  * @param now - Current timestamp (epoch ms) for deterministic testing
888
+ * @param multiSegmentContext - Optional segment-authority context (TP-196 / #462).
889
+ * When provided AND `isFinalSegment === false`,
890
+ * `.DONE` is treated as a non-authoritative signal
891
+ * (Priority 1 is skipped). This guards against a
892
+ * stale or premature `.DONE` from a non-final
893
+ * segment short-circuiting the task to succeeded
894
+ * before the remaining segments have run.
888
895
  */
889
896
  export async function resolveTaskMonitorState(
890
897
  taskId: string,
@@ -896,6 +903,7 @@ export async function resolveTaskMonitorState(
896
903
  now: number,
897
904
  runtimeBackend?: RuntimeBackend,
898
905
  v2Context?: { stateRoot: string; batchId: string; laneNumber: number },
906
+ multiSegmentContext?: { isFinalSegment: boolean; segmentId: string },
899
907
  ): Promise<TaskMonitorSnapshot> {
900
908
  // TP-115/TP-127: Backend-aware liveness check.
901
909
  // V2: read the lane snapshot file written by lane-runner every second.
@@ -1035,7 +1043,27 @@ export async function resolveTaskMonitorState(
1035
1043
  }
1036
1044
 
1037
1045
  // ── Priority 1: .DONE file found → succeeded ────────────────
1038
- if (doneFileFound) {
1046
+ // TP-196 / #462: Monitor guard for multi-segment tasks. When the caller
1047
+ // has provided a segment-authority context AND tells us the active segment
1048
+ // is NOT the final segment in the task plan, `.DONE` MUST NOT be accepted
1049
+ // as authoritative — a non-final segment's worker should never have
1050
+ // produced one. We log a WARN and fall through to the lower priorities
1051
+ // (which keep the task in a non-terminal state so the engine can recover).
1052
+ const doneAcceptedAsAuthority =
1053
+ doneFileFound && !(multiSegmentContext && multiSegmentContext.isFinalSegment === false);
1054
+ if (doneFileFound && !doneAcceptedAsAuthority) {
1055
+ execLog(
1056
+ "monitor",
1057
+ taskId,
1058
+ `WARN: .DONE present for non-final segment '${multiSegmentContext?.segmentId}' — ignoring (#462 guard)`,
1059
+ {
1060
+ session: sessionName,
1061
+ segmentId: multiSegmentContext?.segmentId,
1062
+ donePath,
1063
+ },
1064
+ );
1065
+ }
1066
+ if (doneAcceptedAsAuthority) {
1039
1067
  return {
1040
1068
  taskId,
1041
1069
  status: "succeeded",
@@ -1315,6 +1343,19 @@ export async function monitorLanes(
1315
1343
  const statusPath = unit.packet.statusPath;
1316
1344
  const statusResult = await parseStatusMdAtPath(statusPath);
1317
1345
 
1346
+ // TP-196 / #462: Build multi-segment authority context so
1347
+ // `.DONE` from a non-final segment is not accepted as terminal.
1348
+ const taskSegmentIds = task.task.segmentIds ?? [];
1349
+ const taskActiveSegmentId = task.task.activeSegmentId ?? null;
1350
+ let multiSegmentContext: { isFinalSegment: boolean; segmentId: string } | undefined;
1351
+ if (taskSegmentIds.length > 1 && taskActiveSegmentId) {
1352
+ const finalSegmentId = taskSegmentIds[taskSegmentIds.length - 1];
1353
+ multiSegmentContext = {
1354
+ isFinalSegment: taskActiveSegmentId === finalSegmentId,
1355
+ segmentId: taskActiveSegmentId,
1356
+ };
1357
+ }
1358
+
1318
1359
  const snapshot = await resolveTaskMonitorState(
1319
1360
  task.taskId,
1320
1361
  donePath,
@@ -1331,6 +1372,7 @@ export async function monitorLanes(
1331
1372
  laneNumber: lane.laneNumber,
1332
1373
  }
1333
1374
  : undefined,
1375
+ multiSegmentContext,
1334
1376
  );
1335
1377
 
1336
1378
  currentTaskSnapshot = snapshot;
@@ -68,6 +68,7 @@ import {
68
68
  type LaneTaskStatus,
69
69
  type SupervisorAlertCallback,
70
70
  type StepSegmentMapping,
71
+ type SegmentScopeMode,
71
72
  } from "./types.ts";
72
73
 
73
74
  const LANE_RUNNER_DIR = dirname(fileURLToPath(import.meta.url));
@@ -178,6 +179,75 @@ export function isSegmentComplete(
178
179
  return result.unchecked === 0;
179
180
  }
180
181
 
182
+ /**
183
+ * Compute the authoritative `SegmentScopeMode` for one worker iteration.
184
+ *
185
+ * This is the single source of truth for the FULL_TASK vs SEGMENT_SCOPED
186
+ * decision (TP-196 / #502). All segment-related side-effects (env vars,
187
+ * system-prompt overlay, prompt content, tool registration) should derive
188
+ * their behaviour from this mode rather than re-evaluating the underlying
189
+ * boolean conditions in isolation, which is what created the drift risk
190
+ * documented in #502.
191
+ *
192
+ * Returns `SEGMENT_SCOPED` iff ALL of the following hold:
193
+ * - The task has a non-empty `stepSegmentMap` (parsed from PROMPT.md markers).
194
+ * - The lane has an associated `currentRepoId` (segmentId set, so we know
195
+ * which repo this lane is iterating).
196
+ * - The (legacy-fallback-filtered) `repoStepNumbers` set is non-null (the
197
+ * repo has at least one step with explicit segment markers).
198
+ * - A `currentStepNumber` is provided (there is a step to evaluate).
199
+ * - The current step's segment mapping contains an entry for `currentRepoId`
200
+ * (the worker actually has segment-scoped work in the current step).
201
+ *
202
+ * In any other case the mode is `FULL_TASK`.
203
+ *
204
+ * @since TP-196
205
+ */
206
+ export function computeSegmentScopeMode(
207
+ stepSegmentMap: StepSegmentMapping[] | undefined | null,
208
+ repoStepNumbers: Set<number> | null,
209
+ currentRepoId: string | null,
210
+ currentStepNumber: number | null,
211
+ ): SegmentScopeMode {
212
+ if (!stepSegmentMap || !currentRepoId || !repoStepNumbers) return "FULL_TASK";
213
+ if (currentStepNumber === null) return "FULL_TASK";
214
+ const currentStepMapping = stepSegmentMap.find((s) => s.stepNumber === currentStepNumber);
215
+ if (!currentStepMapping) return "FULL_TASK";
216
+ const mySegment = currentStepMapping.segments.find((seg) => seg.repoId === currentRepoId);
217
+ return mySegment ? "SEGMENT_SCOPED" : "FULL_TASK";
218
+ }
219
+
220
+ /**
221
+ * Pre-spawn segment-completion check (TP-196 / #508).
222
+ *
223
+ * Returns `true` when the lane-runner iteration loop should SKIP spawning
224
+ * a worker because all of the segment's checkboxes for this repo are
225
+ * already complete. The lane should `break` out of its iteration loop and
226
+ * fall through to post-loop completion handling.
227
+ *
228
+ * Contract:
229
+ * - Returns `false` for FULL_TASK iterations (`currentRepoId === null` or
230
+ * `repoStepNumbers === null` or empty). Those rely on the existing
231
+ * `remainingSteps.length === 0` exit, not this check.
232
+ * - Returns `true` iff EVERY step in `repoStepNumbers` is
233
+ * `isSegmentComplete(statusContent, stepNum, currentRepoId)`.
234
+ *
235
+ * Pure function: no filesystem access, no global state. The caller reads
236
+ * the STATUS.md content once per iteration and passes it in.
237
+ *
238
+ * @since TP-196
239
+ */
240
+ export function shouldSkipSpawnForCompleteSegment(
241
+ statusContent: string,
242
+ repoStepNumbers: Set<number> | null,
243
+ currentRepoId: string | null,
244
+ ): boolean {
245
+ if (!repoStepNumbers || !currentRepoId || repoStepNumbers.size === 0) return false;
246
+ return [...repoStepNumbers].every((stepNum) =>
247
+ isSegmentComplete(statusContent, stepNum, currentRepoId),
248
+ );
249
+ }
250
+
181
251
  // ── Types ────────────────────────────────────────────────────────────
182
252
 
183
253
  /**
@@ -418,6 +488,27 @@ export async function executeTaskV2(
418
488
 
419
489
  if (remainingSteps.length === 0) break; // All done
420
490
 
491
+ // TP-196 / #508: Pre-spawn segment-completion check.
492
+ //
493
+ // When the lane is iterating a segment-scoped task, verify that NOT ALL
494
+ // `repoStepNumbers` are segment-complete before incurring the cost of
495
+ // spawning a worker. The `remainingSteps` filter above already enforces
496
+ // this implicitly (via `isSegmentComplete`), but expressing the check
497
+ // explicitly at the spawn boundary:
498
+ // 1. Makes the wasted-iteration prevention contract visible.
499
+ // 2. Provides a defensive backstop for cases where `parsed.steps` and
500
+ // `repoStepNumbers` diverge (e.g., legacy/partial-marker tasks).
501
+ // 3. Gives behavioural tests a clean assertion target (via the pure
502
+ // helper `shouldSkipSpawnForCompleteSegment`).
503
+ if (shouldSkipSpawnForCompleteSegment(iterStatusContent, repoStepNumbers, currentRepoId)) {
504
+ logExecution(
505
+ statusPath,
506
+ "Pre-spawn segment-completion check",
507
+ `all segment checkboxes already complete for repo '${currentRepoId}' — skipping worker spawn (#508)`,
508
+ );
509
+ break;
510
+ }
511
+
421
512
  totalIterations++;
422
513
  updateStatusField(
423
514
  statusPath,
@@ -454,16 +545,16 @@ export async function executeTaskV2(
454
545
  /* ignore */
455
546
  }
456
547
 
457
- // TP-174/TP-501: Compute segment scope mode BEFORE building prompt.
458
- const isSegmentScoped = !!(
459
- stepSegmentMap &&
460
- currentRepoId &&
461
- repoStepNumbers &&
462
- remainingSteps.length > 0 &&
463
- stepSegmentMap
464
- .find((s) => s.stepNumber === remainingSteps[0].number)
465
- ?.segments.find((seg) => seg.repoId === currentRepoId)
548
+ // TP-174/TP-501/TP-196: Compute segment scope mode BEFORE building prompt.
549
+ // `segmentScopeMode` is the authoritative TP-196 flag; `isSegmentScoped` is
550
+ // preserved as a boolean alias for ergonomics at the many existing call sites.
551
+ const segmentScopeMode: SegmentScopeMode = computeSegmentScopeMode(
552
+ stepSegmentMap,
553
+ repoStepNumbers,
554
+ currentRepoId,
555
+ remainingSteps.length > 0 ? remainingSteps[0].number : null,
466
556
  );
557
+ const isSegmentScoped = segmentScopeMode === "SEGMENT_SCOPED";
467
558
 
468
559
  const promptLines = [
469
560
  `Read your task instructions at: ${promptPath}`,
@@ -513,16 +604,27 @@ export async function executeTaskV2(
513
604
  // Segment scope mode is determined by which system prompt was loaded.
514
605
  // No SegmentScopeMode line needed — the prompt IS the mode.
515
606
 
516
- // TP-174: Segment-scoped prompt — show only this segment's checkboxes
517
- if (stepSegmentMap && currentRepoId && repoStepNumbers && remainingSteps.length > 0) {
607
+ // TP-174/TP-196: Segment-scoped prompt — show only this segment's checkboxes.
608
+ // Gated on the authoritative `isSegmentScoped` (derived from `segmentScopeMode`)
609
+ // rather than the raw composite condition, so the prompt branch can't drift
610
+ // from the mode decision (TP-196 / #502).
611
+ if (isSegmentScoped) {
518
612
  const currentStepNum = remainingSteps[0].number;
519
- const currentStepMapping = stepSegmentMap.find((s) => s.stepNumber === currentStepNum);
613
+ // Defensive guards: when `isSegmentScoped === true`, `computeSegmentScopeMode`
614
+ // has already verified `stepSegmentMap`, `currentRepoId`, and that the
615
+ // current step's mapping contains an entry for the active repo. We re-fetch
616
+ // the structures here for clarity. If any are missing we log and skip the
617
+ // segment block (defense-in-depth — should never trip in practice).
618
+ const currentStepMapping = stepSegmentMap?.find((s) => s.stepNumber === currentStepNum);
520
619
  const mySegment = currentStepMapping?.segments.find((seg) => seg.repoId === currentRepoId);
521
620
 
522
- // Only inject segment-scoped prompt when the current step has an explicit
523
- // segment for this repoId. If mySegment is missing (legacy task without
524
- // markers, or step has no work for this repo), skip and preserve legacy behavior.
525
- if (currentStepMapping && mySegment) {
621
+ if (!currentStepMapping || !mySegment) {
622
+ logExecution(
623
+ statusPath,
624
+ "WARN",
625
+ `segmentScopeMode === SEGMENT_SCOPED but current step mapping missing — skipping segment prompt block (currentRepoId=${currentRepoId}, stepNum=${currentStepNum})`,
626
+ );
627
+ } else {
526
628
  const otherSegments = currentStepMapping.segments.filter((seg) => seg.repoId !== currentRepoId);
527
629
 
528
630
  // Count total segments for this repo across all steps
@@ -295,11 +295,40 @@ export function collectAllRepoRoots(
295
295
 
296
296
  // ── Resume Pure Functions ────────────────────────────────────────────
297
297
 
298
+ /**
299
+ * Determine whether a multi-segment task's persisted segment frontier is
300
+ * complete — i.e., every segment for the task reached a terminal-success
301
+ * status ("succeeded" or "skipped").
302
+ *
303
+ * Returns:
304
+ * - `true` when the task has segments AND all of them are terminal-success.
305
+ * - `true` when the task has no segments recorded (single-segment / legacy
306
+ * tasks — the guard does not apply and `.DONE` is authoritative).
307
+ * - `false` when at least one segment is pending/running/failed/stalled.
308
+ *
309
+ * Used by `collectDoneTaskIdsForResume` (TP-196 / #462) to refuse a stale or
310
+ * premature `.DONE` from suppressing re-execution of remaining segments.
311
+ */
312
+ function isSegmentFrontierCompleteForResume(
313
+ persistedState: PersistedBatchState,
314
+ taskId: string,
315
+ ): boolean {
316
+ const segments = (persistedState.segments ?? []).filter((s) => s.taskId === taskId);
317
+ if (segments.length === 0) return true; // No segments recorded — guard does not apply.
318
+ return segments.every((s) => s.status === "succeeded" || s.status === "skipped");
319
+ }
320
+
298
321
  /**
299
322
  * Collect task IDs with authoritative .DONE markers.
300
323
  *
301
- * Segment frontier state does not suppress .DONE authority. If a marker exists,
302
- * resume reconciliation will mark the task complete regardless of segment state.
324
+ * Segment frontier state does not suppress .DONE authority for tasks WITHOUT
325
+ * persisted segment records (single-segment / legacy). For tasks WITH segment
326
+ * records (multi-segment), TP-196 / #462 adds a resume guard: when `.DONE`
327
+ * exists but the segment frontier is incomplete (at least one segment is not
328
+ * yet succeeded/skipped), we DO NOT add the taskId to the done set — the
329
+ * task will be re-reconciled instead of silently marked complete. A WARN is
330
+ * logged so operators can spot the inconsistency. The on-disk `.DONE` marker
331
+ * is left alone; the engine will re-establish authoritative state.
303
332
  */
304
333
  export function collectDoneTaskIdsForResume(
305
334
  persistedState: PersistedBatchState,
@@ -308,22 +337,38 @@ export function collectDoneTaskIdsForResume(
308
337
  ): Set<string> {
309
338
  const doneTaskIds = new Set<string>();
310
339
  for (const task of persistedState.tasks) {
340
+ let markerFound = false;
341
+ let markerLocation: string | null = null;
311
342
  if (task.taskFolder && hasTaskDoneMarker(task.taskFolder)) {
312
- doneTaskIds.add(task.taskId);
313
- continue;
343
+ markerFound = true;
344
+ markerLocation = task.taskFolder;
345
+ }
346
+ if (!markerFound) {
347
+ const laneRec = persistedState.lanes.find((l) => l.taskIds.includes(task.taskId));
348
+ if (laneRec?.worktreePath && task.taskFolder) {
349
+ const resolved = resolveCanonicalTaskPaths(
350
+ task.taskFolder,
351
+ laneRec.worktreePath,
352
+ repoRoot,
353
+ !!workspaceConfig,
354
+ );
355
+ if (existsSync(resolved.donePath)) {
356
+ markerFound = true;
357
+ markerLocation = resolved.donePath;
358
+ }
359
+ }
314
360
  }
315
- const laneRec = persistedState.lanes.find((l) => l.taskIds.includes(task.taskId));
316
- if (laneRec?.worktreePath && task.taskFolder) {
317
- const resolved = resolveCanonicalTaskPaths(
318
- task.taskFolder,
319
- laneRec.worktreePath,
320
- repoRoot,
321
- !!workspaceConfig,
361
+ if (!markerFound) continue;
362
+
363
+ // TP-196 / #462: Resume guard — refuse `.DONE` authority for multi-segment
364
+ // tasks with an incomplete segment frontier.
365
+ if (!isSegmentFrontierCompleteForResume(persistedState, task.taskId)) {
366
+ console.warn(
367
+ `[resume] WARN: .DONE present for task ${task.taskId} at ${markerLocation} but segment frontier is incomplete — not marking complete (#462 guard). Task will re-reconcile.`,
322
368
  );
323
- if (existsSync(resolved.donePath)) {
324
- doneTaskIds.add(task.taskId);
325
- }
369
+ continue;
326
370
  }
371
+ doneTaskIds.add(task.taskId);
327
372
  }
328
373
  return doneTaskIds;
329
374
  }
@@ -180,6 +180,29 @@ export function buildExpansionRequestId(timestamp = Date.now()): string {
180
180
 
181
181
  // ── Step-Segment Mapping (Phase A: segment-scoped worker visibility) ────
182
182
 
183
+ /**
184
+ * Authoritative segment-scope mode for a single worker iteration.
185
+ *
186
+ * - `FULL_TASK`: the worker sees the entire PROMPT.md, all steps, all checkboxes.
187
+ * No `Active segment ID` / `Your checkboxes for this step` prose is injected.
188
+ * Segment-related environment variables (`TASKPLANE_ACTIVE_SEGMENT_ID`,
189
+ * `TASKPLANE_SEGMENT_ID`) are hard-cleared so that runtime tools keyed on
190
+ * them (e.g., `request_segment_expansion`) cannot accidentally register.
191
+ *
192
+ * - `SEGMENT_SCOPED`: the worker is iterating a specific segment of a
193
+ * multi-segment task. Only that segment's steps and checkboxes are shown;
194
+ * `Active segment ID` is announced; segment-related env vars carry the
195
+ * active `segmentId`; the segment-overlay system prompt is appended.
196
+ *
197
+ * This is the single authoritative flag for the segment-scope decision
198
+ * (TP-196 / #502). Call sites should derive their behaviour from this mode
199
+ * rather than re-evaluating the underlying boolean conditions, which prevents
200
+ * the multiple branches drifting out of sync.
201
+ *
202
+ * @since TP-196
203
+ */
204
+ export type SegmentScopeMode = "FULL_TASK" | "SEGMENT_SCOPED";
205
+
183
206
  /** A group of checkboxes scoped to a single repo within a step. */
184
207
  export interface SegmentCheckboxGroup {
185
208
  repoId: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskplane",
3
- "version": "0.30.0",
3
+ "version": "0.30.1",
4
4
  "description": "AI agent orchestration for pi — parallel task execution with checkpoint discipline",
5
5
  "keywords": [
6
6
  "pi-package",