taskplane 0.30.0 → 0.30.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dashboard/public/app.js +148 -15
- package/dashboard/public/style.css +83 -2
- package/dashboard/server.cjs +25 -4
- package/extensions/taskplane/discovery.ts +43 -2
- package/extensions/taskplane/engine.ts +11 -2
- package/extensions/taskplane/execution.ts +43 -1
- package/extensions/taskplane/lane-runner.ts +118 -16
- package/extensions/taskplane/merge.ts +4 -4
- package/extensions/taskplane/path-resolver.ts +52 -15
- package/extensions/taskplane/process-registry.ts +11 -4
- package/extensions/taskplane/resume.ts +59 -14
- package/extensions/taskplane/types.ts +38 -1
- package/package.json +1 -1
package/dashboard/public/app.js
CHANGED
|
@@ -243,6 +243,17 @@ let viewerMode = null; // "conversation" | "status-md" | null
|
|
|
243
243
|
let viewerTarget = null; // session name (conversation) or taskId (status-md)
|
|
244
244
|
let lastBatchId = null; // TP-178: track batchId for stale viewer detection (#487)
|
|
245
245
|
|
|
246
|
+
// #507: Debounce the no-batch transition. A single missed poll happens
|
|
247
|
+
// transiently during batch-state.json writes at batch startup, and was
|
|
248
|
+
// causing the dashboard to flash the previous batch's history view before
|
|
249
|
+
// switching to the new live batch. Require N consecutive no-batch polls
|
|
250
|
+
// before clearing the viewer / showing history. With the server's 2s
|
|
251
|
+
// POLL_INTERVAL, a threshold of 3 corresponds to ~6s of confirmed silence —
|
|
252
|
+
// well past the typical batch-state.json write window (sub-second) while
|
|
253
|
+
// still cleaning up promptly when a batch genuinely ends.
|
|
254
|
+
let consecutiveNoBatchPolls = 0;
|
|
255
|
+
const NO_BATCH_DEBOUNCE_THRESHOLD = 3;
|
|
256
|
+
|
|
246
257
|
// ─── Repo Helpers ───────────────────────────────────────────────────────────
|
|
247
258
|
|
|
248
259
|
/**
|
|
@@ -382,6 +393,53 @@ function taskSegmentProgress(task, segmentStatusMap, forcedActiveSegmentId) {
|
|
|
382
393
|
};
|
|
383
394
|
}
|
|
384
395
|
|
|
396
|
+
// TP-197 (#464): Render a horizontal pill row of per-segment status badges for a
|
|
397
|
+
// multi-segment task. Each pill shows an icon + repoId for one segment. The icon
|
|
398
|
+
// reflects the segment's status (succeeded / running / pending / failed / stalled /
|
|
399
|
+
// skipped). The current segment (the one actively executing on its lane) gets an
|
|
400
|
+
// emphasis class. Returns "" for single-segment tasks so the rendered DOM is
|
|
401
|
+
// byte-identical to today for the non-segmented common case (no regression).
|
|
402
|
+
//
|
|
403
|
+
// Consumes:
|
|
404
|
+
// - task.segmentIds: string[] (ordered, from PersistedTaskRecord)
|
|
405
|
+
// - segmentStatusMap: Map<segmentId, PersistedSegmentStatus> built by
|
|
406
|
+
// buildSegmentStatusMap() from batch.segments[]
|
|
407
|
+
// - activeSegmentId: string|null — current executing segment (from V2 lane
|
|
408
|
+
// snapshot's segmentId, or the task's activeSegmentId field)
|
|
409
|
+
function taskSegmentPillRow(task, segmentStatusMap, activeSegmentId) {
|
|
410
|
+
const segmentIds = Array.isArray(task?.segmentIds)
|
|
411
|
+
? task.segmentIds.filter(id => typeof id === "string")
|
|
412
|
+
: [];
|
|
413
|
+
if (segmentIds.length <= 1) return "";
|
|
414
|
+
|
|
415
|
+
// Status -> { icon, className } table. Keep emoji simple/monospace-friendly.
|
|
416
|
+
// ✅ succeeded, ⏳ running, ⬚ pending, ❌ failed, ⏸ stalled, ↷ skipped.
|
|
417
|
+
const styles = {
|
|
418
|
+
succeeded: { icon: "\u2705", cls: "seg-succeeded" },
|
|
419
|
+
running: { icon: "\u23F3", cls: "seg-running" },
|
|
420
|
+
pending: { icon: "\u2B1A", cls: "seg-pending" },
|
|
421
|
+
failed: { icon: "\u274C", cls: "seg-failed" },
|
|
422
|
+
stalled: { icon: "\u23F8", cls: "seg-stalled" },
|
|
423
|
+
skipped: { icon: "\u21B7", cls: "seg-skipped" },
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
const pills = segmentIds.map((segId) => {
|
|
427
|
+
const status = segmentStatusMap.get(segId) || "pending";
|
|
428
|
+
const style = styles[status] || styles.pending;
|
|
429
|
+
const parsed = parseSegmentId(segId);
|
|
430
|
+
const repoLabel = parsed?.repoId || segId;
|
|
431
|
+
const isCurrent = activeSegmentId && segId === activeSegmentId;
|
|
432
|
+
const currentCls = isCurrent ? " seg-pill-current" : "";
|
|
433
|
+
const title = `${segId} \u00b7 ${status}`;
|
|
434
|
+
return `<span class="seg-pill ${style.cls}${currentCls}" title="${escapeHtml(title)}">`
|
|
435
|
+
+ `<span class="seg-pill-icon">${style.icon}</span>`
|
|
436
|
+
+ `<span class="seg-pill-label">${escapeHtml(repoLabel)}</span>`
|
|
437
|
+
+ `</span>`;
|
|
438
|
+
}).join("");
|
|
439
|
+
|
|
440
|
+
return `<div class="task-segment-row">${pills}</div>`;
|
|
441
|
+
}
|
|
442
|
+
|
|
385
443
|
function laneActiveSegmentInfo(v2snap, laneTasks, segmentStatusMap) {
|
|
386
444
|
if (!v2snap || !v2snap.segmentId) return null;
|
|
387
445
|
const parsed = parseSegmentId(v2snap.segmentId);
|
|
@@ -620,7 +678,13 @@ function renderSummary(batch) {
|
|
|
620
678
|
// their assigned lane: tasks on the same lane render with `→` (serial),
|
|
621
679
|
// tasks on different lanes render with ` | ` (parallel). Tooltip shows
|
|
622
680
|
// the expanded lane breakdown.
|
|
623
|
-
|
|
681
|
+
// TP-197 post-merge fold: pass `batch.tasks` as the task→lane source.
|
|
682
|
+
// The previous arg `batch.lanes` only carries live Runtime V2 lane
|
|
683
|
+
// state for the *currently active* wave — past/future wave chips
|
|
684
|
+
// would fall back to comma-separated. `batch.tasks[].laneNumber` is
|
|
685
|
+
// persisted for the entire batch lifecycle, so all waves render with
|
|
686
|
+
// the correct parallelization separator regardless of active state.
|
|
687
|
+
const { compact, tooltip } = formatWaveLaneBreakdown(taskIds, batch.lanes || [], batch.tasks || [], i + 1);
|
|
624
688
|
const titleAttr = tooltip ? ` title="${escapeHtml(tooltip)}"` : "";
|
|
625
689
|
wavesHtml += `<span class="wave-chip ${cls}"${titleAttr}>W${i + 1} [${compact}]</span>`;
|
|
626
690
|
});
|
|
@@ -648,16 +712,46 @@ function renderSummary(batch) {
|
|
|
648
712
|
* are shown with the previous flat formatting and no tooltip is generated
|
|
649
713
|
* — this preserves backward compatibility with future-wave display.
|
|
650
714
|
*/
|
|
651
|
-
function formatWaveLaneBreakdown(taskIds, lanes, waveNumber) {
|
|
715
|
+
function formatWaveLaneBreakdown(taskIds, lanes, tasks, waveNumber) {
|
|
652
716
|
if (!Array.isArray(taskIds) || taskIds.length === 0) {
|
|
653
717
|
return { compact: "", tooltip: "" };
|
|
654
718
|
}
|
|
655
|
-
// Build taskId → laneNumber map
|
|
719
|
+
// Build taskId → laneNumber map. Prefer the persisted-per-task
|
|
720
|
+
// `tasks[i].laneNumber` (covers all waves, lifecycle-stable). Fall back
|
|
721
|
+
// to live `lanes[]` only when tasks data is missing or doesn't carry
|
|
722
|
+
// laneNumber for a given task.
|
|
723
|
+
//
|
|
724
|
+
// TP-197 post-merge fold: the previous implementation read ONLY from
|
|
725
|
+
// `lanes`, which is Runtime V2 live state and only populated for the
|
|
726
|
+
// currently active wave. That caused inactive waves' chips to fall back
|
|
727
|
+
// to comma-separated display (no parallelization indicator), giving the
|
|
728
|
+
// impression that the separator changed as the batch progressed. Using
|
|
729
|
+
// the persisted `tasks[].laneNumber` makes the indicator stable across
|
|
730
|
+
// all waves regardless of active state.
|
|
656
731
|
const taskToLane = new Map();
|
|
732
|
+
if (Array.isArray(tasks)) {
|
|
733
|
+
for (const t of tasks) {
|
|
734
|
+
// Persistence assigns `laneNumber: 0` as a sentinel meaning
|
|
735
|
+
// "unallocated" (see persistence.ts:1378 — `lane?.laneNumber ??
|
|
736
|
+
// outcome?.laneNumber ?? 0`). Real lane numbers start at 1. We must
|
|
737
|
+
// skip 0 here so future-wave tasks (which all have the 0 sentinel
|
|
738
|
+
// until their wave starts) don't get falsely grouped under a fake
|
|
739
|
+
// "lane 0" and rendered as serial.
|
|
740
|
+
if (
|
|
741
|
+
t &&
|
|
742
|
+
t.taskId &&
|
|
743
|
+
typeof t.laneNumber === "number" &&
|
|
744
|
+
t.laneNumber >= 1 &&
|
|
745
|
+
!taskToLane.has(t.taskId)
|
|
746
|
+
) {
|
|
747
|
+
taskToLane.set(t.taskId, t.laneNumber);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
// Fallback: anything `tasks` didn't cover, try `lanes` (live state).
|
|
657
752
|
for (const lane of lanes) {
|
|
658
753
|
if (!lane || !Array.isArray(lane.taskIds)) continue;
|
|
659
754
|
for (const tid of lane.taskIds) {
|
|
660
|
-
// First lane to claim a task wins (lanes shouldn't overlap, but be defensive).
|
|
661
755
|
if (!taskToLane.has(tid)) taskToLane.set(tid, lane.laneNumber);
|
|
662
756
|
}
|
|
663
757
|
}
|
|
@@ -859,8 +953,24 @@ function renderLanesTasks(batch, sessions) {
|
|
|
859
953
|
stepHtml = `<span style="color:var(--text-faint)">${escapeHtml(task.exitReason || "—")}</span>`;
|
|
860
954
|
}
|
|
861
955
|
|
|
956
|
+
// TP-197 (#464): Compute the per-segment pill row for multi-segment tasks.
|
|
957
|
+
// Returns "" for single-segment tasks (no DOM regression for the common case).
|
|
958
|
+
// For multi-segment tasks we render the pill row in the task-row's grid row 3
|
|
959
|
+
// (via .task-segment-row CSS) and suppress the inline "Segment N/T: repo" text
|
|
960
|
+
// in detailBits to avoid duplicating signal — the pill row already shows the
|
|
961
|
+
// current segment (via seg-pill-current) and total count (via pill count).
|
|
962
|
+
const segmentPillRowHtml = taskSegmentPillRow(
|
|
963
|
+
task,
|
|
964
|
+
segmentStatusMap,
|
|
965
|
+
v2snap && v2snap.taskId === task.taskId ? v2snap.segmentId : (segmentInfo?.segmentId || null),
|
|
966
|
+
);
|
|
967
|
+
const hasSegmentPillRow = segmentPillRowHtml !== "";
|
|
968
|
+
|
|
862
969
|
const detailBits = [];
|
|
863
|
-
if (segmentInfo) {
|
|
970
|
+
if (segmentInfo && !hasSegmentPillRow) {
|
|
971
|
+
// Single-segment + non-segmented tasks: existing inline text (unchanged).
|
|
972
|
+
// Multi-segment tasks: suppressed because the new pill row carries the same
|
|
973
|
+
// information more legibly.
|
|
864
974
|
detailBits.push(`<span class="task-segment-progress" title="${escapeHtml(segmentInfo.segmentId || segmentProgressText(segmentInfo))}">${escapeHtml(segmentProgressText(segmentInfo))}</span>`);
|
|
865
975
|
}
|
|
866
976
|
if (showPacketHome) {
|
|
@@ -950,8 +1060,17 @@ function renderLanesTasks(batch, sessions) {
|
|
|
950
1060
|
const titleHtml = task.taskTitle
|
|
951
1061
|
? `<div class="task-title-subtitle">${escapeHtml(task.taskTitle)}</div>`
|
|
952
1062
|
: "";
|
|
1063
|
+
// TP-197 (#464): segmentPillRowHtml is empty for single-segment tasks so
|
|
1064
|
+
// the rendered DOM is byte-identical to today for non-segmented tasks.
|
|
1065
|
+
// For multi-segment tasks it renders as grid-row 3 of .task-row.
|
|
1066
|
+
// Sage post-merge fold: the .has-segments class opts the .task-row
|
|
1067
|
+
// grid into a 3-row template only when we actually have a pill row;
|
|
1068
|
+
// otherwise the default 2-row template preserves single-segment task
|
|
1069
|
+
// spacing exactly (an unconditional 3-row template would add an 8px
|
|
1070
|
+
// row-gap even when row 3 is empty, breaking the no-regression contract).
|
|
1071
|
+
const taskRowClass = hasSegmentPillRow ? "task-row has-segments" : "task-row";
|
|
953
1072
|
html += `
|
|
954
|
-
<div class="
|
|
1073
|
+
<div class="${taskRowClass}">
|
|
955
1074
|
<span class="task-icon"><span class="status-dot ${task.status}"></span></span>
|
|
956
1075
|
<span class="task-actions">${eyeHtml}</span>
|
|
957
1076
|
<span class="task-id status-${task.status}">${escapeHtml(task.taskId)}${showRepos ? repoBadgeHtml(tRepo, "repo-badge-task") : ""}</span>
|
|
@@ -960,6 +1079,7 @@ function renderLanesTasks(batch, sessions) {
|
|
|
960
1079
|
<span>${progressHtml}</span>
|
|
961
1080
|
<span class="task-step">${stepHtml}${workerHtml}</span>
|
|
962
1081
|
${titleHtml}
|
|
1082
|
+
${segmentPillRowHtml}
|
|
963
1083
|
</div>`;
|
|
964
1084
|
html += reviewerRowHtml;
|
|
965
1085
|
}
|
|
@@ -1054,7 +1174,15 @@ function renderMergeAgents(batch, sessions) {
|
|
|
1054
1174
|
}
|
|
1055
1175
|
|
|
1056
1176
|
let html = '<table class="merge-table"><thead><tr>';
|
|
1057
|
-
|
|
1177
|
+
// TP-197 post-merge fold: removed 'Session ID' and 'Details' columns.
|
|
1178
|
+
// SESSION ID was hardcoded to '—' in every row — dead weight.
|
|
1179
|
+
// DETAILS only populated for `mr.failureReason` (rare failure cases);
|
|
1180
|
+
// for the common all-merges-succeeded case it's always '—' too.
|
|
1181
|
+
// When a real failure happens, the operator sees status='failed' in
|
|
1182
|
+
// the Status column and can dig into engine logs for the reason —
|
|
1183
|
+
// we'll re-add a focused DETAILS column if/when we have meaningful
|
|
1184
|
+
// structured failure-reason data to surface in the dashboard table.
|
|
1185
|
+
html += '<th>Wave</th><th>Status</th><th>Session</th><th>Telemetry</th>';
|
|
1058
1186
|
html += '</tr></thead><tbody>';
|
|
1059
1187
|
|
|
1060
1188
|
// Track sessions shown in wave result rows so we don't duplicate them below
|
|
@@ -1135,10 +1263,6 @@ function renderMergeAgents(batch, sessions) {
|
|
|
1135
1263
|
html += `<td class="merge-session-cell">${effectiveAlive ? escapeHtml(effectiveSession) : "—"}</td>`;
|
|
1136
1264
|
// Full telemetry cell
|
|
1137
1265
|
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
1266
|
html += `</tr>`;
|
|
1143
1267
|
|
|
1144
1268
|
// Per-repo sub-rows: show when workspace mode has repo results
|
|
@@ -1159,8 +1283,6 @@ function renderMergeAgents(batch, sessions) {
|
|
|
1159
1283
|
html += `<td><span class="status-badge ${rrStatusCls}">${rr.status}</span></td>`;
|
|
1160
1284
|
html += `<td class="merge-session-cell">${rrLanes}</td>`;
|
|
1161
1285
|
html += `<td></td>`; /* telemetry placeholder */
|
|
1162
|
-
html += `<td></td>`; /* attach placeholder */
|
|
1163
|
-
html += `<td class="merge-detail-cell">${rrDetail}</td>`;
|
|
1164
1286
|
html += `</tr>`;
|
|
1165
1287
|
}
|
|
1166
1288
|
}
|
|
@@ -1177,8 +1299,6 @@ function renderMergeAgents(batch, sessions) {
|
|
|
1177
1299
|
html += `<td class="merge-session-cell">${escapeHtml(sess)}</td>`;
|
|
1178
1300
|
// Full telemetry cell for active merge session
|
|
1179
1301
|
html += `<td class="merge-telemetry-cell">${mergeTelemetryHtml(sessTel, true)}</td>`;
|
|
1180
|
-
html += `<td>—</td>`;
|
|
1181
|
-
html += `<td>—</td>`;
|
|
1182
1302
|
html += `</tr>`;
|
|
1183
1303
|
}
|
|
1184
1304
|
|
|
@@ -1709,6 +1829,16 @@ function render(data) {
|
|
|
1709
1829
|
$lastUpdate.textContent = new Date().toLocaleTimeString();
|
|
1710
1830
|
|
|
1711
1831
|
if (!batch) {
|
|
1832
|
+
// #507: A single missed poll during batch startup (batch-state.json being
|
|
1833
|
+
// written) is not a real "batch disappeared" signal. Only act on no-batch
|
|
1834
|
+
// after N consecutive polls confirm it, so we don't flash the history
|
|
1835
|
+
// view between two live batches.
|
|
1836
|
+
consecutiveNoBatchPolls += 1;
|
|
1837
|
+
if (consecutiveNoBatchPolls < NO_BATCH_DEBOUNCE_THRESHOLD) {
|
|
1838
|
+
// Hold the previous render in place. Still tick the timestamp so the
|
|
1839
|
+
// user knows the SSE stream is alive.
|
|
1840
|
+
return;
|
|
1841
|
+
}
|
|
1712
1842
|
// TP-178: Clear viewer when batch disappears (#487)
|
|
1713
1843
|
if (lastBatchId && viewerMode) closeViewer();
|
|
1714
1844
|
lastBatchId = null;
|
|
@@ -1721,6 +1851,9 @@ function render(data) {
|
|
|
1721
1851
|
return;
|
|
1722
1852
|
}
|
|
1723
1853
|
|
|
1854
|
+
// Batch present — reset the no-batch debounce counter (#507).
|
|
1855
|
+
consecutiveNoBatchPolls = 0;
|
|
1856
|
+
|
|
1724
1857
|
// TP-178: Detect batchId change — clear stale viewer state (#487)
|
|
1725
1858
|
if (batch.batchId && lastBatchId && batch.batchId !== lastBatchId && viewerMode) {
|
|
1726
1859
|
closeViewer();
|
|
@@ -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
|
-
|
|
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
|
}
|
package/dashboard/server.cjs
CHANGED
|
@@ -467,12 +467,26 @@ function loadRuntimeLaneSnapshots(batchId) {
|
|
|
467
467
|
/**
|
|
468
468
|
* Load Runtime V2 merge agent snapshots for the current batch.
|
|
469
469
|
*
|
|
470
|
-
* Reads all `merge
|
|
471
|
-
* Returns a map of
|
|
470
|
+
* Reads all `merge-*.json` files from `.pi/runtime/{batchId}/lanes/`.
|
|
471
|
+
* Returns a map of unique key → snapshot data, where the key is a composite
|
|
472
|
+
* of waveIndex and mergeNumber.
|
|
473
|
+
*
|
|
474
|
+
* The composite key is essential because lane numbers (and therefore
|
|
475
|
+
* `mergeNumber`) repeat across waves — keying solely by `mergeNumber` caused
|
|
476
|
+
* wave N+1's snapshots to silently overwrite wave N's in the intermediate
|
|
477
|
+
* map, which is the root cause of #509 ('merge agent telemetry missing for
|
|
478
|
+
* some waves').
|
|
472
479
|
*
|
|
473
480
|
* Follows the same pattern as {@link loadRuntimeLaneSnapshots}.
|
|
474
481
|
*
|
|
475
|
-
*
|
|
482
|
+
* Filename accepted patterns (back-compat-tolerant):
|
|
483
|
+
* merge-w{waveIndex}-{mergeNumber}.json (current, post-#509)
|
|
484
|
+
* merge-{mergeNumber}.json (legacy, pre-#509)
|
|
485
|
+
*
|
|
486
|
+
* Both patterns embed waveIndex inside the snapshot JSON itself, so the key
|
|
487
|
+
* derivation works for either filename.
|
|
488
|
+
*
|
|
489
|
+
* @since TP-164 (composite key added in #509 remediation)
|
|
476
490
|
*/
|
|
477
491
|
function loadRuntimeMergeSnapshots(batchId) {
|
|
478
492
|
if (!batchId) return {};
|
|
@@ -484,7 +498,14 @@ function loadRuntimeMergeSnapshots(batchId) {
|
|
|
484
498
|
for (const file of files) {
|
|
485
499
|
try {
|
|
486
500
|
const data = JSON.parse(fs.readFileSync(path.join(lanesDir, file), "utf-8"));
|
|
487
|
-
if (data.mergeNumber
|
|
501
|
+
if (data.mergeNumber == null) continue;
|
|
502
|
+
// Composite key keeps cross-wave snapshots from colliding in this map.
|
|
503
|
+
// Falls back to mergeNumber-only for legacy snapshots that pre-date
|
|
504
|
+
// the waveIndex-in-filename change.
|
|
505
|
+
const key = data.waveIndex != null
|
|
506
|
+
? `w${data.waveIndex}-${data.mergeNumber}`
|
|
507
|
+
: String(data.mergeNumber);
|
|
508
|
+
snapshots[key] = data;
|
|
488
509
|
} catch { continue; }
|
|
489
510
|
}
|
|
490
511
|
} catch { /* dir missing */ }
|
|
@@ -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
|
|
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");
|
|
@@ -4225,14 +4225,23 @@ export async function executeOrchBatch(
|
|
|
4225
4225
|
"info",
|
|
4226
4226
|
);
|
|
4227
4227
|
|
|
4228
|
-
// TP-040: Emit merge_success event
|
|
4228
|
+
// TP-040: Emit merge_success event.
|
|
4229
|
+
//
|
|
4230
|
+
// `waveIndex` is the segment-round index (0-based), and the supervisor
|
|
4231
|
+
// formatter renders the (N/M) counter using `waveIndex + 1` for N.
|
|
4232
|
+
// For unit consistency we therefore pair it with the segment-level
|
|
4233
|
+
// `batchState.totalWaves` (segment-expanded round count), not
|
|
4234
|
+
// `taskLevelWaveCount` (pre-expansion). Using the task-level count as
|
|
4235
|
+
// the denominator while the numerator counts segment rounds produced
|
|
4236
|
+
// `(4/3)`, `(5/3)`, `(6/3)` style overflow once segments expanded —
|
|
4237
|
+
// see issue #562.
|
|
4229
4238
|
emitEvent(
|
|
4230
4239
|
stateRoot,
|
|
4231
4240
|
{
|
|
4232
4241
|
...buildEngineEventBase("merge_success", batchState.batchId, waveIdx, batchState.phase),
|
|
4233
4242
|
laneCount: mergedCount,
|
|
4234
4243
|
durationMs: mergeResult.totalDurationMs,
|
|
4235
|
-
totalWaves:
|
|
4244
|
+
totalWaves: batchState.totalWaves,
|
|
4236
4245
|
},
|
|
4237
4246
|
onEngineEvent,
|
|
4238
4247
|
);
|
|
@@ -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
|
-
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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
|
|
@@ -895,7 +895,7 @@ export async function spawnMergeAgentV2(
|
|
|
895
895
|
agent: buildAgentSnap(tel, "running"),
|
|
896
896
|
updatedAt: Date.now(),
|
|
897
897
|
};
|
|
898
|
-
writeMergeSnapshot(mergeStateRoot, bid, mergeNumber, snap);
|
|
898
|
+
writeMergeSnapshot(mergeStateRoot, bid, waveIndex ?? 0, mergeNumber, snap);
|
|
899
899
|
} catch {
|
|
900
900
|
/* non-fatal */
|
|
901
901
|
}
|
|
@@ -915,7 +915,7 @@ export async function spawnMergeAgentV2(
|
|
|
915
915
|
agent: buildAgentSnap({}, "running"),
|
|
916
916
|
updatedAt: Date.now(),
|
|
917
917
|
};
|
|
918
|
-
writeMergeSnapshot(mergeStateRoot, bid, mergeNumber, initialSnap);
|
|
918
|
+
writeMergeSnapshot(mergeStateRoot, bid, waveIndex ?? 0, mergeNumber, initialSnap);
|
|
919
919
|
} catch {
|
|
920
920
|
/* non-fatal */
|
|
921
921
|
}
|
|
@@ -964,7 +964,7 @@ export async function spawnMergeAgentV2(
|
|
|
964
964
|
agent: buildAgentSnap(result, terminalStatus === "complete" ? "exited" : "crashed"),
|
|
965
965
|
updatedAt: Date.now(),
|
|
966
966
|
};
|
|
967
|
-
writeMergeSnapshot(mergeStateRoot, bid, mergeNumber, snap);
|
|
967
|
+
writeMergeSnapshot(mergeStateRoot, bid, waveIndex ?? 0, mergeNumber, snap);
|
|
968
968
|
} catch {
|
|
969
969
|
/* non-fatal */
|
|
970
970
|
}
|
|
@@ -987,7 +987,7 @@ export async function spawnMergeAgentV2(
|
|
|
987
987
|
agent: buildAgentSnap({}, "crashed"),
|
|
988
988
|
updatedAt: Date.now(),
|
|
989
989
|
};
|
|
990
|
-
writeMergeSnapshot(mergeStateRoot, bid, mergeNumber, snap);
|
|
990
|
+
writeMergeSnapshot(mergeStateRoot, bid, waveIndex ?? 0, mergeNumber, snap);
|
|
991
991
|
} catch {
|
|
992
992
|
/* non-fatal */
|
|
993
993
|
}
|
|
@@ -112,32 +112,53 @@ const PI_PACKAGE_SCOPES = ["@earendil-works", "@mariozechner"] as const;
|
|
|
112
112
|
* `dist/cli.js` so callers can spawn it with `node` directly, without a shell
|
|
113
113
|
* intermediary.
|
|
114
114
|
*
|
|
115
|
-
* Resolution order:
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
* is the
|
|
115
|
+
* Resolution order:
|
|
116
|
+
*
|
|
117
|
+
* 0. **AUTHORITATIVE** — `process.argv[1]` when it points at a Pi `cli.js`.
|
|
118
|
+
* When Taskplane is running as a Pi extension, the parent process IS
|
|
119
|
+
* Pi, and Node sets `process.argv[1]` to the path of the file used to
|
|
120
|
+
* start it. This is the single most reliable resolution path: it works
|
|
121
|
+
* for npm-global, mise, asdf, NVM (Windows + Unix), Nix, Bun-installed
|
|
122
|
+
* Pi, and any future install method we can't enumerate. Issues #519
|
|
123
|
+
* and #598 both stem from this signal being ignored in favor of a
|
|
124
|
+
* static-path search that misses non-canonical install layouts.
|
|
125
|
+
*
|
|
126
|
+
* If `process.argv[1]` isn't a Pi `cli.js` (e.g. running standalone in tests,
|
|
127
|
+
* or invoked through an indirect wrapper), the function falls through to a
|
|
128
|
+
* cross product of base directories × package scopes:
|
|
119
129
|
*
|
|
120
|
-
*
|
|
121
|
-
* 1. `npm root -g` result (dynamic — covers all setups: nvm, Homebrew, volta, etc.)
|
|
130
|
+
* 1. `npm root -g` result (dynamic — covers npm-global, Homebrew, volta, etc.)
|
|
122
131
|
* 2. `%APPDATA%\npm\node_modules\...` (Windows, APPDATA env var)
|
|
123
132
|
* 3. `%USERPROFILE%\AppData\Roaming\npm\node_modules\...` (Windows, HOME-relative)
|
|
124
133
|
* 4. `~/.npm-global/lib/node_modules/...` (macOS/Linux custom global prefix)
|
|
125
|
-
* 5.
|
|
126
|
-
* 6.
|
|
134
|
+
* 5. `$NVM_SYMLINK\node_modules` (NVM-for-Windows, when the env var is set)
|
|
135
|
+
* 6. `dirname($NVM_BIN)/../lib/node_modules` (NVM-for-Unix, when the env var is set)
|
|
136
|
+
* 7. `/usr/local/lib/node_modules/...` (macOS system Node, Linux)
|
|
137
|
+
* 8. `/opt/homebrew/lib/node_modules/...` (macOS Homebrew)
|
|
127
138
|
*
|
|
128
139
|
* Scopes per base (inner loop):
|
|
129
140
|
* a. `@earendil-works/pi-coding-agent/dist/cli.js`
|
|
130
141
|
* b. `@mariozechner/pi-coding-agent/dist/cli.js`
|
|
131
142
|
*
|
|
132
|
-
* @returns Absolute path to a Pi CLI `dist/cli.js
|
|
133
|
-
* @throws {Error} If the CLI entrypoint cannot be found
|
|
134
|
-
*
|
|
135
|
-
*
|
|
143
|
+
* @returns Absolute path to a Pi CLI `dist/cli.js`.
|
|
144
|
+
* @throws {Error} If the CLI entrypoint cannot be found by any strategy.
|
|
145
|
+
* The error message includes the `npm root -g` value AND lists
|
|
146
|
+
* both scopes searched, for operator diagnosis.
|
|
136
147
|
*/
|
|
137
148
|
export function resolvePiCliPath(): string {
|
|
149
|
+
// 0. AUTHORITATIVE: trust process.argv[1] when it points at a Pi cli.js.
|
|
150
|
+
// Pi's package.json declares `"bin": { "pi": "dist/cli.js" }`, so the
|
|
151
|
+
// `endsWith("cli.js")` guard is a tight sanity check that rejects e.g.
|
|
152
|
+
// test runners or wrapper scripts that happen to leave argv[1] pointing
|
|
153
|
+
// somewhere else. existsSync() guards against stale argv state in mocks.
|
|
154
|
+
const piEntry = process.argv[1] || "";
|
|
155
|
+
if (piEntry.endsWith("cli.js") && existsSync(piEntry)) {
|
|
156
|
+
return piEntry;
|
|
157
|
+
}
|
|
158
|
+
|
|
138
159
|
const bases: string[] = [];
|
|
139
160
|
|
|
140
|
-
// 1. Dynamic: npm root -g (covers
|
|
161
|
+
// 1. Dynamic: npm root -g (covers npm-global, Homebrew, volta, custom npm prefix, etc.)
|
|
141
162
|
const npmRoot = getNpmGlobalRoot();
|
|
142
163
|
if (npmRoot) bases.push(npmRoot);
|
|
143
164
|
|
|
@@ -151,9 +172,25 @@ export function resolvePiCliPath(): string {
|
|
|
151
172
|
// 4. macOS/Linux custom global prefix
|
|
152
173
|
bases.push(join(home, ".npm-global", "lib", "node_modules"));
|
|
153
174
|
}
|
|
154
|
-
|
|
175
|
+
|
|
176
|
+
// 5. NVM-for-Windows defense in depth: NVM_SYMLINK points at the active
|
|
177
|
+
// Node install (typically C:\Program Files\nodejs as a junction), and the
|
|
178
|
+
// global packages live under <symlink>\node_modules. Child processes
|
|
179
|
+
// inherit this env var even when PATH is stripped of npm.
|
|
180
|
+
if (process.env.NVM_SYMLINK) {
|
|
181
|
+
bases.push(join(process.env.NVM_SYMLINK, "node_modules"));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 6. NVM-for-Unix defense in depth: NVM_BIN points at the active version's
|
|
185
|
+
// bin directory, and the corresponding node_modules sit alongside it at
|
|
186
|
+
// `../lib/node_modules`. Same inheritance properties as NVM_SYMLINK.
|
|
187
|
+
if (process.env.NVM_BIN) {
|
|
188
|
+
bases.push(join(process.env.NVM_BIN, "..", "lib", "node_modules"));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 7. macOS system Node / Linux
|
|
155
192
|
bases.push(join("/usr", "local", "lib", "node_modules"));
|
|
156
|
-
//
|
|
193
|
+
// 8. macOS Homebrew
|
|
157
194
|
bases.push(join("/opt", "homebrew", "lib", "node_modules"));
|
|
158
195
|
|
|
159
196
|
// Cross product: scope is the inner loop so a single base directory is
|
|
@@ -400,20 +400,25 @@ export function readLaneSnapshot(
|
|
|
400
400
|
* Stored in the `lanes/` directory alongside lane snapshots so the dashboard
|
|
401
401
|
* server picks it up with the same scan that reads lane-N.json files.
|
|
402
402
|
*
|
|
403
|
+
* Filename includes BOTH waveIndex and mergeNumber so wave-N+1's merges
|
|
404
|
+
* cannot overwrite wave-N's snapshots before the dashboard polls them (#509).
|
|
405
|
+
*
|
|
403
406
|
* @param stateRoot - Repository root (where `.pi/` lives)
|
|
404
407
|
* @param batchId - Current batch identifier
|
|
408
|
+
* @param waveIndex - 0-based wave index for the merge
|
|
405
409
|
* @param mergeNumber - 1-indexed merge agent number
|
|
406
410
|
* @param snapshot - Snapshot data to persist
|
|
407
411
|
*
|
|
408
|
-
* @since TP-164
|
|
412
|
+
* @since TP-164 (waveIndex parameter added in #509 remediation)
|
|
409
413
|
*/
|
|
410
414
|
export function writeMergeSnapshot(
|
|
411
415
|
stateRoot: string,
|
|
412
416
|
batchId: string,
|
|
417
|
+
waveIndex: number,
|
|
413
418
|
mergeNumber: number,
|
|
414
419
|
snapshot: RuntimeMergeSnapshot,
|
|
415
420
|
): void {
|
|
416
|
-
const path = runtimeMergeSnapshotPath(stateRoot, batchId, mergeNumber);
|
|
421
|
+
const path = runtimeMergeSnapshotPath(stateRoot, batchId, waveIndex, mergeNumber);
|
|
417
422
|
mkdirSync(dirname(path), { recursive: true });
|
|
418
423
|
const tmpPath = path + ".tmp";
|
|
419
424
|
writeFileSync(tmpPath, JSON.stringify(snapshot, null, 2) + "\n", "utf-8");
|
|
@@ -426,17 +431,19 @@ export function writeMergeSnapshot(
|
|
|
426
431
|
*
|
|
427
432
|
* @param stateRoot - Repository root (where `.pi/` lives)
|
|
428
433
|
* @param batchId - Current batch identifier
|
|
434
|
+
* @param waveIndex - 0-based wave index for the merge
|
|
429
435
|
* @param mergeNumber - 1-indexed merge agent number
|
|
430
436
|
*
|
|
431
|
-
* @since TP-164
|
|
437
|
+
* @since TP-164 (waveIndex parameter added in #509 remediation)
|
|
432
438
|
*/
|
|
433
439
|
export function readMergeSnapshot(
|
|
434
440
|
stateRoot: string,
|
|
435
441
|
batchId: string,
|
|
442
|
+
waveIndex: number,
|
|
436
443
|
mergeNumber: number,
|
|
437
444
|
): RuntimeMergeSnapshot | null {
|
|
438
445
|
try {
|
|
439
|
-
const p = runtimeMergeSnapshotPath(stateRoot, batchId, mergeNumber);
|
|
446
|
+
const p = runtimeMergeSnapshotPath(stateRoot, batchId, waveIndex, mergeNumber);
|
|
440
447
|
if (!existsSync(p)) return null;
|
|
441
448
|
return JSON.parse(readFileSync(p, "utf-8")) as RuntimeMergeSnapshot;
|
|
442
449
|
} catch {
|
|
@@ -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
|
|
302
|
-
*
|
|
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
|
-
|
|
313
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
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;
|
|
@@ -4300,12 +4323,26 @@ export interface RuntimeMergeSnapshot {
|
|
|
4300
4323
|
*
|
|
4301
4324
|
* @since TP-164
|
|
4302
4325
|
*/
|
|
4326
|
+
/**
|
|
4327
|
+
* Path to a merge agent snapshot file.
|
|
4328
|
+
*
|
|
4329
|
+
* The filename includes BOTH `waveIndex` and `mergeNumber` because lane
|
|
4330
|
+
* numbers (and therefore the legacy `mergeNumber`-only filename) repeat
|
|
4331
|
+
* across waves — a wave-2 lane-1 merge would overwrite the wave-1 lane-1
|
|
4332
|
+
* snapshot before the dashboard's next poll could read it. Per-wave
|
|
4333
|
+
* namespacing keeps each merge's snapshot durable until the runtime
|
|
4334
|
+
* directory itself is cleaned up at end-of-batch. See #509.
|
|
4335
|
+
*
|
|
4336
|
+
* @param waveIndex 0-based wave index for the merge
|
|
4337
|
+
* @param mergeNumber 1-based merge agent number (derived from lane number)
|
|
4338
|
+
*/
|
|
4303
4339
|
export function runtimeMergeSnapshotPath(
|
|
4304
4340
|
stateRoot: string,
|
|
4305
4341
|
batchId: string,
|
|
4342
|
+
waveIndex: number,
|
|
4306
4343
|
mergeNumber: number,
|
|
4307
4344
|
): string {
|
|
4308
|
-
return `${stateRoot}/.pi/runtime/${batchId}/lanes/merge-${mergeNumber}.json`;
|
|
4345
|
+
return `${stateRoot}/.pi/runtime/${batchId}/lanes/merge-w${waveIndex}-${mergeNumber}.json`;
|
|
4309
4346
|
}
|
|
4310
4347
|
|
|
4311
4348
|
/**
|