taskplane 0.30.4 → 0.30.6
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/extensions/taskplane/agent-bridge-extension.ts +66 -8
- package/extensions/taskplane/agent-host.ts +170 -9
- package/extensions/taskplane/config-loader.ts +9 -0
- package/extensions/taskplane/config-schema.ts +47 -1
- package/extensions/taskplane/context-repair.ts +158 -0
- package/extensions/taskplane/diagnostic-reports.ts +109 -6
- package/extensions/taskplane/diagnostics.ts +3 -0
- package/extensions/taskplane/engine-identity.ts +401 -0
- package/extensions/taskplane/engine-worker.ts +59 -3
- package/extensions/taskplane/engine.ts +137 -17
- package/extensions/taskplane/execution.ts +89 -8
- package/extensions/taskplane/extension.ts +1288 -241
- package/extensions/taskplane/git.ts +74 -0
- package/extensions/taskplane/lane-runner.ts +971 -95
- package/extensions/taskplane/process-registry.ts +7 -2
- package/extensions/taskplane/resume.ts +559 -23
- package/extensions/taskplane/review-analysis.ts +450 -0
- package/extensions/taskplane/segment-recovery.ts +192 -0
- package/extensions/taskplane/supervisor-dispatch.ts +103 -0
- package/extensions/taskplane/supervisor-primer.md +182 -2
- package/extensions/taskplane/supervisor.ts +247 -24
- package/extensions/taskplane/types.ts +142 -4
- package/extensions/taskplane/worktree.ts +143 -2
- package/package.json +1 -1
|
@@ -12,10 +12,16 @@ import {
|
|
|
12
12
|
resolveDisplayWaveNumber,
|
|
13
13
|
buildSpawnFailureAlertExtras,
|
|
14
14
|
} from "./engine.ts";
|
|
15
|
+
import {
|
|
16
|
+
advanceActiveSegment,
|
|
17
|
+
applyReExecutionOutcomeToSegments,
|
|
18
|
+
taskSegmentsAllSucceeded,
|
|
19
|
+
} from "./segment-recovery.ts";
|
|
15
20
|
import {
|
|
16
21
|
buildReviewerEnv,
|
|
17
22
|
buildWorkerEnv,
|
|
18
23
|
buildWorkerExcludeEnv,
|
|
24
|
+
batchTaskScope,
|
|
19
25
|
computeTransitiveDependents,
|
|
20
26
|
execLog,
|
|
21
27
|
executeLaneV2,
|
|
@@ -31,23 +37,99 @@ import { readRegistrySnapshot, isTerminalStatus, isProcessAlive } from "./proces
|
|
|
31
37
|
* Per Runtime V2 spec §7.3: detect + terminate + rehydrate.
|
|
32
38
|
* Prevents duplicate concurrent agents for the same lane/task on resume.
|
|
33
39
|
*/
|
|
34
|
-
|
|
40
|
+
/** #631: how long to wait for SIGTERM, then SIGKILL, before declaring termination unconfirmed. */
|
|
41
|
+
const TERMINATE_GRACE_MS = 5_000;
|
|
42
|
+
const TERMINATE_KILL_MS = 3_000;
|
|
43
|
+
const TERMINATE_POLL_MS = 200;
|
|
44
|
+
|
|
45
|
+
function sleep(ms: number): Promise<void> {
|
|
46
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function waitForExit(pids: number[], timeoutMs: number): Promise<number[]> {
|
|
50
|
+
const deadline = Date.now() + timeoutMs;
|
|
51
|
+
let survivors = pids.filter((pid) => isProcessAlive(pid));
|
|
52
|
+
while (survivors.length > 0 && Date.now() < deadline) {
|
|
53
|
+
await sleep(TERMINATE_POLL_MS);
|
|
54
|
+
survivors = survivors.filter((pid) => isProcessAlive(pid));
|
|
55
|
+
}
|
|
56
|
+
return survivors;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Terminate the lane's still-alive V2 agents (worker/reviewer) before this
|
|
61
|
+
* resume re-executes the lane in the SAME worktree.
|
|
62
|
+
*
|
|
63
|
+
* #631: termination is VERIFIED, not fire-and-forget. SIGTERM → bounded wait →
|
|
64
|
+
* SIGKILL → bounded wait. If any agent is still alive after that, THROW: a
|
|
65
|
+
* signal-resistant or permission-protected worker would otherwise keep writing
|
|
66
|
+
* the worktree alongside its replacement. The callers' existing catch blocks
|
|
67
|
+
* mark the task failed with this reason instead of re-executing.
|
|
68
|
+
*/
|
|
69
|
+
async function terminateAliveV2Agents(
|
|
70
|
+
stateRoot: string,
|
|
71
|
+
batchId: string,
|
|
72
|
+
sessionName: string,
|
|
73
|
+
): Promise<void> {
|
|
35
74
|
const registry = readRegistrySnapshot(stateRoot, batchId);
|
|
36
75
|
if (!registry) return;
|
|
76
|
+
const targets: Array<{ key: string; pid: number }> = [];
|
|
37
77
|
for (const suffix of ["-worker", "-reviewer", ""]) {
|
|
38
78
|
const key = `${sessionName}${suffix}`;
|
|
39
79
|
const manifest = registry.agents[key];
|
|
40
80
|
if (manifest && !isTerminalStatus(manifest.status) && isProcessAlive(manifest.pid)) {
|
|
81
|
+
targets.push({ key, pid: manifest.pid });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (targets.length === 0) return;
|
|
85
|
+
|
|
86
|
+
for (const t of targets) {
|
|
87
|
+
try {
|
|
88
|
+
process.kill(t.pid, "SIGTERM");
|
|
89
|
+
execLog("resume", t.key, `SIGTERM sent to alive V2 agent (PID ${t.pid}) before re-execute`);
|
|
90
|
+
} catch {
|
|
91
|
+
/* already dead or not signalable — verified below */
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
let survivors = await waitForExit(
|
|
95
|
+
targets.map((t) => t.pid),
|
|
96
|
+
TERMINATE_GRACE_MS,
|
|
97
|
+
);
|
|
98
|
+
if (survivors.length > 0) {
|
|
99
|
+
for (const pid of survivors) {
|
|
41
100
|
try {
|
|
42
|
-
process.kill(
|
|
43
|
-
execLog(
|
|
101
|
+
process.kill(pid, "SIGKILL");
|
|
102
|
+
execLog(
|
|
103
|
+
"resume",
|
|
104
|
+
sessionName,
|
|
105
|
+
`SIGKILL sent to V2 agent PID ${pid} (did not exit within ${TERMINATE_GRACE_MS}ms)`,
|
|
106
|
+
);
|
|
44
107
|
} catch {
|
|
45
|
-
/*
|
|
108
|
+
/* verified below */
|
|
46
109
|
}
|
|
47
110
|
}
|
|
111
|
+
survivors = await waitForExit(survivors, TERMINATE_KILL_MS);
|
|
48
112
|
}
|
|
113
|
+
if (survivors.length > 0) {
|
|
114
|
+
const list = targets
|
|
115
|
+
.filter((t) => survivors.includes(t.pid))
|
|
116
|
+
.map((t) => `${t.key} (PID ${t.pid})`)
|
|
117
|
+
.join(", ");
|
|
118
|
+
throw new Error(
|
|
119
|
+
`cannot confirm termination of ${list} after SIGTERM+SIGKILL — refusing to re-execute the lane alongside a live agent (#631). ` +
|
|
120
|
+
`Terminate the process manually and resume again.`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
execLog(
|
|
124
|
+
"resume",
|
|
125
|
+
sessionName,
|
|
126
|
+
`verified termination of ${targets.length} V2 agent(s) before re-execute`,
|
|
127
|
+
{
|
|
128
|
+
pids: targets.map((t) => t.pid).join(","),
|
|
129
|
+
},
|
|
130
|
+
);
|
|
49
131
|
}
|
|
50
|
-
import { getCurrentBranch, runGit } from "./git.ts";
|
|
132
|
+
import { getCurrentBranch, runGit, describeOrchBranchStateAcrossRepos } from "./git.ts";
|
|
51
133
|
import { mergeWaveByRepo } from "./merge.ts";
|
|
52
134
|
import {
|
|
53
135
|
applyMergeRetryLoop,
|
|
@@ -128,6 +210,41 @@ import {
|
|
|
128
210
|
* @param workspaceConfig - Workspace configuration (null in repo mode)
|
|
129
211
|
* @returns Array of unique absolute repo root paths
|
|
130
212
|
*/
|
|
213
|
+
/**
|
|
214
|
+
* Catch-up merge eligibility (resume step 8d), pure: which persisted lanes hold
|
|
215
|
+
* succeeded-but-unmerged work? A lane qualifies when ALL its tasks are
|
|
216
|
+
* `succeeded`, none were re-executed in this resume pass, and the (LAST) wave
|
|
217
|
+
* of every task has no `succeeded` merge record. Branch existence is checked by
|
|
218
|
+
* the caller (needs git).
|
|
219
|
+
*/
|
|
220
|
+
export function selectCatchUpLanes(
|
|
221
|
+
persistedState: Pick<PersistedBatchState, "lanes" | "tasks" | "mergeResults">,
|
|
222
|
+
wavePlan: string[][],
|
|
223
|
+
reExecutedTaskIds: ReadonlySet<string>,
|
|
224
|
+
): PersistedBatchState["lanes"] {
|
|
225
|
+
// LATEST merge status per wave (the same rule computeResumePoint uses) — an
|
|
226
|
+
// older succeeded record must not mask a later failure (success → failure
|
|
227
|
+
// while a third task was still pending left succeeded work permanently
|
|
228
|
+
// unmerged).
|
|
229
|
+
const waveMerged = (w: number) =>
|
|
230
|
+
getMergeStatusForWave(persistedState.mergeResults ?? [], w) === "succeeded";
|
|
231
|
+
const waveOfTask = new Map<string, number>();
|
|
232
|
+
wavePlan.forEach((wave, i) => {
|
|
233
|
+
for (const id of wave) waveOfTask.set(id, i);
|
|
234
|
+
});
|
|
235
|
+
const succeededById = new Map(
|
|
236
|
+
persistedState.tasks.map((t) => [t.taskId, t.status === "succeeded"]),
|
|
237
|
+
);
|
|
238
|
+
return persistedState.lanes.filter((laneRecord) => {
|
|
239
|
+
if (laneRecord.taskIds.length === 0) return false;
|
|
240
|
+
if (!laneRecord.taskIds.every((id) => succeededById.get(id))) return false;
|
|
241
|
+
if (laneRecord.taskIds.some((id) => reExecutedTaskIds.has(id))) return false;
|
|
242
|
+
const waves = laneRecord.taskIds.map((id) => waveOfTask.get(id));
|
|
243
|
+
if (waves.some((w) => w === undefined || waveMerged(w))) return false;
|
|
244
|
+
return true;
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
131
248
|
export function collectRepoRoots(
|
|
132
249
|
persistedState: PersistedBatchState,
|
|
133
250
|
defaultRepoRoot: string,
|
|
@@ -1267,6 +1384,22 @@ export async function resumeOrchBatch(
|
|
|
1267
1384
|
batchState.phase = "idle";
|
|
1268
1385
|
return;
|
|
1269
1386
|
}
|
|
1387
|
+
// #631: the parent gated engine ownership for a SPECIFIC batch. If the
|
|
1388
|
+
// deterministic reconstruction picked a different one, refuse BEFORE any
|
|
1389
|
+
// write — persisting reconstructed state for an ungated batch is itself an
|
|
1390
|
+
// unauthorized recovery mutation.
|
|
1391
|
+
if (batchState.batchId && reconstruction.batchId !== batchState.batchId) {
|
|
1392
|
+
const msg =
|
|
1393
|
+
`resume target mismatch: this engine was authorized for batch ${batchState.batchId} but reconstruction ` +
|
|
1394
|
+
`selected ${reconstruction.batchId}. Refusing without writing (ownership of ${reconstruction.batchId} was never verified).`;
|
|
1395
|
+
execLog("resume", batchState.batchId, msg);
|
|
1396
|
+
onNotify(`❌ ${msg}`, "error");
|
|
1397
|
+
batchState.phase = "failed";
|
|
1398
|
+
batchState.endedAt = Date.now();
|
|
1399
|
+
batchState.errors.push(msg);
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1270
1403
|
// Successful reconstruction: persist so the rest of resumeOrchBatch
|
|
1271
1404
|
// proceeds with a normal on-disk batch-state.json picture.
|
|
1272
1405
|
onNotify(
|
|
@@ -1291,6 +1424,22 @@ export async function resumeOrchBatch(
|
|
|
1291
1424
|
}
|
|
1292
1425
|
|
|
1293
1426
|
// ── 2. Check eligibility ─────────────────────────────────────
|
|
1427
|
+
// #631: the parent gated engine ownership against a specific target and
|
|
1428
|
+
// published this engine's identity for it. Never resume a DIFFERENT batch
|
|
1429
|
+
// than the one authorized (e.g. reconstruction selecting another runtime
|
|
1430
|
+
// dir) — that batch's engine was never checked.
|
|
1431
|
+
if (batchState.batchId && persistedState.batchId !== batchState.batchId) {
|
|
1432
|
+
const msg =
|
|
1433
|
+
`resume target mismatch: this engine was authorized for batch ${batchState.batchId} but the ` +
|
|
1434
|
+
`persisted/reconstructed state is ${persistedState.batchId}. Refusing (ownership of ${persistedState.batchId} was never verified).`;
|
|
1435
|
+
execLog("resume", batchState.batchId, msg);
|
|
1436
|
+
onNotify(`❌ ${msg}`, "error");
|
|
1437
|
+
batchState.phase = "failed";
|
|
1438
|
+
batchState.endedAt = Date.now();
|
|
1439
|
+
batchState.errors.push(msg);
|
|
1440
|
+
return;
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1294
1443
|
const eligibility = checkResumeEligibility(persistedState, force);
|
|
1295
1444
|
if (!eligibility.eligible) {
|
|
1296
1445
|
onNotify(
|
|
@@ -1557,6 +1706,23 @@ export async function resumeOrchBatch(
|
|
|
1557
1706
|
// v3: Carry forward resilience and diagnostics from persisted state
|
|
1558
1707
|
batchState.resilience = persistedState.resilience;
|
|
1559
1708
|
batchState.diagnostics = persistedState.diagnostics;
|
|
1709
|
+
// Carry forward merge HISTORY. The runtime starts fresh on every resume, so
|
|
1710
|
+
// without this the next checkpoint serialized only the merges appended by
|
|
1711
|
+
// this pass and earlier waves' records were dropped — a later resume then
|
|
1712
|
+
// re-flagged already-merged waves for retry (and catch-up re-selected their
|
|
1713
|
+
// lanes). Persisted records are 0-based; runtime is 1-based (converted once
|
|
1714
|
+
// here, back once in serializeBatchState). Order preserved (latest-wins reads).
|
|
1715
|
+
batchState.mergeResults = (persistedState.mergeResults ?? []).map(
|
|
1716
|
+
(mr) =>
|
|
1717
|
+
({
|
|
1718
|
+
waveIndex: mr.waveIndex + 1,
|
|
1719
|
+
status: mr.status,
|
|
1720
|
+
laneResults: [],
|
|
1721
|
+
failedLane: mr.failedLane,
|
|
1722
|
+
failureReason: mr.failureReason,
|
|
1723
|
+
totalDurationMs: 0,
|
|
1724
|
+
}) as MergeWaveResult,
|
|
1725
|
+
);
|
|
1560
1726
|
// v4: Carry forward segment records (including dynamically expanded segments)
|
|
1561
1727
|
batchState.segments = [...(persistedState.segments ?? [])];
|
|
1562
1728
|
// Carry forward unknown fields for roundtrip preservation
|
|
@@ -1682,8 +1848,8 @@ export async function resumeOrchBatch(
|
|
|
1682
1848
|
execLog("resume", task.taskId, "V2 reconnect: terminate + rehydrate via lane-runner", {
|
|
1683
1849
|
repoId: laneRecord.repoId ?? "(default)",
|
|
1684
1850
|
});
|
|
1685
|
-
terminateAliveV2Agents(stateRoot, persistedState.batchId, laneRecord.laneSessionId);
|
|
1686
1851
|
try {
|
|
1852
|
+
await terminateAliveV2Agents(stateRoot, persistedState.batchId, laneRecord.laneSessionId);
|
|
1687
1853
|
const laneResult = await executeLaneV2(
|
|
1688
1854
|
lane,
|
|
1689
1855
|
orchConfig,
|
|
@@ -1693,6 +1859,7 @@ export async function resumeOrchBatch(
|
|
|
1693
1859
|
!!workspaceConfig,
|
|
1694
1860
|
{
|
|
1695
1861
|
ORCH_BATCH_ID: batchState.batchId,
|
|
1862
|
+
...buildWorkerEnv(runnerConfig.worker),
|
|
1696
1863
|
...buildReviewerEnv(runnerConfig.reviewer),
|
|
1697
1864
|
...buildWorkerExcludeEnv(runnerConfig.workerExcludeExtensions),
|
|
1698
1865
|
},
|
|
@@ -1729,7 +1896,14 @@ export async function resumeOrchBatch(
|
|
|
1729
1896
|
|
|
1730
1897
|
// ── 8b. Handle re-execute tasks (dead session + existing worktree) ──
|
|
1731
1898
|
const reExecuteTasks = reconciledTasks.filter((t) => t.action === "re-execute");
|
|
1899
|
+
// Worktree preservation flag (declared before 8c/8d so a merge failure on
|
|
1900
|
+
// resume can set it; consumed by terminal cleanup).
|
|
1901
|
+
let preserveWorktreesForResume = false;
|
|
1732
1902
|
const reExecuteFinalStatus = new Map<string, LaneTaskStatus>();
|
|
1903
|
+
// #629: the REAL lane outcome of a re-executed task (telemetry, exit
|
|
1904
|
+
// diagnostic, timestamps). Previously discarded — the synthesized outcome
|
|
1905
|
+
// below carried the persisted (already-cleared) diagnostic instead.
|
|
1906
|
+
const reExecuteOutcome = new Map<string, LaneTaskOutcome>();
|
|
1733
1907
|
const reExecAllocatedLanes: AllocatedLane[] = [];
|
|
1734
1908
|
|
|
1735
1909
|
if (reExecuteTasks.length > 0) {
|
|
@@ -1775,7 +1949,7 @@ export async function resumeOrchBatch(
|
|
|
1775
1949
|
|
|
1776
1950
|
try {
|
|
1777
1951
|
// TP-112: Runtime V2 re-execution.
|
|
1778
|
-
terminateAliveV2Agents(stateRoot, batchState.batchId, laneRecord.laneSessionId);
|
|
1952
|
+
await terminateAliveV2Agents(stateRoot, batchState.batchId, laneRecord.laneSessionId);
|
|
1779
1953
|
const laneResult = await executeLaneV2(
|
|
1780
1954
|
lane,
|
|
1781
1955
|
orchConfig,
|
|
@@ -1785,6 +1959,7 @@ export async function resumeOrchBatch(
|
|
|
1785
1959
|
!!workspaceConfig,
|
|
1786
1960
|
{
|
|
1787
1961
|
ORCH_BATCH_ID: batchState.batchId,
|
|
1962
|
+
...buildWorkerEnv(runnerConfig.worker),
|
|
1788
1963
|
...buildReviewerEnv(runnerConfig.reviewer),
|
|
1789
1964
|
...buildWorkerExcludeEnv(runnerConfig.workerExcludeExtensions),
|
|
1790
1965
|
},
|
|
@@ -1796,8 +1971,80 @@ export async function resumeOrchBatch(
|
|
|
1796
1971
|
exitReason: taskResult?.exitReason ?? "V2 re-execution completed",
|
|
1797
1972
|
doneFileFound: taskResult?.doneFileFound ?? false,
|
|
1798
1973
|
};
|
|
1974
|
+
// #629: a PAUSE during re-execution surfaces as `skipped` from
|
|
1975
|
+
// executeLaneV2. That is not a terminal outcome: leave the task and
|
|
1976
|
+
// its segments pending/re-executable (the previous code marked the
|
|
1977
|
+
// task failed; treating the real outcome verbatim would make it an
|
|
1978
|
+
// unretryable `skipped`). Nothing else changes; the next resume
|
|
1979
|
+
// reconciles it again.
|
|
1980
|
+
if (
|
|
1981
|
+
pollResult.status === "pending" ||
|
|
1982
|
+
(pollResult.status === "skipped" && /paused/i.test(pollResult.exitReason))
|
|
1983
|
+
) {
|
|
1984
|
+
reExecuteFinalStatus.set(task.taskId, "pending");
|
|
1985
|
+
execLog(
|
|
1986
|
+
"resume",
|
|
1987
|
+
task.taskId,
|
|
1988
|
+
"re-execution paused — task remains pending for the next resume",
|
|
1989
|
+
);
|
|
1990
|
+
continue;
|
|
1991
|
+
}
|
|
1992
|
+
if (taskResult) reExecuteOutcome.set(task.taskId, taskResult);
|
|
1993
|
+
|
|
1994
|
+
// #629: keep SEGMENT authority — transition the EXECUTED segment record
|
|
1995
|
+
// to the re-execution result. Re-execution runs the unit built from the
|
|
1996
|
+
// task's activeSegmentId, i.e. ONE segment (segmentId null = whole
|
|
1997
|
+
// single-segment/legacy task). Without this a successful retry persisted
|
|
1998
|
+
// task=succeeded / segment=pending and the next resume normalized the
|
|
1999
|
+
// task straight back to pending.
|
|
2000
|
+
const segFinal: "succeeded" | "failed" =
|
|
2001
|
+
pollResult.status === "succeeded" ? "succeeded" : "failed";
|
|
2002
|
+
const executedSegmentId =
|
|
2003
|
+
taskResult?.segmentId ??
|
|
2004
|
+
persistedState.tasks.find((t) => t.taskId === task.taskId)?.activeSegmentId ??
|
|
2005
|
+
null;
|
|
2006
|
+
const touchedSegments = applyReExecutionOutcomeToSegments(
|
|
2007
|
+
batchState.segments,
|
|
2008
|
+
task.taskId,
|
|
2009
|
+
segFinal,
|
|
2010
|
+
{
|
|
2011
|
+
startTime: taskResult?.startTime,
|
|
2012
|
+
endTime: taskResult?.endTime,
|
|
2013
|
+
exitReason: pollResult.exitReason,
|
|
2014
|
+
exitDiagnostic: taskResult?.exitDiagnostic,
|
|
2015
|
+
},
|
|
2016
|
+
executedSegmentId,
|
|
2017
|
+
);
|
|
2018
|
+
if (touchedSegments.length > 0) {
|
|
2019
|
+
execLog("resume", task.taskId, `re-execution: segment records → ${segFinal}`, {
|
|
2020
|
+
segments: touchedSegments.join(","),
|
|
2021
|
+
executedSegmentId: executedSegmentId ?? "(whole task)",
|
|
2022
|
+
});
|
|
2023
|
+
}
|
|
1799
2024
|
|
|
1800
|
-
|
|
2025
|
+
// Task completion is derived from the segment FRONTIER, not from one
|
|
2026
|
+
// segment's success: a non-final segment succeeding leaves the task
|
|
2027
|
+
// pending with downstream segments still to run.
|
|
2028
|
+
const frontierComplete = taskSegmentsAllSucceeded(batchState.segments, task.taskId);
|
|
2029
|
+
if (pollResult.status === "succeeded" && frontierComplete === false) {
|
|
2030
|
+
reExecuteFinalStatus.set(task.taskId, "pending");
|
|
2031
|
+
reExecuteOutcome.delete(task.taskId); // not a task-level outcome yet
|
|
2032
|
+
reExecuteTaskSet.delete(task.taskId);
|
|
2033
|
+
reExecAllocatedLanes.push(lane);
|
|
2034
|
+
// Advance the frontier so the next execution runs the NEXT segment (on
|
|
2035
|
+
// both the persisted record and the parsed task the wave loop will use).
|
|
2036
|
+
const nextSeg = advanceActiveSegment(
|
|
2037
|
+
{ tasks: persistedState.tasks, segments: batchState.segments } as never,
|
|
2038
|
+
task.taskId,
|
|
2039
|
+
);
|
|
2040
|
+
const parsedForFrontier = discovery.pending.get(task.taskId);
|
|
2041
|
+
if (parsedForFrontier) parsedForFrontier.activeSegmentId = nextSeg;
|
|
2042
|
+
execLog(
|
|
2043
|
+
"resume",
|
|
2044
|
+
task.taskId,
|
|
2045
|
+
`re-executed segment ${executedSegmentId} succeeded — task has further segments pending`,
|
|
2046
|
+
);
|
|
2047
|
+
} else if (pollResult.status === "succeeded") {
|
|
1801
2048
|
reExecuteFinalStatus.set(task.taskId, "succeeded");
|
|
1802
2049
|
completedTaskSet.add(task.taskId);
|
|
1803
2050
|
failedTaskSet.delete(task.taskId);
|
|
@@ -1825,6 +2072,9 @@ export async function resumeOrchBatch(
|
|
|
1825
2072
|
batchState.failedTasks++;
|
|
1826
2073
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1827
2074
|
execLog("resume", task.taskId, `re-execution error: ${msg}`);
|
|
2075
|
+
applyReExecutionOutcomeToSegments(batchState.segments, task.taskId, "failed", {
|
|
2076
|
+
exitReason: `re-execution error: ${msg}`,
|
|
2077
|
+
});
|
|
1828
2078
|
}
|
|
1829
2079
|
}
|
|
1830
2080
|
}
|
|
@@ -1919,13 +2169,206 @@ export async function resumeOrchBatch(
|
|
|
1919
2169
|
}
|
|
1920
2170
|
}
|
|
1921
2171
|
} else {
|
|
2172
|
+
// Fail closed (mirrors 8d): a re-executed task that succeeded but whose
|
|
2173
|
+
// branch did not merge must not let the batch proceed to a state that
|
|
2174
|
+
// reads as complete with its work unmerged. Pause (resumable), preserve
|
|
2175
|
+
// worktrees; the next resume re-executes nothing for it (it is succeeded)
|
|
2176
|
+
// but 8d's catch-up merge picks its lane up.
|
|
1922
2177
|
onNotify(
|
|
1923
|
-
`⚠️ Re-executed branch merge ${reExecMergeResult.status}: ${reExecMergeResult.failureReason || "unknown"}
|
|
2178
|
+
`⚠️ Re-executed branch merge ${reExecMergeResult.status}: ${reExecMergeResult.failureReason || "unknown"} — batch paused with worktrees preserved; fix the conflict and orch_resume() to retry the merge.`,
|
|
1924
2179
|
"warning",
|
|
1925
2180
|
);
|
|
2181
|
+
batchState.pauseSignal.paused = true;
|
|
2182
|
+
batchState.pauseSignal.cause = "merge-failure";
|
|
2183
|
+
preserveWorktreesForResume = true;
|
|
2184
|
+
emitAlert({
|
|
2185
|
+
category: "merge-failure",
|
|
2186
|
+
summary:
|
|
2187
|
+
`🔴 Merge of re-executed task(s) ${succeededReExecTaskIds.join(", ")} failed on resume: ${reExecMergeResult.failureReason || "unknown"}
|
|
2188
|
+
` +
|
|
2189
|
+
` The work remains on the lane branch(es); the batch is paused with worktrees preserved.
|
|
2190
|
+
` +
|
|
2191
|
+
` Resolve the conflict, then orch_resume() — the catch-up merge re-runs automatically.`,
|
|
2192
|
+
context: {
|
|
2193
|
+
batchProgress: buildBatchProgressSnapshot(batchState),
|
|
2194
|
+
mergeError: reExecMergeResult.failureReason ?? undefined,
|
|
2195
|
+
},
|
|
2196
|
+
});
|
|
1926
2197
|
}
|
|
1927
2198
|
|
|
1928
|
-
|
|
2199
|
+
// Attribute the outcome to the ACTUAL original wave(s) of the re-executed
|
|
2200
|
+
// tasks (runtime 1-indexed), not the -1 sentinel: persistence clamps the
|
|
2201
|
+
// sentinel to wave 0, which mis-attributed a second-wave merge failure to
|
|
2202
|
+
// wave 0 and let the real wave keep an older success record.
|
|
2203
|
+
// (LAST wave per task — the same mapping selectCatchUpLanes uses for a
|
|
2204
|
+
// multi-segment task's lane.)
|
|
2205
|
+
const lastWaveOf = new Map<string, number>();
|
|
2206
|
+
runtimeWavePlan.forEach((wave, i) => {
|
|
2207
|
+
for (const id of wave) lastWaveOf.set(id, i);
|
|
2208
|
+
});
|
|
2209
|
+
const reExecWaves = new Set<number>();
|
|
2210
|
+
for (const id of succeededReExecTaskIds) {
|
|
2211
|
+
const w = lastWaveOf.get(id);
|
|
2212
|
+
if (w !== undefined) reExecWaves.add(w);
|
|
2213
|
+
}
|
|
2214
|
+
if (reExecWaves.size === 0) {
|
|
2215
|
+
batchState.mergeResults.push(reExecMergeResult);
|
|
2216
|
+
} else {
|
|
2217
|
+
for (const w of reExecWaves) {
|
|
2218
|
+
batchState.mergeResults.push({ ...reExecMergeResult, waveIndex: w + 1 });
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
// ── 8d. Catch-up merge: succeeded-but-unmerged lane work ─────
|
|
2225
|
+
// A pause (or crash) that lands DURING a wave can leave tasks that succeeded
|
|
2226
|
+
// with their lane branches never merged — the wave's merge step did not run.
|
|
2227
|
+
// On resume the wave loop only merges what it re-executes, so that work was
|
|
2228
|
+
// silently dropped (Sage review of the owned-batch pause fix). Merge every
|
|
2229
|
+
// persisted lane whose tasks are all succeeded and whose wave has no
|
|
2230
|
+
// successful merge record. Idempotent: an already-merged branch is a no-op.
|
|
2231
|
+
// Skipped entirely when 8c already raised a merge-failure pause: a successful
|
|
2232
|
+
// subset catch-up must never certify a wave whose other lane work (the failed
|
|
2233
|
+
// 8c merge) remains unmerged — the next resume retries BOTH via this step.
|
|
2234
|
+
if (batchState.pauseSignal.cause !== "merge-failure") {
|
|
2235
|
+
const waveOfTask = new Map<string, number>();
|
|
2236
|
+
runtimeWavePlan.forEach((wave, i) => {
|
|
2237
|
+
for (const id of wave) waveOfTask.set(id, i);
|
|
2238
|
+
});
|
|
2239
|
+
const catchUpLanes: AllocatedLane[] = [];
|
|
2240
|
+
for (const laneRecord of selectCatchUpLanes(
|
|
2241
|
+
persistedState,
|
|
2242
|
+
runtimeWavePlan,
|
|
2243
|
+
new Set(reExecuteFinalStatus.keys()),
|
|
2244
|
+
)) {
|
|
2245
|
+
const laneRepoRoot = resolveRepoRoot(laneRecord.repoId, repoRoot, workspaceConfig);
|
|
2246
|
+
if (
|
|
2247
|
+
!runGit(["rev-parse", "--verify", "--quiet", `refs/heads/${laneRecord.branch}`], laneRepoRoot)
|
|
2248
|
+
.ok
|
|
2249
|
+
) {
|
|
2250
|
+
continue; // branch gone — nothing to merge
|
|
2251
|
+
}
|
|
2252
|
+
catchUpLanes.push({
|
|
2253
|
+
laneNumber: laneRecord.laneNumber,
|
|
2254
|
+
laneId: laneRecord.laneId,
|
|
2255
|
+
laneSessionId: laneRecord.laneSessionId,
|
|
2256
|
+
worktreePath: laneRecord.worktreePath,
|
|
2257
|
+
branch: laneRecord.branch,
|
|
2258
|
+
tasks: laneRecord.taskIds
|
|
2259
|
+
// A task that succeeded may already be in the completed set (no ParsedTask).
|
|
2260
|
+
// Enrich a stub from the persisted record so merge staging can still find
|
|
2261
|
+
// the task folder (.DONE / STATUS / review artifacts).
|
|
2262
|
+
.map((id) => {
|
|
2263
|
+
const parsed = discovery.pending.get(id);
|
|
2264
|
+
if (parsed) return parsed;
|
|
2265
|
+
const rec = persistedState.tasks.find((t) => t.taskId === id);
|
|
2266
|
+
return {
|
|
2267
|
+
taskId: id,
|
|
2268
|
+
taskName: id,
|
|
2269
|
+
taskFolder: rec?.taskFolder ?? "",
|
|
2270
|
+
promptPath: rec?.taskFolder ? join(rec.taskFolder, "PROMPT.md") : "",
|
|
2271
|
+
fileScope: [],
|
|
2272
|
+
dependencies: [],
|
|
2273
|
+
} as unknown as ParsedTask;
|
|
2274
|
+
})
|
|
2275
|
+
.map((t) => ({ taskId: t.taskId, order: 0, task: t, estimatedMinutes: 0 })),
|
|
2276
|
+
strategy: "round-robin",
|
|
2277
|
+
estimatedLoad: 0,
|
|
2278
|
+
estimatedMinutes: 0,
|
|
2279
|
+
...(laneRecord.repoId !== undefined ? { repoId: laneRecord.repoId } : {}),
|
|
2280
|
+
});
|
|
2281
|
+
}
|
|
2282
|
+
if (catchUpLanes.length > 0) {
|
|
2283
|
+
const ids = catchUpLanes.flatMap((l) => l.tasks.map((t) => t.taskId));
|
|
2284
|
+
onNotify(
|
|
2285
|
+
`🔀 Merging ${catchUpLanes.length} succeeded-but-unmerged lane branch(es) from the interrupted wave (${ids.join(", ")})...`,
|
|
2286
|
+
"info",
|
|
2287
|
+
);
|
|
2288
|
+
const CATCH_UP_WAVE_INDEX = -1;
|
|
2289
|
+
const synthetic: WaveExecutionResult = {
|
|
2290
|
+
waveIndex: CATCH_UP_WAVE_INDEX,
|
|
2291
|
+
startedAt: Date.now(),
|
|
2292
|
+
endedAt: Date.now(),
|
|
2293
|
+
laneResults: catchUpLanes.map((lane) => ({
|
|
2294
|
+
laneNumber: lane.laneNumber,
|
|
2295
|
+
laneId: lane.laneId,
|
|
2296
|
+
tasks: lane.tasks.map((t) => ({
|
|
2297
|
+
taskId: t.taskId,
|
|
2298
|
+
status: "succeeded" as LaneTaskStatus,
|
|
2299
|
+
startTime: Date.now(),
|
|
2300
|
+
endTime: Date.now(),
|
|
2301
|
+
exitReason: "Succeeded before pause; merged on resume",
|
|
2302
|
+
sessionName: lane.laneSessionId,
|
|
2303
|
+
doneFileFound: true,
|
|
2304
|
+
laneNumber: lane.laneNumber,
|
|
2305
|
+
})),
|
|
2306
|
+
overallStatus: "succeeded" as const,
|
|
2307
|
+
startTime: Date.now(),
|
|
2308
|
+
endTime: Date.now(),
|
|
2309
|
+
})),
|
|
2310
|
+
policyApplied: orchConfig.failure.on_task_failure,
|
|
2311
|
+
stoppedEarly: false,
|
|
2312
|
+
failedTaskIds: [],
|
|
2313
|
+
skippedTaskIds: [],
|
|
2314
|
+
succeededTaskIds: ids,
|
|
2315
|
+
blockedTaskIds: [],
|
|
2316
|
+
laneCount: catchUpLanes.length,
|
|
2317
|
+
overallStatus: "succeeded",
|
|
2318
|
+
finalMonitorState: null,
|
|
2319
|
+
allocatedLanes: catchUpLanes,
|
|
2320
|
+
};
|
|
2321
|
+
const catchUp = await mergeWaveByRepo(
|
|
2322
|
+
catchUpLanes,
|
|
2323
|
+
synthetic,
|
|
2324
|
+
CATCH_UP_WAVE_INDEX,
|
|
2325
|
+
orchConfig,
|
|
2326
|
+
repoRoot,
|
|
2327
|
+
batchState.batchId,
|
|
2328
|
+
batchState.orchBranch,
|
|
2329
|
+
workspaceConfig,
|
|
2330
|
+
stateRoot,
|
|
2331
|
+
agentRoot,
|
|
2332
|
+
runnerConfig.testing_commands,
|
|
2333
|
+
undefined,
|
|
2334
|
+
undefined,
|
|
2335
|
+
resumeBackend,
|
|
2336
|
+
);
|
|
2337
|
+
if (catchUp.status === "succeeded") {
|
|
2338
|
+
onNotify(`✅ Catch-up merge complete: ${catchUp.laneResults.length} lane(s) merged`, "info");
|
|
2339
|
+
} else {
|
|
2340
|
+
// Normal merge-failure handling: the batch must NOT proceed to a state
|
|
2341
|
+
// that can read as complete while succeeded work sits unmerged. Pause
|
|
2342
|
+
// (resumable), preserve worktrees, and stop before the wave loop; the
|
|
2343
|
+
// next resume re-runs this catch-up (it is the retry mechanism).
|
|
2344
|
+
onNotify(
|
|
2345
|
+
`⚠️ Catch-up merge ${catchUp.status}: ${catchUp.failureReason || "unknown"} — the succeeded work remains on its lane branch(es). Batch paused; fix the conflict and orch_resume() to retry.`,
|
|
2346
|
+
"warning",
|
|
2347
|
+
);
|
|
2348
|
+
batchState.pauseSignal.paused = true;
|
|
2349
|
+
batchState.pauseSignal.cause = "merge-failure";
|
|
2350
|
+
preserveWorktreesForResume = true;
|
|
2351
|
+
emitAlert({
|
|
2352
|
+
category: "merge-failure",
|
|
2353
|
+
summary:
|
|
2354
|
+
`🔴 Catch-up merge failed on resume for ${ids.join(", ")}: ${catchUp.failureReason || "unknown"}
|
|
2355
|
+
` +
|
|
2356
|
+
` The succeeded work remains on its lane branch(es); the batch is paused with worktrees preserved.
|
|
2357
|
+
` +
|
|
2358
|
+
` Resolve the conflict, then orch_resume() — the catch-up merge re-runs automatically.`,
|
|
2359
|
+
context: {
|
|
2360
|
+
batchProgress: buildBatchProgressSnapshot(batchState),
|
|
2361
|
+
mergeError: catchUp.failureReason ?? undefined,
|
|
2362
|
+
},
|
|
2363
|
+
});
|
|
2364
|
+
}
|
|
2365
|
+
// Record it against the ORIGINAL wave(s) so the wave loop's merge-retry
|
|
2366
|
+
// logic sees those waves as merged (or as needing retry on failure).
|
|
2367
|
+
for (const w of new Set(
|
|
2368
|
+
catchUpLanes.flatMap((l) => l.tasks.map((t) => waveOfTask.get(t.taskId) ?? 0)),
|
|
2369
|
+
)) {
|
|
2370
|
+
batchState.mergeResults.push({ ...catchUp, waveIndex: w + 1 });
|
|
2371
|
+
}
|
|
1929
2372
|
}
|
|
1930
2373
|
}
|
|
1931
2374
|
|
|
@@ -1959,6 +2402,22 @@ export async function resumeOrchBatch(
|
|
|
1959
2402
|
const persistedTask = persistedState.tasks.find((t) => t.taskId === task.taskId);
|
|
1960
2403
|
const reconnectStatus = reconnectFinalStatus.get(task.taskId);
|
|
1961
2404
|
const reExecuteStatus = reExecuteFinalStatus.get(task.taskId);
|
|
2405
|
+
// #629: a re-executed task has a REAL outcome — use it verbatim (telemetry,
|
|
2406
|
+
// diagnostic, timestamps) rather than synthesizing one from stale state.
|
|
2407
|
+
const realOutcome = task.action === "re-execute" ? reExecuteOutcome.get(task.taskId) : undefined;
|
|
2408
|
+
if (realOutcome && (realOutcome.status === "succeeded" || realOutcome.status === "failed")) {
|
|
2409
|
+
allTaskOutcomes.push({
|
|
2410
|
+
...realOutcome,
|
|
2411
|
+
laneNumber: realOutcome.laneNumber ?? persistedTask?.laneNumber,
|
|
2412
|
+
// Preserve persisted partial-progress metadata when the fresh outcome
|
|
2413
|
+
// did not set it (recovery metadata; the stale diagnostic is NOT restored).
|
|
2414
|
+
partialProgressCommits:
|
|
2415
|
+
realOutcome.partialProgressCommits ?? persistedTask?.partialProgressCommits,
|
|
2416
|
+
partialProgressBranch:
|
|
2417
|
+
realOutcome.partialProgressBranch ?? persistedTask?.partialProgressBranch,
|
|
2418
|
+
});
|
|
2419
|
+
continue;
|
|
2420
|
+
}
|
|
1962
2421
|
const status =
|
|
1963
2422
|
task.action === "reconnect"
|
|
1964
2423
|
? reconnectStatus || "running"
|
|
@@ -2006,7 +2465,11 @@ export async function resumeOrchBatch(
|
|
|
2006
2465
|
// (mark-failed) or resolved during reconnect/re-execute must propagate
|
|
2007
2466
|
// to their transitive dependents BEFORE the wave loop begins.
|
|
2008
2467
|
if (orchConfig.failure.on_task_failure === "skip-dependents" && failedTaskSet.size > 0) {
|
|
2009
|
-
const reconciledBlocked = computeTransitiveDependents(
|
|
2468
|
+
const reconciledBlocked = computeTransitiveDependents(
|
|
2469
|
+
failedTaskSet,
|
|
2470
|
+
depGraph,
|
|
2471
|
+
batchTaskScope(wavePlan),
|
|
2472
|
+
);
|
|
2010
2473
|
for (const taskId of reconciledBlocked) {
|
|
2011
2474
|
batchState.blockedTaskIds.add(taskId);
|
|
2012
2475
|
}
|
|
@@ -2037,7 +2500,6 @@ export async function resumeOrchBatch(
|
|
|
2037
2500
|
// We need to execute remaining waves starting from resumeWaveIndex.
|
|
2038
2501
|
// For waves where some tasks are already done, we filter them out.
|
|
2039
2502
|
|
|
2040
|
-
let preserveWorktreesForResume = false;
|
|
2041
2503
|
const persistedStatusByTaskId = new Map(
|
|
2042
2504
|
persistedState.tasks.map((task) => [task.taskId, task.status] as const),
|
|
2043
2505
|
);
|
|
@@ -2050,6 +2512,7 @@ export async function resumeOrchBatch(
|
|
|
2050
2512
|
// Check pause signal
|
|
2051
2513
|
if (batchState.pauseSignal.paused) {
|
|
2052
2514
|
batchState.phase = "paused";
|
|
2515
|
+
preserveWorktreesForResume = true; // every pause exit preserves recovery worktrees
|
|
2053
2516
|
persistRuntimeState(
|
|
2054
2517
|
"pause-before-wave",
|
|
2055
2518
|
batchState,
|
|
@@ -2403,8 +2866,12 @@ export async function resumeOrchBatch(
|
|
|
2403
2866
|
reconnectTaskSet.delete(taskId);
|
|
2404
2867
|
}
|
|
2405
2868
|
|
|
2406
|
-
|
|
2407
|
-
|
|
2869
|
+
{
|
|
2870
|
+
// #629: scope to batch tasks (dependency graph is repo-wide)
|
|
2871
|
+
const scope = batchTaskScope(wavePlan);
|
|
2872
|
+
for (const blocked of waveResult.blockedTaskIds) {
|
|
2873
|
+
if (scope.has(blocked)) batchState.blockedTaskIds.add(blocked);
|
|
2874
|
+
}
|
|
2408
2875
|
}
|
|
2409
2876
|
|
|
2410
2877
|
// ── TP-076: Emit supervisor alerts for task failures ────
|
|
@@ -2480,6 +2947,38 @@ export async function resumeOrchBatch(
|
|
|
2480
2947
|
});
|
|
2481
2948
|
}
|
|
2482
2949
|
|
|
2950
|
+
// ── Pause finalizer (mirrors engine.ts; penster 20260906T194514) ──
|
|
2951
|
+
// Paused tasks are pending, never skipped; a wave with any is not complete.
|
|
2952
|
+
// Finalize as paused (worktrees preserved, no merge) and stop.
|
|
2953
|
+
{
|
|
2954
|
+
const pausedIds = waveResult.pausedTaskIds ?? [];
|
|
2955
|
+
const notAborting =
|
|
2956
|
+
batchState.pauseSignal.cause !== "abort" && waveResult.overallStatus !== "aborted";
|
|
2957
|
+
const operatorPaused = batchState.pauseSignal.paused && notAborting;
|
|
2958
|
+
if (notAborting && (pausedIds.length > 0 || operatorPaused)) {
|
|
2959
|
+
batchState.phase = "paused";
|
|
2960
|
+
preserveWorktreesForResume = true;
|
|
2961
|
+
execLog("resume", batchState.batchId, `batch paused during wave ${waveIdx + 1}`, {
|
|
2962
|
+
cause: batchState.pauseSignal.cause ?? "operator",
|
|
2963
|
+
pendingTasks: pausedIds.join(",") || "(none)",
|
|
2964
|
+
});
|
|
2965
|
+
persistRuntimeState(
|
|
2966
|
+
"pause-during-wave",
|
|
2967
|
+
batchState,
|
|
2968
|
+
wavePlan,
|
|
2969
|
+
latestAllocatedLanes,
|
|
2970
|
+
allTaskOutcomes,
|
|
2971
|
+
discovery,
|
|
2972
|
+
stateRoot,
|
|
2973
|
+
);
|
|
2974
|
+
onNotify(
|
|
2975
|
+
`⏸️ Batch paused during wave ${waveIdx + 1}: ${pausedIds.length} task(s) remain pending. Worktrees preserved. Use orch_resume() to continue.`,
|
|
2976
|
+
"warning",
|
|
2977
|
+
);
|
|
2978
|
+
break;
|
|
2979
|
+
}
|
|
2980
|
+
}
|
|
2981
|
+
|
|
2483
2982
|
persistRuntimeState(
|
|
2484
2983
|
"wave-execution-complete",
|
|
2485
2984
|
batchState,
|
|
@@ -3140,17 +3639,32 @@ export async function resumeOrchBatch(
|
|
|
3140
3639
|
|
|
3141
3640
|
const resetResult = safeResetWorktree(wt, targetBranch, perRepoRoot);
|
|
3142
3641
|
if (!resetResult.success) {
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
// Track this worktree for the cleanup gate — it may still be registered
|
|
3642
|
+
// Track for the cleanup gate on ANY non-removal outcome: throw,
|
|
3643
|
+
// OR a #628 dirty refusal (refusal is NOT success — the worktree
|
|
3644
|
+
// still exists, preserving uncommitted work; never force-clean it).
|
|
3645
|
+
const trackFailedRemoval = () => {
|
|
3148
3646
|
const perRepoId =
|
|
3149
3647
|
perRepoRoot === repoRoot ? undefined : resolveRepoIdFromRoot(perRepoRoot, workspaceConfig);
|
|
3150
3648
|
if (!failedRemovalWorktrees.has(perRepoRoot)) {
|
|
3151
3649
|
failedRemovalWorktrees.set(perRepoRoot, { repoId: perRepoId, paths: [] });
|
|
3152
3650
|
}
|
|
3153
3651
|
failedRemovalWorktrees.get(perRepoRoot)!.paths.push(wt.path);
|
|
3652
|
+
};
|
|
3653
|
+
try {
|
|
3654
|
+
const rm = removeWorktree(wt, perRepoRoot);
|
|
3655
|
+
if (rm.refusedDirty) {
|
|
3656
|
+
execLog(
|
|
3657
|
+
"batch",
|
|
3658
|
+
batchState.batchId,
|
|
3659
|
+
`worktree removal REFUSED for lane ${wt.laneNumber}: ${rm.dirtyFileCount} uncommitted change(s) — preserve progress before cleanup (#628)`,
|
|
3660
|
+
{ path: wt.path },
|
|
3661
|
+
);
|
|
3662
|
+
trackFailedRemoval();
|
|
3663
|
+
}
|
|
3664
|
+
} catch {
|
|
3665
|
+
forceCleanupWorktree(wt, perRepoRoot, batchState.batchId);
|
|
3666
|
+
// Track this worktree for the cleanup gate — it may still be registered
|
|
3667
|
+
trackFailedRemoval();
|
|
3154
3668
|
}
|
|
3155
3669
|
}
|
|
3156
3670
|
}
|
|
@@ -3408,14 +3922,36 @@ export async function resumeOrchBatch(
|
|
|
3408
3922
|
? `${Math.floor(batchDurationMs / 60000)}m ${Math.round((batchDurationMs % 60000) / 1000)}s`
|
|
3409
3923
|
: "unknown";
|
|
3410
3924
|
if (batchState.phase === "completed" && batchState.failedTasks === 0) {
|
|
3925
|
+
// Report outcomes and branch state SEPARATELY and truthfully (penster
|
|
3926
|
+
// 20260906T194514 saw "Merged … Ready for integration" on 0/1 succeeded
|
|
3927
|
+
// with an empty orch branch). Never say "merged" unless the orch branch is
|
|
3928
|
+
// verifiably ahead of base; a failed comparison is "unknown", not "nothing".
|
|
3929
|
+
const branchState = describeOrchBranchStateAcrossRepos(
|
|
3930
|
+
batchState.orchBranch,
|
|
3931
|
+
batchState.baseBranch,
|
|
3932
|
+
encounteredRepoRoots.keys(),
|
|
3933
|
+
);
|
|
3934
|
+
const hasSuccess = batchState.succeededTasks > 0;
|
|
3935
|
+
const outcomeLine =
|
|
3936
|
+
` ${batchState.succeededTasks}/${batchState.totalTasks} tasks succeeded` +
|
|
3937
|
+
(batchState.skippedTasks > 0 ? `, ${batchState.skippedTasks} skipped` : "") +
|
|
3938
|
+
"\n";
|
|
3939
|
+
const nextStep =
|
|
3940
|
+
hasSuccess && branchState.kind === "ahead"
|
|
3941
|
+
? `Ready for integration. Run orch_integrate() or review first.`
|
|
3942
|
+
: branchState.kind === "ahead"
|
|
3943
|
+
? `⚠️ No task succeeded, yet ${branchState.detail} — partial work was merged; inspect before integrating.`
|
|
3944
|
+
: branchState.kind === "unknown"
|
|
3945
|
+
? `⚠️ Could not verify the orch branch (${branchState.detail}). Inspect before integrating.`
|
|
3946
|
+
: `Nothing to integrate: ${branchState.detail}.`;
|
|
3411
3947
|
emitAlert({
|
|
3412
3948
|
category: "batch-complete",
|
|
3413
3949
|
summary:
|
|
3414
|
-
|
|
3415
|
-
|
|
3950
|
+
`${hasSuccess ? "✅" : "⚠️"} Batch ${batchState.batchId} completed\n` +
|
|
3951
|
+
outcomeLine +
|
|
3416
3952
|
` ${batchState.taskLevelWaveCount ?? batchState.totalWaves} wave(s), duration: ${durationStr}\n` +
|
|
3417
|
-
`
|
|
3418
|
-
|
|
3953
|
+
` Orch branch ${batchState.orchBranch}: ${branchState.detail}\n\n` +
|
|
3954
|
+
nextStep,
|
|
3419
3955
|
context: {
|
|
3420
3956
|
batchProgress: buildBatchProgressSnapshot(batchState),
|
|
3421
3957
|
batchDurationMs,
|