taskplane 0.1.18 → 0.2.0
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 +63 -0
- 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 +57 -61
- package/extensions/taskplane/execution.ts +4 -2
- package/extensions/taskplane/extension.ts +11 -0
- 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 +57 -31
- package/package.json +1 -1
- package/templates/config/task-orchestrator.yaml +7 -2
|
@@ -7,17 +7,153 @@ import { join } from "path";
|
|
|
7
7
|
|
|
8
8
|
import { runDiscovery } from "./discovery.ts";
|
|
9
9
|
import { executeOrchBatch } from "./engine.ts";
|
|
10
|
-
import { execLog, executeWave, pollUntilTaskComplete, spawnLaneSession, tmuxHasSession } from "./execution.ts";
|
|
10
|
+
import { computeTransitiveDependents, execLog, executeWave, pollUntilTaskComplete, spawnLaneSession, tmuxHasSession } from "./execution.ts";
|
|
11
11
|
import type { MonitorUpdateCallback } from "./execution.ts";
|
|
12
12
|
import { runGit } from "./git.ts";
|
|
13
|
-
import {
|
|
14
|
-
import { ORCH_MESSAGES } from "./messages.ts";
|
|
13
|
+
import { mergeWaveByRepo } from "./merge.ts";
|
|
14
|
+
import { computeMergeFailurePolicy, formatRepoMergeSummary, ORCH_MESSAGES } from "./messages.ts";
|
|
15
|
+
import { resolveOperatorId } from "./naming.ts";
|
|
15
16
|
import { deleteBatchState, hasTaskDoneMarker, loadBatchState, persistRuntimeState, seedPendingOutcomesForAllocatedLanes, syncTaskOutcomesFromMonitor, upsertTaskOutcome } from "./persistence.ts";
|
|
16
17
|
import { StateFileError } from "./types.ts";
|
|
17
|
-
import type { AllocatedLane, AllocatedTask, LaneExecutionResult, LaneTaskOutcome, LaneTaskStatus, MergeWaveResult, OrchBatchPhase, OrchBatchRuntimeState, OrchestratorConfig, ParsedTask, PersistedBatchState, ReconciledTaskState, ResumeEligibility, ResumePoint, TaskRunnerConfig, WaveExecutionResult, WorkspaceConfig } from "./types.ts";
|
|
18
|
-
import { buildDependencyGraph } from "./waves.ts";
|
|
18
|
+
import type { AllocatedLane, AllocatedTask, LaneExecutionResult, LaneTaskOutcome, LaneTaskStatus, MergeWaveResult, OrchBatchPhase, OrchBatchRuntimeState, OrchestratorConfig, ParsedTask, PersistedBatchState, PersistedLaneRecord, ReconciledTaskState, ResumeEligibility, ResumePoint, TaskRunnerConfig, WaveExecutionResult, WorkspaceConfig } from "./types.ts";
|
|
19
|
+
import { buildDependencyGraph, resolveRepoRoot } from "./waves.ts";
|
|
19
20
|
import { deleteBranchBestEffort, forceCleanupWorktree, listWorktrees, removeAllWorktrees, removeWorktree, safeResetWorktree } from "./worktree.ts";
|
|
20
21
|
|
|
22
|
+
// ── Resume Repo Helpers ──────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Collect unique repo roots from persisted lane records.
|
|
26
|
+
*
|
|
27
|
+
* In repo mode (no repoId on lanes), returns `[defaultRepoRoot]`.
|
|
28
|
+
* In workspace mode, returns one entry per unique repoId, resolved
|
|
29
|
+
* via `resolveRepoRoot()`. Includes the default root as a fallback
|
|
30
|
+
* for lanes with no repoId.
|
|
31
|
+
*
|
|
32
|
+
* Used by inter-wave worktree reset and terminal cleanup to operate
|
|
33
|
+
* on worktrees across all repos in the batch.
|
|
34
|
+
*
|
|
35
|
+
* @param persistedState - Loaded batch state with lane records
|
|
36
|
+
* @param defaultRepoRoot - Default/main repo root (cwd)
|
|
37
|
+
* @param workspaceConfig - Workspace configuration (null in repo mode)
|
|
38
|
+
* @returns Array of unique absolute repo root paths
|
|
39
|
+
*/
|
|
40
|
+
export function collectRepoRoots(
|
|
41
|
+
persistedState: PersistedBatchState,
|
|
42
|
+
defaultRepoRoot: string,
|
|
43
|
+
workspaceConfig?: WorkspaceConfig | null,
|
|
44
|
+
): string[] {
|
|
45
|
+
const roots = new Set<string>();
|
|
46
|
+
|
|
47
|
+
for (const lane of persistedState.lanes) {
|
|
48
|
+
const root = resolveRepoRoot(lane.repoId, defaultRepoRoot, workspaceConfig);
|
|
49
|
+
roots.add(root);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Always include the default repo root (covers repo mode and any
|
|
53
|
+
// lanes without repoId)
|
|
54
|
+
roots.add(defaultRepoRoot);
|
|
55
|
+
|
|
56
|
+
return [...roots];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Reconstruct AllocatedLane[] from persisted lane records.
|
|
61
|
+
*
|
|
62
|
+
* Used during resume to preserve lane metadata (worktreePath, branch, repoId)
|
|
63
|
+
* across persistence checkpoints. Without this, the first resume checkpoint
|
|
64
|
+
* would serialize empty lanes, losing all lane context.
|
|
65
|
+
*
|
|
66
|
+
* When `persistedTasks` is provided, repo attribution fields (repoId,
|
|
67
|
+
* resolvedRepoId, taskFolder) are carried forward onto the reconstructed
|
|
68
|
+
* ParsedTask stubs. This ensures `serializeBatchState()` can emit repo
|
|
69
|
+
* fields for tasks not in `discovery.pending` (e.g., completed/failed tasks
|
|
70
|
+
* that have been archived).
|
|
71
|
+
*
|
|
72
|
+
* @param persistedLanes - Persisted lane records
|
|
73
|
+
* @param persistedTasks - Optional persisted task records for repo field carry-forward
|
|
74
|
+
* @returns Reconstructed AllocatedLane array with repo attribution preserved
|
|
75
|
+
*/
|
|
76
|
+
export function reconstructAllocatedLanes(
|
|
77
|
+
persistedLanes: PersistedLaneRecord[],
|
|
78
|
+
persistedTasks?: PersistedBatchState["tasks"],
|
|
79
|
+
): AllocatedLane[] {
|
|
80
|
+
// Build task lookup for repo field carry-forward
|
|
81
|
+
const taskLookup = new Map<string, PersistedBatchState["tasks"][0]>();
|
|
82
|
+
if (persistedTasks) {
|
|
83
|
+
for (const t of persistedTasks) {
|
|
84
|
+
taskLookup.set(t.taskId, t);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return persistedLanes.map((lr) => ({
|
|
89
|
+
laneNumber: lr.laneNumber,
|
|
90
|
+
laneId: lr.laneId,
|
|
91
|
+
tmuxSessionName: lr.tmuxSessionName,
|
|
92
|
+
worktreePath: lr.worktreePath,
|
|
93
|
+
branch: lr.branch,
|
|
94
|
+
tasks: lr.taskIds.map((taskId) => {
|
|
95
|
+
const persistedTask = taskLookup.get(taskId);
|
|
96
|
+
// Build a minimal ParsedTask stub that carries repo attribution
|
|
97
|
+
// from the persisted record. This ensures serializeBatchState()
|
|
98
|
+
// can emit repoId/resolvedRepoId for tasks not in discovery.
|
|
99
|
+
const taskStub: Partial<ParsedTask> = {};
|
|
100
|
+
if (persistedTask?.repoId !== undefined) {
|
|
101
|
+
taskStub.promptRepoId = persistedTask.repoId;
|
|
102
|
+
}
|
|
103
|
+
if (persistedTask?.resolvedRepoId !== undefined) {
|
|
104
|
+
taskStub.resolvedRepoId = persistedTask.resolvedRepoId;
|
|
105
|
+
}
|
|
106
|
+
if (persistedTask?.taskFolder) {
|
|
107
|
+
taskStub.taskFolder = persistedTask.taskFolder;
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
taskId,
|
|
111
|
+
order: 0,
|
|
112
|
+
task: (Object.keys(taskStub).length > 0 ? taskStub : null) as unknown as ParsedTask,
|
|
113
|
+
estimatedMinutes: 0,
|
|
114
|
+
};
|
|
115
|
+
}),
|
|
116
|
+
strategy: "round-robin" as const,
|
|
117
|
+
estimatedLoad: 0,
|
|
118
|
+
estimatedMinutes: 0,
|
|
119
|
+
...(lr.repoId !== undefined ? { repoId: lr.repoId } : {}),
|
|
120
|
+
}));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Collect unique repo roots from a combination of sources.
|
|
125
|
+
*
|
|
126
|
+
* Unlike `collectRepoRoots()` which only reads from persistedState.lanes,
|
|
127
|
+
* this variant merges repo roots from multiple lane sources. This is
|
|
128
|
+
* important during resumed execution where new waves may allocate lanes
|
|
129
|
+
* in repos not present in the original persisted state.
|
|
130
|
+
*
|
|
131
|
+
* @param laneSources - Array of lane arrays to collect repo roots from
|
|
132
|
+
* @param defaultRepoRoot - Default/main repo root (cwd)
|
|
133
|
+
* @param workspaceConfig - Workspace configuration (null in repo mode)
|
|
134
|
+
* @returns Array of unique absolute repo root paths
|
|
135
|
+
*/
|
|
136
|
+
export function collectAllRepoRoots(
|
|
137
|
+
laneSources: Array<{ repoId?: string }[]>,
|
|
138
|
+
defaultRepoRoot: string,
|
|
139
|
+
workspaceConfig?: WorkspaceConfig | null,
|
|
140
|
+
): string[] {
|
|
141
|
+
const roots = new Set<string>();
|
|
142
|
+
|
|
143
|
+
for (const lanes of laneSources) {
|
|
144
|
+
for (const lane of lanes) {
|
|
145
|
+
const root = resolveRepoRoot(lane.repoId, defaultRepoRoot, workspaceConfig);
|
|
146
|
+
roots.add(root);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Always include the default repo root (covers repo mode and any
|
|
151
|
+
// lanes without repoId)
|
|
152
|
+
roots.add(defaultRepoRoot);
|
|
153
|
+
|
|
154
|
+
return [...roots];
|
|
155
|
+
}
|
|
156
|
+
|
|
21
157
|
// ── Resume Pure Functions ────────────────────────────────────────────
|
|
22
158
|
|
|
23
159
|
/**
|
|
@@ -200,7 +336,23 @@ export function reconcileTaskStates(
|
|
|
200
336
|
};
|
|
201
337
|
}
|
|
202
338
|
|
|
203
|
-
// Precedence 5:
|
|
339
|
+
// Precedence 5: Never-started task (pending + no session assigned) → remain pending
|
|
340
|
+
// These are future-wave tasks that were never allocated to a lane.
|
|
341
|
+
// They should be re-queued for execution, not failed.
|
|
342
|
+
if (task.status === "pending" && !task.sessionName) {
|
|
343
|
+
return {
|
|
344
|
+
taskId: task.taskId,
|
|
345
|
+
persistedStatus: task.status,
|
|
346
|
+
liveStatus: "pending" as LaneTaskStatus,
|
|
347
|
+
sessionAlive: false,
|
|
348
|
+
doneFileFound: false,
|
|
349
|
+
worktreeExists: false,
|
|
350
|
+
action: "pending" as const,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Precedence 6: Dead session + not terminal + no .DONE + no worktree → failed
|
|
355
|
+
// (Task was allocated and started but crashed without completing)
|
|
204
356
|
return {
|
|
205
357
|
taskId: task.taskId,
|
|
206
358
|
persistedStatus: task.status,
|
|
@@ -245,13 +397,16 @@ export function computeResumePoint(
|
|
|
245
397
|
for (const task of reconciledTasks) {
|
|
246
398
|
switch (task.action) {
|
|
247
399
|
case "mark-complete":
|
|
400
|
+
completedTaskIds.push(task.taskId);
|
|
401
|
+
break;
|
|
248
402
|
case "skip":
|
|
249
403
|
if (task.liveStatus === "succeeded" || task.persistedStatus === "succeeded") {
|
|
250
404
|
completedTaskIds.push(task.taskId);
|
|
251
405
|
} else if (task.liveStatus === "failed" || task.liveStatus === "stalled" || task.persistedStatus === "failed" || task.persistedStatus === "stalled") {
|
|
252
406
|
failedTaskIds.push(task.taskId);
|
|
253
407
|
}
|
|
254
|
-
//
|
|
408
|
+
// persistedStatus === "skipped" → terminal but neither completed nor failed.
|
|
409
|
+
// Not re-queued. Counted separately via batchState.skippedTasks (carried from persisted state).
|
|
255
410
|
break;
|
|
256
411
|
case "reconnect":
|
|
257
412
|
reconnectTaskIds.push(task.taskId);
|
|
@@ -262,6 +417,11 @@ export function computeResumePoint(
|
|
|
262
417
|
case "mark-failed":
|
|
263
418
|
failedTaskIds.push(task.taskId);
|
|
264
419
|
break;
|
|
420
|
+
case "pending":
|
|
421
|
+
// Never-started tasks remain pending for execution — not failed.
|
|
422
|
+
// These are future-wave tasks that were never allocated to a lane.
|
|
423
|
+
pendingTaskIds.push(task.taskId);
|
|
424
|
+
break;
|
|
265
425
|
}
|
|
266
426
|
}
|
|
267
427
|
|
|
@@ -273,18 +433,17 @@ export function computeResumePoint(
|
|
|
273
433
|
const allDone = waveTasks.every((taskId) => {
|
|
274
434
|
const reconciled = reconciledMap.get(taskId);
|
|
275
435
|
if (!reconciled) return false;
|
|
276
|
-
// A task is "done" for wave-skip purposes if it
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
);
|
|
436
|
+
// A task is "done" for wave-skip purposes if it's terminal:
|
|
437
|
+
// mark-complete, mark-failed, or skip with any terminal status
|
|
438
|
+
// (succeeded, failed, stalled, skipped)
|
|
439
|
+
if (reconciled.action === "mark-complete" || reconciled.action === "mark-failed") {
|
|
440
|
+
return true;
|
|
441
|
+
}
|
|
442
|
+
if (reconciled.action === "skip") {
|
|
443
|
+
const s = reconciled.liveStatus ?? reconciled.persistedStatus;
|
|
444
|
+
return s === "succeeded" || s === "failed" || s === "stalled" || s === "skipped";
|
|
445
|
+
}
|
|
446
|
+
return false;
|
|
288
447
|
});
|
|
289
448
|
|
|
290
449
|
if (!allDone) {
|
|
@@ -314,6 +473,10 @@ export function computeResumePoint(
|
|
|
314
473
|
// Skipped tasks that were pending need execution
|
|
315
474
|
actualPendingTaskIds.push(taskId);
|
|
316
475
|
}
|
|
476
|
+
if (reconciled.action === "pending") {
|
|
477
|
+
// Never-started tasks from future waves need execution
|
|
478
|
+
actualPendingTaskIds.push(taskId);
|
|
479
|
+
}
|
|
317
480
|
}
|
|
318
481
|
}
|
|
319
482
|
|
|
@@ -444,6 +607,7 @@ export async function resumeOrchBatch(
|
|
|
444
607
|
batchState.phase = "executing";
|
|
445
608
|
batchState.batchId = persistedState.batchId;
|
|
446
609
|
batchState.baseBranch = persistedState.baseBranch || "";
|
|
610
|
+
batchState.mode = persistedState.mode;
|
|
447
611
|
batchState.startedAt = persistedState.startedAt;
|
|
448
612
|
batchState.pauseSignal = { paused: false };
|
|
449
613
|
batchState.totalWaves = persistedState.totalWaves;
|
|
@@ -453,6 +617,33 @@ export async function resumeOrchBatch(
|
|
|
453
617
|
batchState.skippedTasks = persistedState.skippedTasks;
|
|
454
618
|
batchState.blockedTasks = persistedState.blockedTasks;
|
|
455
619
|
batchState.blockedTaskIds = new Set(persistedState.blockedTaskIds);
|
|
620
|
+
// Track persisted blocked IDs separately to avoid double-counting in wave loop.
|
|
621
|
+
// Engine.ts counts blocked tasks per-wave when a wave is entered. If the prior
|
|
622
|
+
// run paused before reaching a wave, tasks blocked for that wave are in
|
|
623
|
+
// `blockedTaskIds` but NOT yet counted in `blockedTasks`. On resume, the
|
|
624
|
+
// per-wave counting loop excludes `persistedBlockedTaskIds`, so those tasks
|
|
625
|
+
// would never be counted. Fix: count persisted blocked tasks in future waves
|
|
626
|
+
// (waves >= resumeWaveIndex) that were not yet counted.
|
|
627
|
+
const persistedBlockedTaskIds = new Set(persistedState.blockedTaskIds);
|
|
628
|
+
|
|
629
|
+
// Count persisted-blocked tasks in unvisited waves (wave >= resumeWaveIndex).
|
|
630
|
+
// These were added to blockedTaskIds in the prior run but their wave was never
|
|
631
|
+
// entered, so they were never counted in blockedTasks.
|
|
632
|
+
if (persistedBlockedTaskIds.size > 0) {
|
|
633
|
+
let uncountedBlocked = 0;
|
|
634
|
+
for (let wi = resumePoint.resumeWaveIndex; wi < persistedState.wavePlan.length; wi++) {
|
|
635
|
+
for (const taskId of persistedState.wavePlan[wi]) {
|
|
636
|
+
if (persistedBlockedTaskIds.has(taskId)) {
|
|
637
|
+
uncountedBlocked++;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
if (uncountedBlocked > 0) {
|
|
642
|
+
batchState.blockedTasks += uncountedBlocked;
|
|
643
|
+
execLog("resume", persistedState.batchId, `blocked counter fix: ${uncountedBlocked} persisted-blocked task(s) in unvisited waves added to blockedTasks`);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
456
647
|
batchState.errors = [...persistedState.errors];
|
|
457
648
|
batchState.endedAt = null;
|
|
458
649
|
batchState.currentWaveIndex = resumePoint.resumeWaveIndex;
|
|
@@ -507,10 +698,15 @@ export async function resumeOrchBatch(
|
|
|
507
698
|
strategy: "round-robin",
|
|
508
699
|
estimatedLoad: 0,
|
|
509
700
|
estimatedMinutes: 0,
|
|
701
|
+
...(laneRecord.repoId !== undefined ? { repoId: laneRecord.repoId } : {}),
|
|
510
702
|
};
|
|
511
703
|
|
|
704
|
+
// Resolve per-lane repo root for workspace mode (v1/repo mode: falls back to repoRoot)
|
|
705
|
+
const laneRepoRoot = resolveRepoRoot(laneRecord.repoId, repoRoot, workspaceConfig);
|
|
706
|
+
|
|
512
707
|
execLog("resume", task.taskId, "reconnecting to alive session", {
|
|
513
708
|
session: laneRecord.tmuxSessionName,
|
|
709
|
+
repoId: laneRecord.repoId ?? "(default)",
|
|
514
710
|
});
|
|
515
711
|
|
|
516
712
|
// Poll until task completes
|
|
@@ -519,7 +715,7 @@ export async function resumeOrchBatch(
|
|
|
519
715
|
lane,
|
|
520
716
|
allocatedTask,
|
|
521
717
|
orchConfig,
|
|
522
|
-
|
|
718
|
+
laneRepoRoot,
|
|
523
719
|
batchState.pauseSignal,
|
|
524
720
|
);
|
|
525
721
|
|
|
@@ -586,20 +782,25 @@ export async function resumeOrchBatch(
|
|
|
586
782
|
strategy: "round-robin",
|
|
587
783
|
estimatedLoad: 0,
|
|
588
784
|
estimatedMinutes: 0,
|
|
785
|
+
...(laneRecord.repoId !== undefined ? { repoId: laneRecord.repoId } : {}),
|
|
589
786
|
};
|
|
590
787
|
|
|
788
|
+
// Resolve per-lane repo root for workspace mode (v1/repo mode: falls back to repoRoot)
|
|
789
|
+
const reExecRepoRoot = resolveRepoRoot(laneRecord.repoId, repoRoot, workspaceConfig);
|
|
790
|
+
|
|
591
791
|
execLog("resume", task.taskId, "re-executing interrupted task in existing worktree", {
|
|
592
792
|
session: laneRecord.tmuxSessionName,
|
|
593
793
|
worktree: laneRecord.worktreePath,
|
|
794
|
+
repoId: laneRecord.repoId ?? "(default)",
|
|
594
795
|
});
|
|
595
796
|
|
|
596
797
|
try {
|
|
597
|
-
spawnLaneSession(lane, allocatedTask, orchConfig,
|
|
798
|
+
spawnLaneSession(lane, allocatedTask, orchConfig, reExecRepoRoot);
|
|
598
799
|
const pollResult = await pollUntilTaskComplete(
|
|
599
800
|
lane,
|
|
600
801
|
allocatedTask,
|
|
601
802
|
orchConfig,
|
|
602
|
-
|
|
803
|
+
reExecRepoRoot,
|
|
603
804
|
batchState.pauseSignal,
|
|
604
805
|
);
|
|
605
806
|
|
|
@@ -645,7 +846,7 @@ export async function resumeOrchBatch(
|
|
|
645
846
|
"info",
|
|
646
847
|
);
|
|
647
848
|
|
|
648
|
-
// Build synthetic WaveExecutionResult for
|
|
849
|
+
// Build synthetic WaveExecutionResult for mergeWaveByRepo()
|
|
649
850
|
const syntheticLaneResults: LaneExecutionResult[] = reExecAllocatedLanes.map(lane => ({
|
|
650
851
|
laneNumber: lane.laneNumber,
|
|
651
852
|
laneId: lane.laneId,
|
|
@@ -663,8 +864,16 @@ export async function resumeOrchBatch(
|
|
|
663
864
|
endTime: Date.now(),
|
|
664
865
|
}));
|
|
665
866
|
|
|
867
|
+
// Use waveIndex -1 as a sentinel for "pre-wave-loop re-exec merge".
|
|
868
|
+
// mergeWaveByRepo expects 1-indexed waveIndex; persistence normalizes
|
|
869
|
+
// to 0-based via `mr.waveIndex - 1`. By passing -1 here:
|
|
870
|
+
// - mergeWaveByRepo logs it as "W-1" (harmless)
|
|
871
|
+
// - persistence normalizes to `Math.max(0, -1 - 1)` = 0 (valid)
|
|
872
|
+
// - semantically distinguishes re-exec merges from wave 1 merges
|
|
873
|
+
const RE_EXEC_WAVE_INDEX = -1;
|
|
874
|
+
|
|
666
875
|
const syntheticWaveResult: WaveExecutionResult = {
|
|
667
|
-
waveIndex:
|
|
876
|
+
waveIndex: RE_EXEC_WAVE_INDEX,
|
|
668
877
|
startedAt: Date.now(),
|
|
669
878
|
endedAt: Date.now(),
|
|
670
879
|
laneResults: syntheticLaneResults,
|
|
@@ -680,14 +889,15 @@ export async function resumeOrchBatch(
|
|
|
680
889
|
allocatedLanes: reExecAllocatedLanes,
|
|
681
890
|
};
|
|
682
891
|
|
|
683
|
-
const reExecMergeResult =
|
|
892
|
+
const reExecMergeResult = mergeWaveByRepo(
|
|
684
893
|
reExecAllocatedLanes,
|
|
685
894
|
syntheticWaveResult,
|
|
686
|
-
|
|
895
|
+
RE_EXEC_WAVE_INDEX,
|
|
687
896
|
orchConfig,
|
|
688
897
|
repoRoot,
|
|
689
898
|
batchState.batchId,
|
|
690
899
|
batchState.baseBranch,
|
|
900
|
+
workspaceConfig,
|
|
691
901
|
);
|
|
692
902
|
|
|
693
903
|
if (reExecMergeResult.status === "succeeded") {
|
|
@@ -696,11 +906,11 @@ export async function resumeOrchBatch(
|
|
|
696
906
|
"info",
|
|
697
907
|
);
|
|
698
908
|
|
|
699
|
-
// Clean up merged branches
|
|
700
|
-
const targetBranch = batchState.baseBranch;
|
|
909
|
+
// Clean up merged branches (resolve per-lane repo root for workspace mode)
|
|
701
910
|
for (const lr of reExecMergeResult.laneResults) {
|
|
702
911
|
if (lr.result?.status === "SUCCESS" || lr.result?.status === "CONFLICT_RESOLVED") {
|
|
703
|
-
|
|
912
|
+
const laneRepoRoot = resolveRepoRoot(lr.repoId, repoRoot, workspaceConfig);
|
|
913
|
+
deleteBranchBestEffort(lr.sourceBranch, laneRepoRoot);
|
|
704
914
|
}
|
|
705
915
|
}
|
|
706
916
|
} else {
|
|
@@ -718,7 +928,21 @@ export async function resumeOrchBatch(
|
|
|
718
928
|
// Track state for persistence
|
|
719
929
|
const wavePlan = persistedState.wavePlan;
|
|
720
930
|
const allTaskOutcomes: LaneTaskOutcome[] = [];
|
|
721
|
-
|
|
931
|
+
|
|
932
|
+
// Initialize latestAllocatedLanes from persisted lane records so that
|
|
933
|
+
// early persistence calls (before the first resumed wave) retain lane
|
|
934
|
+
// records with repo attribution (laneNumber, laneId, branch, repoId).
|
|
935
|
+
// Without this, the `resume-reconciliation` checkpoint would serialize
|
|
936
|
+
// empty lanes[], losing all lane context until a new wave allocates.
|
|
937
|
+
let latestAllocatedLanes: AllocatedLane[] = reconstructAllocatedLanes(persistedState.lanes, persistedState.tasks);
|
|
938
|
+
|
|
939
|
+
// Track all repo roots encountered during execution (persisted + newly allocated).
|
|
940
|
+
// Used by inter-wave reset and terminal cleanup to cover repos introduced
|
|
941
|
+
// after resume starts (not present in persisted lanes).
|
|
942
|
+
// Initialized from collectRepoRoots() helper for parity with other callers.
|
|
943
|
+
const encounteredRepoRoots = new Set(
|
|
944
|
+
collectRepoRoots(persistedState, repoRoot, workspaceConfig),
|
|
945
|
+
);
|
|
722
946
|
|
|
723
947
|
// Build outcomes from reconciled tasks
|
|
724
948
|
for (const task of reconciledTasks) {
|
|
@@ -748,6 +972,23 @@ export async function resumeOrchBatch(
|
|
|
748
972
|
});
|
|
749
973
|
}
|
|
750
974
|
|
|
975
|
+
// ── 9b. Seed blocked dependents from reconciled failures ─────
|
|
976
|
+
// Under skip-dependents policy, failures discovered during reconciliation
|
|
977
|
+
// (mark-failed) or resolved during reconnect/re-execute must propagate
|
|
978
|
+
// to their transitive dependents BEFORE the wave loop begins.
|
|
979
|
+
if (orchConfig.failure.on_task_failure === "skip-dependents" && failedTaskSet.size > 0) {
|
|
980
|
+
const reconciledBlocked = computeTransitiveDependents(failedTaskSet, depGraph);
|
|
981
|
+
for (const taskId of reconciledBlocked) {
|
|
982
|
+
batchState.blockedTaskIds.add(taskId);
|
|
983
|
+
}
|
|
984
|
+
if (reconciledBlocked.size > 0) {
|
|
985
|
+
execLog("resume", batchState.batchId, `skip-dependents: ${reconciledBlocked.size} task(s) blocked from reconciled failures`, {
|
|
986
|
+
blocked: [...reconciledBlocked].sort().join(","),
|
|
987
|
+
sources: [...failedTaskSet].sort().join(","),
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
751
992
|
persistRuntimeState("resume-reconciliation", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery ?? null, repoRoot);
|
|
752
993
|
|
|
753
994
|
// ── 10. Continue wave execution ──────────────────────────────
|
|
@@ -778,8 +1019,12 @@ export async function resumeOrchBatch(
|
|
|
778
1019
|
// Also filter tasks where discovery doesn't have them as pending
|
|
779
1020
|
waveTasks = waveTasks.filter(taskId => discovery.pending.has(taskId));
|
|
780
1021
|
|
|
1022
|
+
// Count only newly blocked tasks (not already persisted) to avoid double-counting.
|
|
1023
|
+
// persistedState.blockedTaskIds were already counted in persistedState.blockedTasks
|
|
1024
|
+
// which initialized batchState.blockedTasks.
|
|
781
1025
|
const blockedInWave = persistedState.wavePlan[waveIdx].filter(
|
|
782
|
-
taskId => batchState.blockedTaskIds.has(taskId)
|
|
1026
|
+
taskId => batchState.blockedTaskIds.has(taskId) &&
|
|
1027
|
+
!persistedBlockedTaskIds.has(taskId),
|
|
783
1028
|
);
|
|
784
1029
|
if (blockedInWave.length > 0) {
|
|
785
1030
|
batchState.blockedTasks += blockedInWave.length;
|
|
@@ -818,10 +1063,15 @@ export async function resumeOrchBatch(
|
|
|
818
1063
|
(lanes) => {
|
|
819
1064
|
latestAllocatedLanes = lanes;
|
|
820
1065
|
batchState.currentLanes = lanes;
|
|
1066
|
+
// Track repos from newly allocated lanes for cleanup coverage
|
|
1067
|
+
for (const lane of lanes) {
|
|
1068
|
+
encounteredRepoRoots.add(resolveRepoRoot(lane.repoId, repoRoot, workspaceConfig));
|
|
1069
|
+
}
|
|
821
1070
|
if (seedPendingOutcomesForAllocatedLanes(lanes, allTaskOutcomes)) {
|
|
822
1071
|
persistRuntimeState("wave-lanes-allocated", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, repoRoot);
|
|
823
1072
|
}
|
|
824
1073
|
},
|
|
1074
|
+
workspaceConfig,
|
|
825
1075
|
);
|
|
826
1076
|
|
|
827
1077
|
batchState.waveResults.push(waveResult);
|
|
@@ -916,7 +1166,7 @@ export async function resumeOrchBatch(
|
|
|
916
1166
|
persistRuntimeState("merge-start", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, repoRoot);
|
|
917
1167
|
onNotify(ORCH_MESSAGES.orchMergeStart(waveIdx + 1, mergeableLaneCount), "info");
|
|
918
1168
|
|
|
919
|
-
mergeResult =
|
|
1169
|
+
mergeResult = mergeWaveByRepo(
|
|
920
1170
|
waveResult.allocatedLanes,
|
|
921
1171
|
waveResult,
|
|
922
1172
|
waveIdx + 1,
|
|
@@ -924,6 +1174,7 @@ export async function resumeOrchBatch(
|
|
|
924
1174
|
repoRoot,
|
|
925
1175
|
batchState.batchId,
|
|
926
1176
|
batchState.baseBranch,
|
|
1177
|
+
workspaceConfig,
|
|
927
1178
|
);
|
|
928
1179
|
batchState.mergeResults.push(mergeResult);
|
|
929
1180
|
|
|
@@ -961,6 +1212,14 @@ export async function resumeOrchBatch(
|
|
|
961
1212
|
ORCH_MESSAGES.orchMergeFailed(waveIdx + 1, mergeResult.failedLane ?? 0, mergeResult.failureReason || "unknown"),
|
|
962
1213
|
"error",
|
|
963
1214
|
);
|
|
1215
|
+
|
|
1216
|
+
// Emit repo-divergence summary when partial is caused by cross-repo outcome differences
|
|
1217
|
+
if (mergeResult.status === "partial") {
|
|
1218
|
+
const repoSummary = formatRepoMergeSummary(mergeResult);
|
|
1219
|
+
if (repoSummary) {
|
|
1220
|
+
onNotify(repoSummary, "warning");
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
964
1223
|
}
|
|
965
1224
|
|
|
966
1225
|
batchState.phase = "executing";
|
|
@@ -988,48 +1247,28 @@ export async function resumeOrchBatch(
|
|
|
988
1247
|
onNotify(ORCH_MESSAGES.orchMergeSkipped(waveIdx + 1), "info");
|
|
989
1248
|
}
|
|
990
1249
|
|
|
991
|
-
// Handle merge failure
|
|
1250
|
+
// Handle merge failure — shared helper guarantees parity with engine.ts (TP-005 Step 2)
|
|
992
1251
|
if (mergeResult && (mergeResult.status === "failed" || mergeResult.status === "partial")) {
|
|
993
|
-
const
|
|
1252
|
+
const policyResult = computeMergeFailurePolicy(mergeResult, waveIdx, orchConfig);
|
|
994
1253
|
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
`⏸️ Batch paused due to merge failure at wave ${waveIdx + 1}. ` +
|
|
1004
|
-
`Resolve conflicts and resume.`,
|
|
1005
|
-
"error",
|
|
1006
|
-
);
|
|
1007
|
-
preserveWorktreesForResume = true;
|
|
1008
|
-
break;
|
|
1009
|
-
} else {
|
|
1010
|
-
batchState.phase = "stopped";
|
|
1011
|
-
batchState.errors.push(
|
|
1012
|
-
`Merge failed at wave ${waveIdx + 1}: ${mergeResult.failureReason || "unknown"}. ` +
|
|
1013
|
-
`Batch aborted by on_merge_failure policy.`,
|
|
1014
|
-
);
|
|
1015
|
-
persistRuntimeState("merge-failure-abort", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, repoRoot);
|
|
1016
|
-
onNotify(
|
|
1017
|
-
`⛔ Batch aborted due to merge failure at wave ${waveIdx + 1}.`,
|
|
1018
|
-
"error",
|
|
1019
|
-
);
|
|
1020
|
-
preserveWorktreesForResume = true;
|
|
1021
|
-
break;
|
|
1022
|
-
}
|
|
1254
|
+
execLog("batch", batchState.batchId, `merge failure — applying ${policyResult.policy} policy`, policyResult.logDetails);
|
|
1255
|
+
|
|
1256
|
+
batchState.phase = policyResult.targetPhase;
|
|
1257
|
+
batchState.errors.push(policyResult.errorMessage);
|
|
1258
|
+
persistRuntimeState(policyResult.persistTrigger, batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, repoRoot);
|
|
1259
|
+
onNotify(policyResult.notifyMessage, policyResult.notifyLevel);
|
|
1260
|
+
preserveWorktreesForResume = true;
|
|
1261
|
+
break;
|
|
1023
1262
|
}
|
|
1024
1263
|
|
|
1025
1264
|
// Post-merge: reset worktrees for next wave
|
|
1026
1265
|
if (mergeResult && mergeResult.status === "succeeded") {
|
|
1027
|
-
const targetBranch = batchState.baseBranch;
|
|
1028
1266
|
for (const lr of mergeResult.laneResults) {
|
|
1029
1267
|
if (lr.result?.status === "SUCCESS" || lr.result?.status === "CONFLICT_RESOLVED") {
|
|
1030
|
-
const
|
|
1268
|
+
const laneRepoRoot = resolveRepoRoot(lr.repoId, repoRoot, workspaceConfig);
|
|
1269
|
+
const ancestorCheck = runGit(["merge-base", "--is-ancestor", lr.sourceBranch, lr.targetBranch], laneRepoRoot);
|
|
1031
1270
|
if (ancestorCheck.ok) {
|
|
1032
|
-
deleteBranchBestEffort(lr.sourceBranch,
|
|
1271
|
+
deleteBranchBestEffort(lr.sourceBranch, laneRepoRoot);
|
|
1033
1272
|
}
|
|
1034
1273
|
}
|
|
1035
1274
|
}
|
|
@@ -1037,16 +1276,23 @@ export async function resumeOrchBatch(
|
|
|
1037
1276
|
|
|
1038
1277
|
if (waveIdx < persistedState.wavePlan.length - 1 && !batchState.pauseSignal.paused) {
|
|
1039
1278
|
const wtPrefix = orchConfig.orchestrator.worktree_prefix;
|
|
1040
|
-
const
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1279
|
+
const resetOpId = resolveOperatorId(orchConfig);
|
|
1280
|
+
|
|
1281
|
+
// Use encounteredRepoRoots which includes both persisted lanes
|
|
1282
|
+
// AND newly allocated lanes from resumed waves, ensuring repos
|
|
1283
|
+
// introduced after resume starts are covered.
|
|
1284
|
+
for (const perRepoRoot of encounteredRepoRoots) {
|
|
1285
|
+
const existingWorktrees = listWorktrees(wtPrefix, perRepoRoot, resetOpId);
|
|
1286
|
+
if (existingWorktrees.length > 0) {
|
|
1287
|
+
const targetBranch = batchState.baseBranch;
|
|
1288
|
+
for (const wt of existingWorktrees) {
|
|
1289
|
+
const resetResult = safeResetWorktree(wt, targetBranch, perRepoRoot);
|
|
1290
|
+
if (!resetResult.success) {
|
|
1291
|
+
try {
|
|
1292
|
+
removeWorktree(wt, perRepoRoot);
|
|
1293
|
+
} catch {
|
|
1294
|
+
forceCleanupWorktree(wt, perRepoRoot, batchState.batchId);
|
|
1295
|
+
}
|
|
1050
1296
|
}
|
|
1051
1297
|
}
|
|
1052
1298
|
}
|
|
@@ -1057,8 +1303,15 @@ export async function resumeOrchBatch(
|
|
|
1057
1303
|
// ── 11. Cleanup and terminal state ───────────────────────────
|
|
1058
1304
|
if (!preserveWorktreesForResume) {
|
|
1059
1305
|
const wtPrefix = orchConfig.orchestrator.worktree_prefix;
|
|
1306
|
+
const cleanupOpId = resolveOperatorId(orchConfig);
|
|
1060
1307
|
const targetBranch = batchState.baseBranch;
|
|
1061
|
-
|
|
1308
|
+
|
|
1309
|
+
// Use encounteredRepoRoots which includes both persisted lanes
|
|
1310
|
+
// AND newly allocated lanes from resumed waves, ensuring repos
|
|
1311
|
+
// introduced after resume starts are cleaned up.
|
|
1312
|
+
for (const perRepoRoot of encounteredRepoRoots) {
|
|
1313
|
+
removeAllWorktrees(wtPrefix, perRepoRoot, cleanupOpId, targetBranch);
|
|
1314
|
+
}
|
|
1062
1315
|
}
|
|
1063
1316
|
|
|
1064
1317
|
batchState.endedAt = Date.now();
|