taskplane 0.1.18 → 0.2.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.
- package/dashboard/public/app.js +155 -1
- package/dashboard/public/index.html +3 -0
- package/dashboard/public/style.css +73 -6
- package/dashboard/server.cjs +5 -0
- package/extensions/taskplane/abort.ts +24 -3
- package/extensions/taskplane/discovery.ts +24 -0
- package/extensions/taskplane/engine.ts +58 -62
- package/extensions/taskplane/execution.ts +4 -2
- package/extensions/taskplane/extension.ts +12 -1
- package/extensions/taskplane/index.ts +1 -0
- package/extensions/taskplane/merge.ts +250 -6
- package/extensions/taskplane/messages.ts +207 -3
- package/extensions/taskplane/naming.ts +117 -0
- package/extensions/taskplane/persistence.ts +174 -24
- package/extensions/taskplane/resume.ts +329 -76
- package/extensions/taskplane/types.ts +153 -6
- package/extensions/taskplane/waves.ts +386 -94
- package/extensions/taskplane/workspace.ts +17 -0
- package/extensions/taskplane/worktree.ts +67 -35
- package/package.json +1 -1
- package/templates/config/task-orchestrator.yaml +7 -2
package/dashboard/public/app.js
CHANGED
|
@@ -137,6 +137,13 @@ const $historySelect = $("history-select");
|
|
|
137
137
|
const $historyPanel = $("history-panel");
|
|
138
138
|
const $historyBody = $("history-body");
|
|
139
139
|
|
|
140
|
+
// ─── Repo Filter State ──────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
const $repoFilter = $("repo-filter");
|
|
143
|
+
let selectedRepo = ""; // "" means "All repos"
|
|
144
|
+
let knownRepos = []; // sorted list of known repo IDs
|
|
145
|
+
let repoFilterVisible = false;
|
|
146
|
+
|
|
140
147
|
// ─── History State ──────────────────────────────────────────────────────────
|
|
141
148
|
|
|
142
149
|
let historyList = []; // compact batch summaries
|
|
@@ -147,6 +154,97 @@ let viewingHistoryId = null; // batchId if viewing history, null if live
|
|
|
147
154
|
let viewerMode = null; // "conversation" | "status-md" | null
|
|
148
155
|
let viewerTarget = null; // session name (conversation) or taskId (status-md)
|
|
149
156
|
|
|
157
|
+
// ─── Repo Helpers ───────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Build a sorted, deduplicated list of repo IDs from the batch payload.
|
|
161
|
+
* Returns empty array when mode !== "workspace" or when fewer than 2 repos.
|
|
162
|
+
*/
|
|
163
|
+
function buildRepoSet(batch) {
|
|
164
|
+
if (!batch || batch.mode !== "workspace") return [];
|
|
165
|
+
|
|
166
|
+
const repos = new Set();
|
|
167
|
+
for (const lane of (batch.lanes || [])) {
|
|
168
|
+
if (lane.repoId) repos.add(lane.repoId);
|
|
169
|
+
}
|
|
170
|
+
for (const task of (batch.tasks || [])) {
|
|
171
|
+
const rid = task.resolvedRepoId || task.repoId;
|
|
172
|
+
if (rid) repos.add(rid);
|
|
173
|
+
}
|
|
174
|
+
for (const mr of (batch.mergeResults || [])) {
|
|
175
|
+
for (const rr of (mr.repoResults || [])) {
|
|
176
|
+
if (rr.repoId) repos.add(rr.repoId);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const sorted = Array.from(repos).sort();
|
|
180
|
+
return sorted.length >= 2 ? sorted : [];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Update the repo filter dropdown options and visibility.
|
|
185
|
+
* Resets selection to "All repos" if the previously selected repo disappeared.
|
|
186
|
+
*/
|
|
187
|
+
function updateRepoFilter(repos) {
|
|
188
|
+
knownRepos = repos;
|
|
189
|
+
const shouldShow = repos.length >= 2;
|
|
190
|
+
|
|
191
|
+
if (shouldShow !== repoFilterVisible) {
|
|
192
|
+
$repoFilter.style.display = shouldShow ? "" : "none";
|
|
193
|
+
repoFilterVisible = shouldShow;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (!shouldShow) {
|
|
197
|
+
selectedRepo = "";
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// If selected repo disappeared, reset to "All"
|
|
202
|
+
if (selectedRepo && !repos.includes(selectedRepo)) {
|
|
203
|
+
selectedRepo = "";
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Rebuild options only if repo set changed
|
|
207
|
+
const currentOpts = Array.from($repoFilter.options).slice(1).map(o => o.value);
|
|
208
|
+
const changed = currentOpts.length !== repos.length || currentOpts.some((v, i) => v !== repos[i]);
|
|
209
|
+
if (changed) {
|
|
210
|
+
// Preserve selection
|
|
211
|
+
const prev = selectedRepo;
|
|
212
|
+
$repoFilter.innerHTML = '<option value="">All repos</option>';
|
|
213
|
+
for (const r of repos) {
|
|
214
|
+
const opt = document.createElement("option");
|
|
215
|
+
opt.value = r;
|
|
216
|
+
opt.textContent = r;
|
|
217
|
+
$repoFilter.appendChild(opt);
|
|
218
|
+
}
|
|
219
|
+
$repoFilter.value = prev;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Get the effective repo ID for a task (prefer resolvedRepoId, fallback repoId). */
|
|
224
|
+
function taskRepoId(task) {
|
|
225
|
+
return task.resolvedRepoId || task.repoId || undefined;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Render a repo badge span. Returns "" if repoId is falsy or repos not active. */
|
|
229
|
+
function repoBadgeHtml(repoId, extraClass) {
|
|
230
|
+
if (!repoId || knownRepos.length < 2) return "";
|
|
231
|
+
return `<span class="repo-badge ${extraClass || ""}" title="Repo: ${escapeHtml(repoId)}">${escapeHtml(repoId)}</span>`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Repo filter change handler
|
|
235
|
+
$repoFilter.addEventListener("change", (e) => {
|
|
236
|
+
selectedRepo = e.target.value;
|
|
237
|
+
// Re-render with current data
|
|
238
|
+
if (currentData) {
|
|
239
|
+
const batch = currentData.batch;
|
|
240
|
+
const tmux = currentData.tmuxSessions || [];
|
|
241
|
+
if (batch) {
|
|
242
|
+
renderLanesTasks(batch, tmux);
|
|
243
|
+
renderMergeAgents(batch, tmux);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
150
248
|
// ─── Render: Header ─────────────────────────────────────────────────────────
|
|
151
249
|
|
|
152
250
|
function renderHeader(batch) {
|
|
@@ -288,9 +386,18 @@ function renderLanesTasks(batch, tmuxSessions) {
|
|
|
288
386
|
const tasks = batch.tasks || [];
|
|
289
387
|
const tmuxSet = new Set(tmuxSessions || []);
|
|
290
388
|
const laneStates = currentData?.laneStates || {};
|
|
389
|
+
const showRepos = knownRepos.length >= 2;
|
|
291
390
|
let html = "";
|
|
292
391
|
|
|
293
392
|
for (const lane of batch.lanes) {
|
|
393
|
+
// Repo filtering: if a repo is selected, skip lanes that don't match
|
|
394
|
+
if (selectedRepo && showRepos) {
|
|
395
|
+
const laneTasks = (lane.taskIds || []).map(tid => tasks.find(t => t.taskId === tid)).filter(Boolean);
|
|
396
|
+
const laneMatchesRepo = (lane.repoId === selectedRepo) ||
|
|
397
|
+
laneTasks.some(t => (taskRepoId(t) || lane.repoId) === selectedRepo);
|
|
398
|
+
if (!laneMatchesRepo) continue;
|
|
399
|
+
}
|
|
400
|
+
|
|
294
401
|
const alive = tmuxSet.has(lane.tmuxSessionName);
|
|
295
402
|
const tmuxCmd = `tmux attach -t ${lane.tmuxSessionName}`;
|
|
296
403
|
|
|
@@ -301,6 +408,9 @@ function renderLanesTasks(batch, tmuxSessions) {
|
|
|
301
408
|
html += ` <div class="lane-meta">`;
|
|
302
409
|
html += ` <span class="lane-session">${escapeHtml(lane.tmuxSessionName || "—")}</span>`;
|
|
303
410
|
html += ` <span class="lane-branch">${escapeHtml(lane.branch || "—")}</span>`;
|
|
411
|
+
if (showRepos && lane.repoId) {
|
|
412
|
+
html += ` ${repoBadgeHtml(lane.repoId, "repo-badge-lane")}`;
|
|
413
|
+
}
|
|
304
414
|
html += ` </div>`;
|
|
305
415
|
html += ` <div class="lane-right">`;
|
|
306
416
|
html += ` <span class="tmux-dot ${alive ? "alive" : "dead"}" title="${alive ? "tmux alive" : "tmux dead"}"></span>`;
|
|
@@ -326,6 +436,10 @@ function renderLanesTasks(batch, tmuxSessions) {
|
|
|
326
436
|
const ls = laneStates[lane.tmuxSessionName] || null;
|
|
327
437
|
|
|
328
438
|
for (const task of laneTasks) {
|
|
439
|
+
// Repo filtering at task level
|
|
440
|
+
const tRepo = taskRepoId(task) || lane.repoId;
|
|
441
|
+
if (selectedRepo && showRepos && tRepo !== selectedRepo) continue;
|
|
442
|
+
|
|
329
443
|
const sd = task.statusData;
|
|
330
444
|
const dur = task.startedAt
|
|
331
445
|
? formatDuration((task.endedAt || Date.now()) - task.startedAt)
|
|
@@ -402,7 +516,7 @@ function renderLanesTasks(batch, tmuxSessions) {
|
|
|
402
516
|
<div class="task-row">
|
|
403
517
|
<span class="task-icon"><span class="status-dot ${task.status}"></span></span>
|
|
404
518
|
<span class="task-actions">${eyeHtml}</span>
|
|
405
|
-
<span class="task-id status-${task.status}">${escapeHtml(task.taskId)}</span>
|
|
519
|
+
<span class="task-id status-${task.status}">${escapeHtml(task.taskId)}${showRepos ? repoBadgeHtml(tRepo, "repo-badge-task") : ""}</span>
|
|
406
520
|
<span><span class="status-badge status-${task.status}"><span class="status-dot ${task.status}"></span> ${task.status}</span></span>
|
|
407
521
|
<span class="task-duration">${dur}</span>
|
|
408
522
|
<span>${progressHtml}</span>
|
|
@@ -421,6 +535,7 @@ function renderLanesTasks(batch, tmuxSessions) {
|
|
|
421
535
|
function renderMergeAgents(batch, tmuxSessions) {
|
|
422
536
|
const mergeResults = batch?.mergeResults || [];
|
|
423
537
|
const tmuxSet = new Set(tmuxSessions || []);
|
|
538
|
+
const showRepos = knownRepos.length >= 2;
|
|
424
539
|
|
|
425
540
|
// Check for active merge sessions (convention: orch-merge-*)
|
|
426
541
|
const mergeSessions = (tmuxSessions || []).filter(s => s.startsWith("orch-merge"));
|
|
@@ -436,6 +551,14 @@ function renderMergeAgents(batch, tmuxSessions) {
|
|
|
436
551
|
|
|
437
552
|
// Show merge results
|
|
438
553
|
for (const mr of mergeResults) {
|
|
554
|
+
// Repo filtering: if a repo is selected and this merge has repoResults,
|
|
555
|
+
// check if the selected repo is among them
|
|
556
|
+
const repoResults = mr.repoResults || [];
|
|
557
|
+
if (selectedRepo && showRepos && repoResults.length >= 2) {
|
|
558
|
+
const hasSelectedRepo = repoResults.some(rr => rr.repoId === selectedRepo);
|
|
559
|
+
if (!hasSelectedRepo) continue;
|
|
560
|
+
}
|
|
561
|
+
|
|
439
562
|
const statusCls = mr.status === "succeeded" ? "status-succeeded"
|
|
440
563
|
: mr.status === "partial" ? "status-stalled"
|
|
441
564
|
: "status-failed";
|
|
@@ -458,6 +581,29 @@ function renderMergeAgents(batch, tmuxSessions) {
|
|
|
458
581
|
html += `</td>`;
|
|
459
582
|
html += `<td style="font-size:0.8rem;color:var(--text-muted);">${mr.failureReason ? escapeHtml(mr.failureReason) : "—"}</td>`;
|
|
460
583
|
html += `</tr>`;
|
|
584
|
+
|
|
585
|
+
// Per-repo sub-rows: only when repoResults has 2+ entries (workspace mode)
|
|
586
|
+
if (showRepos && repoResults.length >= 2) {
|
|
587
|
+
const displayRepos = selectedRepo
|
|
588
|
+
? repoResults.filter(rr => rr.repoId === selectedRepo)
|
|
589
|
+
: repoResults;
|
|
590
|
+
|
|
591
|
+
for (const rr of displayRepos) {
|
|
592
|
+
const rrStatusCls = rr.status === "succeeded" ? "status-succeeded"
|
|
593
|
+
: rr.status === "partial" ? "status-stalled"
|
|
594
|
+
: "status-failed";
|
|
595
|
+
const rrLanes = (rr.laneNumbers || []).map(n => `L${n}`).join(", ") || "—";
|
|
596
|
+
const rrDetail = rr.failureReason ? escapeHtml(rr.failureReason) : "—";
|
|
597
|
+
|
|
598
|
+
html += `<tr class="merge-repo-row">`;
|
|
599
|
+
html += `<td>${repoBadgeHtml(rr.repoId)}</td>`;
|
|
600
|
+
html += `<td><span class="status-badge ${rrStatusCls}">${rr.status}</span></td>`;
|
|
601
|
+
html += `<td style="font-family:var(--font-mono);font-size:0.75rem;color:var(--text-faint);">${rrLanes}</td>`;
|
|
602
|
+
html += `<td></td>`;
|
|
603
|
+
html += `<td style="font-size:0.75rem;color:var(--text-faint);">${rrDetail}</td>`;
|
|
604
|
+
html += `</tr>`;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
461
607
|
}
|
|
462
608
|
|
|
463
609
|
// Show active merge sessions not yet in results
|
|
@@ -504,6 +650,9 @@ function renderNoBatch() {
|
|
|
504
650
|
if (noBatchRendered) return;
|
|
505
651
|
noBatchRendered = true;
|
|
506
652
|
|
|
653
|
+
// Hide repo filter when no batch
|
|
654
|
+
updateRepoFilter([]);
|
|
655
|
+
|
|
507
656
|
// Hide live panels, show history panel
|
|
508
657
|
const $lanesPanel = document.getElementById("lanes-tasks-panel");
|
|
509
658
|
const $mergePanel = document.getElementById("merge-panel");
|
|
@@ -570,6 +719,11 @@ function render(data) {
|
|
|
570
719
|
|
|
571
720
|
renderHeader(batch);
|
|
572
721
|
renderSummary(batch);
|
|
722
|
+
|
|
723
|
+
// Update repo filter based on current batch data
|
|
724
|
+
const repos = buildRepoSet(batch);
|
|
725
|
+
updateRepoFilter(repos);
|
|
726
|
+
|
|
573
727
|
renderLanesTasks(batch, tmux);
|
|
574
728
|
renderMergeAgents(batch, tmux);
|
|
575
729
|
renderErrors(batch);
|
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
<select class="history-select" id="history-select" title="View past batch runs">
|
|
19
19
|
<option value="">History ▾</option>
|
|
20
20
|
</select>
|
|
21
|
+
<select class="repo-filter-select" id="repo-filter" title="Filter by repository" style="display:none;">
|
|
22
|
+
<option value="">All repos</option>
|
|
23
|
+
</select>
|
|
21
24
|
<div class="header-meta">
|
|
22
25
|
<span id="last-update">—</span>
|
|
23
26
|
<span class="connection-dot disconnected" id="conn-dot" title="SSE connection"></span>
|
|
@@ -712,22 +712,26 @@ body {
|
|
|
712
712
|
background: none;
|
|
713
713
|
border: none;
|
|
714
714
|
cursor: pointer;
|
|
715
|
-
font-size: 0.
|
|
716
|
-
padding:
|
|
715
|
+
font-size: 0.85rem;
|
|
716
|
+
padding: 3px 5px;
|
|
717
717
|
border-radius: var(--radius-sm);
|
|
718
|
-
opacity:
|
|
719
|
-
transition: opacity 0.2s, background 0.2s;
|
|
718
|
+
opacity: 1;
|
|
719
|
+
transition: opacity 0.2s, background 0.2s, color 0.2s, box-shadow 0.2s;
|
|
720
720
|
line-height: 1;
|
|
721
|
+
color: var(--text-muted);
|
|
721
722
|
}
|
|
722
723
|
|
|
723
724
|
.viewer-eye-btn:hover {
|
|
724
725
|
opacity: 1;
|
|
725
|
-
background: rgba(88,166,255,0.
|
|
726
|
+
background: rgba(88,166,255,0.18);
|
|
727
|
+
color: var(--accent);
|
|
726
728
|
}
|
|
727
729
|
|
|
728
730
|
.viewer-eye-btn.active {
|
|
729
731
|
opacity: 1;
|
|
730
|
-
background: rgba(88,166,255,0.
|
|
732
|
+
background: rgba(88,166,255,0.25);
|
|
733
|
+
color: var(--accent);
|
|
734
|
+
box-shadow: 0 0 0 1px var(--accent-dim);
|
|
731
735
|
}
|
|
732
736
|
|
|
733
737
|
.tmux-view-btn.active {
|
|
@@ -940,6 +944,69 @@ body {
|
|
|
940
944
|
.progress-bar-bg { width: 160px; }
|
|
941
945
|
}
|
|
942
946
|
|
|
947
|
+
/* ─── Repo Filter & Badges ─────────────────────────────────────────────── */
|
|
948
|
+
|
|
949
|
+
.repo-filter-select {
|
|
950
|
+
background: var(--bg-surface);
|
|
951
|
+
border: 1px solid var(--border);
|
|
952
|
+
color: var(--text-muted);
|
|
953
|
+
font-family: var(--font-mono);
|
|
954
|
+
font-size: 0.8rem;
|
|
955
|
+
padding: 3px 8px;
|
|
956
|
+
border-radius: 8px;
|
|
957
|
+
cursor: pointer;
|
|
958
|
+
max-width: 220px;
|
|
959
|
+
}
|
|
960
|
+
.repo-filter-select:hover {
|
|
961
|
+
border-color: var(--accent);
|
|
962
|
+
color: var(--text);
|
|
963
|
+
}
|
|
964
|
+
.repo-filter-select:focus {
|
|
965
|
+
outline: none;
|
|
966
|
+
border-color: var(--accent);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
.repo-badge {
|
|
970
|
+
display: inline-flex;
|
|
971
|
+
align-items: center;
|
|
972
|
+
gap: 4px;
|
|
973
|
+
font-family: var(--font-mono);
|
|
974
|
+
font-size: 0.68rem;
|
|
975
|
+
font-weight: 500;
|
|
976
|
+
padding: 1px 7px;
|
|
977
|
+
border-radius: 8px;
|
|
978
|
+
background: rgba(188,140,255,0.12);
|
|
979
|
+
color: var(--magenta);
|
|
980
|
+
white-space: nowrap;
|
|
981
|
+
max-width: 140px;
|
|
982
|
+
overflow: hidden;
|
|
983
|
+
text-overflow: ellipsis;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
.repo-badge-lane {
|
|
987
|
+
margin-left: 8px;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
.repo-badge-task {
|
|
991
|
+
margin-left: 4px;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/* Merge repo sub-rows */
|
|
995
|
+
.merge-repo-row td {
|
|
996
|
+
padding: 4px 12px 4px 28px !important;
|
|
997
|
+
font-size: 0.78rem !important;
|
|
998
|
+
color: var(--text-muted);
|
|
999
|
+
border-bottom: 1px solid var(--border-subtle);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
.merge-repo-row td:first-child {
|
|
1003
|
+
padding-left: 28px !important;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
.merge-repo-row:last-child td {
|
|
1007
|
+
border-bottom: none;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
943
1010
|
/* ─── Scrollbar ────────────────────────────────────────────────────────── */
|
|
944
1011
|
|
|
945
1012
|
::-webkit-scrollbar { width: 6px; }
|
package/dashboard/server.cjs
CHANGED
|
@@ -191,11 +191,16 @@ function buildDashboardState() {
|
|
|
191
191
|
currentWaveIndex: state.currentWaveIndex || 0,
|
|
192
192
|
totalWaves: state.totalWaves || (state.wavePlan ? state.wavePlan.length : 0),
|
|
193
193
|
wavePlan: state.wavePlan || [],
|
|
194
|
+
// Lanes already include repoId (string|undefined) from PersistedLaneRecord (v2).
|
|
194
195
|
lanes: state.lanes || [],
|
|
196
|
+
// Tasks already include repoId, resolvedRepoId (string|undefined) from PersistedTaskRecord (v2).
|
|
195
197
|
tasks,
|
|
196
198
|
mergeResults: state.mergeResults || [],
|
|
197
199
|
errors: state.errors || [],
|
|
198
200
|
lastError: state.lastError || null,
|
|
201
|
+
// Workspace mode: "repo" (default/v1) or "workspace" (v2 multi-repo).
|
|
202
|
+
// Additive field — absent in v1 state files, frontend must default to "repo".
|
|
203
|
+
mode: state.mode || "repo",
|
|
199
204
|
},
|
|
200
205
|
tmuxSessions,
|
|
201
206
|
timestamp: Date.now(),
|
|
@@ -8,7 +8,7 @@ import { join } from "path";
|
|
|
8
8
|
|
|
9
9
|
import { execLog, resolveCanonicalTaskPaths, tmuxHasSession, tmuxKillSession } from "./execution.ts";
|
|
10
10
|
import { deleteBatchState, parseOrchSessionNames, persistRuntimeState } from "./persistence.ts";
|
|
11
|
-
import type { AbortActionStep, AbortErrorCode, AbortLaneResult, AbortMode, AbortResult, AbortTargetSession, AllocatedLane, OrchBatchRuntimeState, PersistedBatchState } from "./types.ts";
|
|
11
|
+
import type { AbortActionStep, AbortErrorCode, AbortLaneResult, AbortMode, AbortResult, AbortTargetSession, AllocatedLane, OrchBatchRuntimeState, PersistedBatchState, PersistedLaneRecord } from "./types.ts";
|
|
12
12
|
|
|
13
13
|
// ── Abort Pure Functions ─────────────────────────────────────────────
|
|
14
14
|
|
|
@@ -35,20 +35,41 @@ export function selectAbortTargetSessions(
|
|
|
35
35
|
prefix: string = "orch",
|
|
36
36
|
): AbortTargetSession[] {
|
|
37
37
|
// Filter to only lane and merge sessions for the exact orchestrator prefix.
|
|
38
|
+
// Handles both repo-mode (`<prefix>-lane-<N>`) and workspace-mode
|
|
39
|
+
// (`<prefix>-<repoId>-lane-<N>`) session name formats.
|
|
38
40
|
const targetNames = allSessionNames.filter(name => {
|
|
39
41
|
const prefixWithDash = `${prefix}-`;
|
|
40
42
|
if (!name.startsWith(prefixWithDash)) return false;
|
|
41
43
|
const suffix = name.slice(prefixWithDash.length);
|
|
42
|
-
|
|
44
|
+
// Repo mode: suffix starts with "lane-" or "merge-"
|
|
45
|
+
if (suffix.startsWith("lane-") || suffix.startsWith("merge-")) return true;
|
|
46
|
+
// Workspace mode: suffix is "<repoId>-lane-<N>" — contains "-lane-"
|
|
47
|
+
// Match any suffix that contains "-lane-" or "-merge-" followed by a number
|
|
48
|
+
if (/\-lane-\d/.test(suffix) || /\-merge-\d/.test(suffix)) return true;
|
|
49
|
+
return false;
|
|
43
50
|
});
|
|
44
51
|
|
|
52
|
+
// Build lookup from persisted lane records for workspace-aware laneId resolution.
|
|
53
|
+
// Keyed by tmuxSessionName for direct session-to-lane mapping.
|
|
54
|
+
const persistedLaneLookup = new Map<string, PersistedLaneRecord>();
|
|
55
|
+
if (persistedState?.lanes) {
|
|
56
|
+
for (const lane of persistedState.lanes) {
|
|
57
|
+
persistedLaneLookup.set(lane.tmuxSessionName, lane);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
45
61
|
// Build lookup from persisted state task records
|
|
46
62
|
const persistedLookup = new Map<string, { laneId: string; taskId: string; taskFolder: string }>();
|
|
47
63
|
if (persistedState) {
|
|
48
64
|
for (const task of persistedState.tasks) {
|
|
49
65
|
if (task.sessionName) {
|
|
66
|
+
// Source laneId from persisted lane records (workspace-aware)
|
|
67
|
+
// rather than reconstructing as `lane-${laneNumber}` which
|
|
68
|
+
// drops the repo dimension in workspace mode.
|
|
69
|
+
const laneRecord = persistedLaneLookup.get(task.sessionName);
|
|
70
|
+
const laneId = laneRecord?.laneId ?? `lane-${task.laneNumber}`;
|
|
50
71
|
persistedLookup.set(task.sessionName, {
|
|
51
|
-
laneId
|
|
72
|
+
laneId,
|
|
52
73
|
taskId: task.taskId,
|
|
53
74
|
taskFolder: task.taskFolder,
|
|
54
75
|
});
|
|
@@ -886,8 +886,32 @@ export function resolveTaskRouting(
|
|
|
886
886
|
): DiscoveryError[] {
|
|
887
887
|
const errors: DiscoveryError[] = [];
|
|
888
888
|
const validRepoIds = workspaceConfig.repos;
|
|
889
|
+
const strictMode = workspaceConfig.routing.strict === true;
|
|
889
890
|
|
|
890
891
|
for (const task of discovery.pending.values()) {
|
|
892
|
+
// ── Strict mode enforcement ──────────────────────────────
|
|
893
|
+
// When strict routing is enabled, every task MUST declare an
|
|
894
|
+
// explicit execution target in PROMPT.md. Area-level and
|
|
895
|
+
// workspace-default fallbacks are NOT used for resolution.
|
|
896
|
+
if (strictMode && !task.promptRepoId) {
|
|
897
|
+
errors.push({
|
|
898
|
+
code: "TASK_ROUTING_STRICT",
|
|
899
|
+
message:
|
|
900
|
+
`Task ${task.taskId} has no explicit execution target, but strict routing is enabled ` +
|
|
901
|
+
`(routing.strict: true in workspace config). ` +
|
|
902
|
+
`Add an execution target to the task's PROMPT.md:\n` +
|
|
903
|
+
`\n` +
|
|
904
|
+
` ## Execution Target\n` +
|
|
905
|
+
`\n` +
|
|
906
|
+
` Repo: <repo-id>\n` +
|
|
907
|
+
`\n` +
|
|
908
|
+
`Available repos: ${[...validRepoIds.keys()].join(", ")}`,
|
|
909
|
+
taskId: task.taskId,
|
|
910
|
+
taskPath: task.promptPath,
|
|
911
|
+
});
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
|
|
891
915
|
// Precedence 1: prompt-declared repo
|
|
892
916
|
let resolvedId = task.promptRepoId;
|
|
893
917
|
let source = "prompt";
|
|
@@ -9,13 +9,14 @@ import { formatDiscoveryResults, runDiscovery } from "./discovery.ts";
|
|
|
9
9
|
import { execLog, executeWave, tmuxKillSession } from "./execution.ts";
|
|
10
10
|
import type { MonitorUpdateCallback } from "./execution.ts";
|
|
11
11
|
import { getCurrentBranch, runGit } from "./git.ts";
|
|
12
|
-
import {
|
|
13
|
-
import { ORCH_MESSAGES } from "./messages.ts";
|
|
12
|
+
import { mergeWaveByRepo } from "./merge.ts";
|
|
13
|
+
import { computeMergeFailurePolicy, formatRepoMergeSummary, ORCH_MESSAGES } from "./messages.ts";
|
|
14
|
+
import { resolveOperatorId } from "./naming.ts";
|
|
14
15
|
import { deleteBatchState, loadBatchHistory, persistRuntimeState, saveBatchHistory, seedPendingOutcomesForAllocatedLanes, syncTaskOutcomesFromMonitor, upsertTaskOutcome } from "./persistence.ts";
|
|
15
16
|
import { listOrchSessions } from "./sessions.ts";
|
|
16
17
|
import { FATAL_DISCOVERY_CODES, generateBatchId } from "./types.ts";
|
|
17
18
|
import type { AllocatedLane, BatchHistorySummary, BatchTaskSummary, BatchWaveSummary, DiscoveryResult, LaneExecutionResult, LaneTaskOutcome, MergeWaveResult, OrchBatchPhase, OrchBatchRuntimeState, OrchestratorConfig, TaskRunnerConfig, TokenCounts, WorkspaceConfig } from "./types.ts";
|
|
18
|
-
import { buildDependencyGraph, computeWaves, validateGraph } from "./waves.ts";
|
|
19
|
+
import { buildDependencyGraph, computeWaves, resolveRepoRoot, validateGraph } from "./waves.ts";
|
|
19
20
|
import { deleteBranchBestEffort, forceCleanupWorktree, formatPreflightResults, listWorktrees, removeAllWorktrees, removeWorktree, runPreflight, safeResetWorktree, sleepSync } from "./worktree.ts";
|
|
20
21
|
|
|
21
22
|
// ── /orch Execution Engine ───────────────────────────────────────────
|
|
@@ -52,6 +53,7 @@ export async function executeOrchBatch(
|
|
|
52
53
|
batchState.startedAt = Date.now();
|
|
53
54
|
batchState.pauseSignal = { paused: false };
|
|
54
55
|
batchState.mergeResults = [];
|
|
56
|
+
batchState.mode = workspaceConfig ? "workspace" : "repo";
|
|
55
57
|
|
|
56
58
|
// Capture the current branch as the base for worktrees and merge target
|
|
57
59
|
const detectedBranch = getCurrentBranch(repoRoot);
|
|
@@ -83,7 +85,7 @@ export async function executeOrchBatch(
|
|
|
83
85
|
execLog("batch", batchState.batchId, "starting batch planning");
|
|
84
86
|
|
|
85
87
|
// Preflight
|
|
86
|
-
const preflight = runPreflight(orchConfig);
|
|
88
|
+
const preflight = runPreflight(orchConfig, repoRoot);
|
|
87
89
|
onNotify(formatPreflightResults(preflight), preflight.passed ? "info" : "error");
|
|
88
90
|
if (!preflight.passed) {
|
|
89
91
|
batchState.phase = "failed";
|
|
@@ -118,6 +120,17 @@ export async function executeOrchBatch(
|
|
|
118
120
|
"info",
|
|
119
121
|
);
|
|
120
122
|
}
|
|
123
|
+
const hasStrictErrors = fatalErrors.some(
|
|
124
|
+
(e) => e.code === "TASK_ROUTING_STRICT",
|
|
125
|
+
);
|
|
126
|
+
if (hasStrictErrors) {
|
|
127
|
+
onNotify(
|
|
128
|
+
"💡 Strict routing is enabled (routing.strict: true). Every task must declare an explicit execution target.\n" +
|
|
129
|
+
" Add a `## Execution Target` section with `Repo: <id>` to each task's PROMPT.md.\n" +
|
|
130
|
+
" To disable strict routing, set `routing.strict: false` in workspace config.",
|
|
131
|
+
"info",
|
|
132
|
+
);
|
|
133
|
+
}
|
|
121
134
|
return;
|
|
122
135
|
}
|
|
123
136
|
|
|
@@ -241,6 +254,7 @@ export async function executeOrchBatch(
|
|
|
241
254
|
persistRuntimeState("wave-lanes-allocated", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, repoRoot);
|
|
242
255
|
}
|
|
243
256
|
},
|
|
257
|
+
workspaceConfig,
|
|
244
258
|
);
|
|
245
259
|
|
|
246
260
|
batchState.waveResults.push(waveResult);
|
|
@@ -332,7 +346,7 @@ export async function executeOrchBatch(
|
|
|
332
346
|
persistRuntimeState("merge-start", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, repoRoot);
|
|
333
347
|
onNotify(ORCH_MESSAGES.orchMergeStart(waveIdx + 1, mergeableLaneCount), "info");
|
|
334
348
|
|
|
335
|
-
mergeResult =
|
|
349
|
+
mergeResult = mergeWaveByRepo(
|
|
336
350
|
waveResult.allocatedLanes,
|
|
337
351
|
waveResult,
|
|
338
352
|
waveIdx + 1,
|
|
@@ -340,6 +354,7 @@ export async function executeOrchBatch(
|
|
|
340
354
|
repoRoot,
|
|
341
355
|
batchState.batchId,
|
|
342
356
|
batchState.baseBranch,
|
|
357
|
+
workspaceConfig,
|
|
343
358
|
);
|
|
344
359
|
allMergeResults.push(mergeResult);
|
|
345
360
|
batchState.mergeResults.push(mergeResult);
|
|
@@ -392,6 +407,14 @@ export async function executeOrchBatch(
|
|
|
392
407
|
ORCH_MESSAGES.orchMergeFailed(waveIdx + 1, mergeResult.failedLane ?? 0, mergeResult.failureReason || "unknown"),
|
|
393
408
|
"error",
|
|
394
409
|
);
|
|
410
|
+
|
|
411
|
+
// Emit repo-divergence summary when partial is caused by cross-repo outcome differences
|
|
412
|
+
if (mergeResult.status === "partial") {
|
|
413
|
+
const repoSummary = formatRepoMergeSummary(mergeResult);
|
|
414
|
+
if (repoSummary) {
|
|
415
|
+
onNotify(repoSummary, "warning");
|
|
416
|
+
}
|
|
417
|
+
}
|
|
395
418
|
}
|
|
396
419
|
|
|
397
420
|
// Restore phase to executing (may be overridden below by failure handling)
|
|
@@ -424,58 +447,20 @@ export async function executeOrchBatch(
|
|
|
424
447
|
}
|
|
425
448
|
|
|
426
449
|
// ── Handle merge failure ─────────────────────────────────
|
|
427
|
-
// Apply config.failure.on_merge_failure policy
|
|
450
|
+
// Apply config.failure.on_merge_failure policy via shared helper
|
|
451
|
+
// for guaranteed parity with resume.ts (TP-005 Step 2).
|
|
428
452
|
if (mergeResult && (mergeResult.status === "failed" || mergeResult.status === "partial")) {
|
|
429
|
-
const
|
|
430
|
-
let failedLaneIds = mergeResult.laneResults
|
|
431
|
-
.filter(r => r.result?.status === "CONFLICT_UNRESOLVED" || r.result?.status === "BUILD_FAILURE" || r.error)
|
|
432
|
-
.map(r => `lane-${r.laneNumber}`)
|
|
433
|
-
.join(", ");
|
|
434
|
-
if (!failedLaneIds && mergeResult.failedLane !== null) {
|
|
435
|
-
failedLaneIds = `lane-${mergeResult.failedLane}`;
|
|
436
|
-
}
|
|
453
|
+
const policyResult = computeMergeFailurePolicy(mergeResult, waveIdx, orchConfig);
|
|
437
454
|
|
|
438
|
-
execLog("batch", batchState.batchId, `merge failure — applying ${
|
|
439
|
-
failedLane: mergeResult.failedLane ?? 0,
|
|
440
|
-
failedLaneIds,
|
|
441
|
-
reason: mergeResult.failureReason?.slice(0, 200) || "unknown",
|
|
442
|
-
});
|
|
455
|
+
execLog("batch", batchState.batchId, `merge failure — applying ${policyResult.policy} policy`, policyResult.logDetails);
|
|
443
456
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
persistRuntimeState("merge-failure-pause", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, repoRoot);
|
|
452
|
-
onNotify(
|
|
453
|
-
`⏸️ Batch paused due to merge failure at wave ${waveIdx + 1} (${failedLaneIds}). ` +
|
|
454
|
-
`Reason: ${mergeResult.failureReason?.slice(0, 200) || "unknown"}. ` +
|
|
455
|
-
`Resolve conflicts and resume (TS-009).`,
|
|
456
|
-
"error",
|
|
457
|
-
);
|
|
458
|
-
// DO NOT cleanup/reset worktrees — preserve state for debugging/resume
|
|
459
|
-
preserveWorktreesForResume = true;
|
|
460
|
-
break;
|
|
461
|
-
} else {
|
|
462
|
-
// abort policy
|
|
463
|
-
batchState.phase = "stopped";
|
|
464
|
-
batchState.errors.push(
|
|
465
|
-
`Merge failed at wave ${waveIdx + 1}: ${mergeResult.failureReason || "unknown"}. ` +
|
|
466
|
-
`Batch aborted by on_merge_failure policy.`,
|
|
467
|
-
);
|
|
468
|
-
// ── TS-009: Persist BEFORE cleanup decision (abort) ──
|
|
469
|
-
persistRuntimeState("merge-failure-abort", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, repoRoot);
|
|
470
|
-
onNotify(
|
|
471
|
-
`⛔ Batch aborted due to merge failure at wave ${waveIdx + 1} (${failedLaneIds}). ` +
|
|
472
|
-
`Reason: ${mergeResult.failureReason?.slice(0, 200) || "unknown"}.`,
|
|
473
|
-
"error",
|
|
474
|
-
);
|
|
475
|
-
// DO NOT cleanup/reset worktrees — preserve state for debugging
|
|
476
|
-
preserveWorktreesForResume = true;
|
|
477
|
-
break;
|
|
478
|
-
}
|
|
457
|
+
batchState.phase = policyResult.targetPhase;
|
|
458
|
+
batchState.errors.push(policyResult.errorMessage);
|
|
459
|
+
persistRuntimeState(policyResult.persistTrigger, batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, repoRoot);
|
|
460
|
+
onNotify(policyResult.notifyMessage, policyResult.notifyLevel);
|
|
461
|
+
// DO NOT cleanup/reset worktrees — preserve state for debugging/resume
|
|
462
|
+
preserveWorktreesForResume = true;
|
|
463
|
+
break;
|
|
479
464
|
}
|
|
480
465
|
|
|
481
466
|
// NOTE: Merged branch cleanup is deferred to Phase 3, AFTER worktree
|
|
@@ -485,7 +470,8 @@ export async function executeOrchBatch(
|
|
|
485
470
|
// Only reset if merge succeeded AND there are more waves
|
|
486
471
|
if (waveIdx < rawWaves.length - 1 && !batchState.pauseSignal.paused) {
|
|
487
472
|
const prefix = orchConfig.orchestrator.worktree_prefix;
|
|
488
|
-
const
|
|
473
|
+
const resetOpId = resolveOperatorId(orchConfig);
|
|
474
|
+
const existingWorktrees = listWorktrees(prefix, repoRoot, resetOpId);
|
|
489
475
|
|
|
490
476
|
if (existingWorktrees.length > 0) {
|
|
491
477
|
onNotify(
|
|
@@ -678,8 +664,9 @@ export async function executeOrchBatch(
|
|
|
678
664
|
|
|
679
665
|
// Clean up worktrees — pass base branch to protect unmerged work
|
|
680
666
|
const targetBranch = batchState.baseBranch;
|
|
667
|
+
const cleanupOpId = resolveOperatorId(orchConfig);
|
|
681
668
|
execLog("batch", batchState.batchId, "cleaning up worktrees");
|
|
682
|
-
const removeResult = removeAllWorktrees(prefix, repoRoot, targetBranch);
|
|
669
|
+
const removeResult = removeAllWorktrees(prefix, repoRoot, cleanupOpId, targetBranch);
|
|
683
670
|
|
|
684
671
|
// Log preserved branches
|
|
685
672
|
for (const p of removeResult.preserved) {
|
|
@@ -704,23 +691,32 @@ export async function executeOrchBatch(
|
|
|
704
691
|
// ── Post-worktree-removal: Clean up merged branches ──────
|
|
705
692
|
// This MUST run after worktree removal because git branch -D
|
|
706
693
|
// fails if any worktree still has the branch checked out.
|
|
694
|
+
// In workspace mode, each lane's branch lives in its owning repo,
|
|
695
|
+
// so we resolve the correct repo root per lane using repoId.
|
|
707
696
|
for (const mergeResult of allMergeResults) {
|
|
708
697
|
if (mergeResult.status === "succeeded" || mergeResult.status === "partial") {
|
|
709
698
|
for (const lr of mergeResult.laneResults) {
|
|
710
699
|
if (lr.result?.status === "SUCCESS" || lr.result?.status === "CONFLICT_RESOLVED") {
|
|
700
|
+
const laneRepoRoot = resolveRepoRoot(lr.repoId, repoRoot, workspaceConfig);
|
|
711
701
|
const ancestorCheck = runGit(
|
|
712
|
-
["merge-base", "--is-ancestor", lr.sourceBranch, targetBranch],
|
|
713
|
-
|
|
702
|
+
["merge-base", "--is-ancestor", lr.sourceBranch, lr.targetBranch],
|
|
703
|
+
laneRepoRoot,
|
|
714
704
|
);
|
|
715
705
|
if (ancestorCheck.ok) {
|
|
716
|
-
const deleted = deleteBranchBestEffort(lr.sourceBranch,
|
|
706
|
+
const deleted = deleteBranchBestEffort(lr.sourceBranch, laneRepoRoot);
|
|
717
707
|
if (deleted) {
|
|
718
|
-
execLog("batch", batchState.batchId, `deleted merged branch ${lr.sourceBranch}
|
|
708
|
+
execLog("batch", batchState.batchId, `deleted merged branch ${lr.sourceBranch}`, {
|
|
709
|
+
repoId: lr.repoId ?? "(default)",
|
|
710
|
+
});
|
|
719
711
|
} else {
|
|
720
|
-
execLog("batch", batchState.batchId, `warning: failed to delete merged branch ${lr.sourceBranch} — retained for manual cleanup
|
|
712
|
+
execLog("batch", batchState.batchId, `warning: failed to delete merged branch ${lr.sourceBranch} — retained for manual cleanup`, {
|
|
713
|
+
repoId: lr.repoId ?? "(default)",
|
|
714
|
+
});
|
|
721
715
|
}
|
|
722
716
|
} else {
|
|
723
|
-
execLog("batch", batchState.batchId, `warning: branch ${lr.sourceBranch} not fully merged into ${targetBranch} — retained
|
|
717
|
+
execLog("batch", batchState.batchId, `warning: branch ${lr.sourceBranch} not fully merged into ${lr.targetBranch} — retained`, {
|
|
718
|
+
repoId: lr.repoId ?? "(default)",
|
|
719
|
+
});
|
|
724
720
|
}
|
|
725
721
|
}
|
|
726
722
|
}
|