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.
@@ -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.75rem;
716
- padding: 2px;
715
+ font-size: 0.85rem;
716
+ padding: 3px 5px;
717
717
  border-radius: var(--radius-sm);
718
- opacity: 0.6;
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.15);
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.15);
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; }
@@ -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
- return suffix.startsWith("lane-") || suffix.startsWith("merge-");
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: `lane-${task.laneNumber}`,
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 { mergeWave } from "./merge.ts";
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 = mergeWave(
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 mergeFailurePolicy = orchConfig.failure.on_merge_failure;
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 ${mergeFailurePolicy} policy`, {
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
- if (mergeFailurePolicy === "pause") {
445
- batchState.phase = "paused";
446
- batchState.errors.push(
447
- `Merge failed at wave ${waveIdx + 1}: ${mergeResult.failureReason || "unknown"}. ` +
448
- `Batch paused. Resolve conflicts and use /orch-resume to continue.`,
449
- );
450
- // ── TS-009: Persist BEFORE cleanup decision (pause) ──
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 existingWorktrees = listWorktrees(prefix, repoRoot);
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
- repoRoot,
702
+ ["merge-base", "--is-ancestor", lr.sourceBranch, lr.targetBranch],
703
+ laneRepoRoot,
714
704
  );
715
705
  if (ancestorCheck.ok) {
716
- const deleted = deleteBranchBestEffort(lr.sourceBranch, repoRoot);
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
  }