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
|
@@ -120,6 +120,14 @@ export interface EngineWorkerData {
|
|
|
120
120
|
force?: boolean;
|
|
121
121
|
/** Supervisor autonomy mode propagated to worker bridge tools. */
|
|
122
122
|
supervisorAutonomy?: "interactive" | "supervised" | "autonomous";
|
|
123
|
+
/**
|
|
124
|
+
* #631: the batch this engine is AUTHORIZED to drive. Preallocated by the
|
|
125
|
+
* parent so the engine identity (pid) is published BEFORE the engine starts
|
|
126
|
+
* — for a fresh batch this is the id the engine must adopt instead of
|
|
127
|
+
* generating its own; for resume it is the persisted/reconstructed target
|
|
128
|
+
* the parent gated ownership against, and resume verifies it matches.
|
|
129
|
+
*/
|
|
130
|
+
authorizedBatchId?: string;
|
|
123
131
|
}
|
|
124
132
|
|
|
125
133
|
// ── Serialization helpers (used by both main thread and worker) ──────
|
|
@@ -211,13 +219,57 @@ export function applySerializedState(
|
|
|
211
219
|
|
|
212
220
|
// Guard: only run engine main when launched via fork() with the sentinel env var.
|
|
213
221
|
if (process.env.TASKPLANE_ENGINE_FORK === "1" && typeof process.send === "function") {
|
|
222
|
+
// #631: a send() on a CLOSED channel does not throw synchronously — Node emits
|
|
223
|
+
// an asynchronous ERR_IPC_CHANNEL_CLOSED on `process`, which would surface as
|
|
224
|
+
// an uncaughtException and route an orphaned engine into reportFatalAndExit
|
|
225
|
+
// instead of its graceful paused wind-down. Gate every send on
|
|
226
|
+
// `process.connected`, and absorb any stray channel error.
|
|
214
227
|
const send = (msg: WorkerToMainMessage) => {
|
|
228
|
+
if (!process.connected) return;
|
|
215
229
|
try {
|
|
216
230
|
process.send?.(msg);
|
|
217
231
|
} catch {
|
|
218
232
|
// best effort only
|
|
219
233
|
}
|
|
220
234
|
};
|
|
235
|
+
const safeDisconnect = () => {
|
|
236
|
+
if (!process.connected) return;
|
|
237
|
+
try {
|
|
238
|
+
process.disconnect?.();
|
|
239
|
+
} catch {
|
|
240
|
+
/* already closed */
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
process.on("error", (err: unknown) => {
|
|
244
|
+
const code = (err as { code?: string } | null)?.code;
|
|
245
|
+
if (code === "ERR_IPC_CHANNEL_CLOSED" || code === "EPIPE") return; // parent gone — expected while orphaned
|
|
246
|
+
throw err;
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// #631: orphan detection must be armed BEFORE the async module imports below
|
|
250
|
+
// (a parent can die during engine startup). `batchState` is hoisted so the
|
|
251
|
+
// handler can pause it once it exists.
|
|
252
|
+
let batchState: OrchBatchRuntimeState | null = null;
|
|
253
|
+
let orphanedBeforeInit = false;
|
|
254
|
+
process.on("disconnect", () => {
|
|
255
|
+
if (!batchState) {
|
|
256
|
+
// Parent vanished before init/planning produced any state: nothing to
|
|
257
|
+
// checkpoint, nothing another session could inherit. Exit quietly.
|
|
258
|
+
orphanedBeforeInit = true;
|
|
259
|
+
process.exit(0);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
// We call process.disconnect() ourselves after a terminal state — that is
|
|
263
|
+
// not an orphaning; only act while the batch is still active.
|
|
264
|
+
const p = batchState.phase;
|
|
265
|
+
if (p === "completed" || p === "failed" || p === "paused" || p === "stopped") return;
|
|
266
|
+
batchState.pauseSignal.paused = true;
|
|
267
|
+
batchState.pauseSignal.cause = "operator";
|
|
268
|
+
process.stderr.write(
|
|
269
|
+
`[orch] engine-worker: supervisor disconnected (parent pid gone) — winding down as paused (#631)
|
|
270
|
+
`,
|
|
271
|
+
);
|
|
272
|
+
});
|
|
221
273
|
|
|
222
274
|
const sendWithAck = (msg: WorkerToMainMessage, onFlushed: () => void) => {
|
|
223
275
|
if (typeof process.send !== "function" || !process.connected) {
|
|
@@ -255,8 +307,8 @@ if (process.env.TASKPLANE_ENGINE_FORK === "1" && typeof process.send === "functi
|
|
|
255
307
|
// Wait for the init message carrying workerData, then start the engine.
|
|
256
308
|
process.once("message", async (initMsg: { type: string; data: EngineWorkerData }) => {
|
|
257
309
|
if (initMsg?.type !== "init") return;
|
|
310
|
+
if (orphanedBeforeInit) return;
|
|
258
311
|
|
|
259
|
-
let batchState: OrchBatchRuntimeState | null = null;
|
|
260
312
|
let fatalHandled = false;
|
|
261
313
|
const reportFatalAndExit = (source: WorkerErrorSource, err: unknown) => {
|
|
262
314
|
if (fatalHandled) return;
|
|
@@ -292,6 +344,7 @@ if (process.env.TASKPLANE_ENGINE_FORK === "1" && typeof process.send === "functi
|
|
|
292
344
|
|
|
293
345
|
// Create a fresh batch state for this process
|
|
294
346
|
batchState = freshOrchBatchState();
|
|
347
|
+
if (data.authorizedBatchId) batchState.batchId = data.authorizedBatchId; // #631
|
|
295
348
|
batchState.phase = "launching";
|
|
296
349
|
batchState.startedAt = Date.now();
|
|
297
350
|
|
|
@@ -306,12 +359,15 @@ if (process.env.TASKPLANE_ENGINE_FORK === "1" && typeof process.send === "functi
|
|
|
306
359
|
switch (msg.type) {
|
|
307
360
|
case "pause":
|
|
308
361
|
batchState.pauseSignal.paused = true;
|
|
362
|
+
batchState.pauseSignal.cause = "operator";
|
|
309
363
|
break;
|
|
310
364
|
case "resume":
|
|
311
365
|
batchState.pauseSignal.paused = false;
|
|
366
|
+
batchState.pauseSignal.cause = undefined;
|
|
312
367
|
break;
|
|
313
368
|
case "abort":
|
|
314
369
|
batchState.pauseSignal.paused = true;
|
|
370
|
+
batchState.pauseSignal.cause = "abort";
|
|
315
371
|
break;
|
|
316
372
|
}
|
|
317
373
|
});
|
|
@@ -392,7 +448,7 @@ if (process.env.TASKPLANE_ENGINE_FORK === "1" && typeof process.send === "functi
|
|
|
392
448
|
const finalState = serializeBatchState(batchState);
|
|
393
449
|
send({ type: "complete", state: finalState });
|
|
394
450
|
// Disconnect IPC so the child process can exit cleanly
|
|
395
|
-
|
|
451
|
+
safeDisconnect();
|
|
396
452
|
})
|
|
397
453
|
.catch((err: unknown) => {
|
|
398
454
|
const normalized = normalizeError(err);
|
|
@@ -409,7 +465,7 @@ if (process.env.TASKPLANE_ENGINE_FORK === "1" && typeof process.send === "functi
|
|
|
409
465
|
message: normalized.message,
|
|
410
466
|
stack: normalized.stack,
|
|
411
467
|
});
|
|
412
|
-
|
|
468
|
+
safeDisconnect();
|
|
413
469
|
});
|
|
414
470
|
});
|
|
415
471
|
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
buildReviewerEnv,
|
|
11
11
|
buildWorkerEnv,
|
|
12
12
|
buildWorkerExcludeEnv,
|
|
13
|
+
batchTaskScope,
|
|
13
14
|
computeTransitiveDependents,
|
|
14
15
|
execLog,
|
|
15
16
|
executeLaneV2,
|
|
@@ -21,7 +22,7 @@ import type { RuntimeBackend } from "./execution.ts";
|
|
|
21
22
|
import type { MonitorUpdateCallback } from "./execution.ts";
|
|
22
23
|
// classifyExit no longer called directly — Tier 0 uses exitDiagnostic.classification
|
|
23
24
|
// from the diagnostic-reports pipeline (populated by assembleDiagnosticInput).
|
|
24
|
-
import { getCurrentBranch, runGit } from "./git.ts";
|
|
25
|
+
import { describeOrchBranchStateAcrossRepos, getCurrentBranch, runGit } from "./git.ts";
|
|
25
26
|
import { killAllMergeAgentsV2, mergeWaveByRepo, MergeHealthMonitor } from "./merge.ts";
|
|
26
27
|
import {
|
|
27
28
|
applyMergeRetryLoop,
|
|
@@ -1581,6 +1582,18 @@ async function attemptWorkerCrashRetry(
|
|
|
1581
1582
|
continue;
|
|
1582
1583
|
}
|
|
1583
1584
|
|
|
1585
|
+
// #629: a finalize refusal over an outstanding REVISE/RETHINK is a
|
|
1586
|
+
// governance outcome, not a fault. Re-running the same worker without
|
|
1587
|
+
// the review file changing cannot succeed; the supervisor adjudicates.
|
|
1588
|
+
if (classification === "review_gate_refusal") {
|
|
1589
|
+
execLog(
|
|
1590
|
+
"batch",
|
|
1591
|
+
batchState.batchId,
|
|
1592
|
+
`tier0: task ${taskId} review_gate_refusal — awaiting adjudication, NOT auto-retrying (#629)`,
|
|
1593
|
+
);
|
|
1594
|
+
continue;
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1584
1597
|
// Check if retryable
|
|
1585
1598
|
if (!TIER0_RETRYABLE_CLASSIFICATIONS.has(classification)) {
|
|
1586
1599
|
execLog(
|
|
@@ -2341,6 +2354,8 @@ async function attemptStaleWorktreeRecovery(
|
|
|
2341
2354
|
thinking: runnerConfig?.reviewer?.thinking || "",
|
|
2342
2355
|
tools: runnerConfig?.reviewer?.tools || "",
|
|
2343
2356
|
excludeExtensions: runnerConfig?.reviewer?.excludeExtensions ?? [],
|
|
2357
|
+
severityLabels: runnerConfig?.reviewer?.severityLabels,
|
|
2358
|
+
spiral: runnerConfig?.reviewer?.spiral,
|
|
2344
2359
|
},
|
|
2345
2360
|
runnerConfig?.worker
|
|
2346
2361
|
? {
|
|
@@ -2533,7 +2548,9 @@ export async function executeOrchBatch(
|
|
|
2533
2548
|
|
|
2534
2549
|
// ── Phase 1: Planning ────────────────────────────────────────
|
|
2535
2550
|
batchState.phase = "planning";
|
|
2536
|
-
|
|
2551
|
+
// #631: honour a parent-preallocated (authorized) batchId so the engine
|
|
2552
|
+
// identity could be published before this process started.
|
|
2553
|
+
batchState.batchId = batchState.batchId || generateBatchId();
|
|
2537
2554
|
// Preserve startedAt if set during "launching" phase (TP-040)
|
|
2538
2555
|
if (!batchState.startedAt) batchState.startedAt = Date.now();
|
|
2539
2556
|
// Preserve pauseSignal if already set during "launching" phase (TP-040)
|
|
@@ -2887,6 +2904,7 @@ export async function executeOrchBatch(
|
|
|
2887
2904
|
// Check pause signal before starting each wave
|
|
2888
2905
|
if (batchState.pauseSignal.paused) {
|
|
2889
2906
|
batchState.phase = "paused";
|
|
2907
|
+
preserveWorktreesForResume = true; // every pause exit preserves recovery worktrees
|
|
2890
2908
|
execLog("batch", batchState.batchId, `batch paused before wave ${waveIdx + 1}`);
|
|
2891
2909
|
{
|
|
2892
2910
|
const { displayWave } = resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount);
|
|
@@ -3111,6 +3129,8 @@ export async function executeOrchBatch(
|
|
|
3111
3129
|
thinking: runnerConfig?.reviewer?.thinking || "",
|
|
3112
3130
|
tools: runnerConfig?.reviewer?.tools || "",
|
|
3113
3131
|
excludeExtensions: runnerConfig?.reviewer?.excludeExtensions ?? [],
|
|
3132
|
+
severityLabels: runnerConfig?.reviewer?.severityLabels,
|
|
3133
|
+
spiral: runnerConfig?.reviewer?.spiral,
|
|
3114
3134
|
},
|
|
3115
3135
|
runnerConfig?.worker
|
|
3116
3136
|
? {
|
|
@@ -3243,7 +3263,11 @@ export async function executeOrchBatch(
|
|
|
3243
3263
|
if (modelFallbackOutcome.succeededRetries.length > 0) {
|
|
3244
3264
|
// Recompute blocked tasks after model fallback successes
|
|
3245
3265
|
if (waveResult.policyApplied === "skip-dependents" && waveResult.failedTaskIds.length > 0) {
|
|
3246
|
-
const recomputed = computeTransitiveDependents(
|
|
3266
|
+
const recomputed = computeTransitiveDependents(
|
|
3267
|
+
new Set(waveResult.failedTaskIds),
|
|
3268
|
+
depGraph,
|
|
3269
|
+
batchTaskScope(wavePlan),
|
|
3270
|
+
);
|
|
3247
3271
|
waveResult.blockedTaskIds = [...recomputed].sort();
|
|
3248
3272
|
} else if (waveResult.failedTaskIds.length === 0) {
|
|
3249
3273
|
waveResult.blockedTaskIds = [];
|
|
@@ -3285,7 +3309,11 @@ export async function executeOrchBatch(
|
|
|
3285
3309
|
// attemptWorkerCrashRetry already updated waveResult.failedTaskIds
|
|
3286
3310
|
// and waveResult.succeededTaskIds in-place.
|
|
3287
3311
|
if (waveResult.policyApplied === "skip-dependents" && waveResult.failedTaskIds.length > 0) {
|
|
3288
|
-
const recomputed = computeTransitiveDependents(
|
|
3312
|
+
const recomputed = computeTransitiveDependents(
|
|
3313
|
+
new Set(waveResult.failedTaskIds),
|
|
3314
|
+
depGraph,
|
|
3315
|
+
batchTaskScope(wavePlan),
|
|
3316
|
+
);
|
|
3289
3317
|
waveResult.blockedTaskIds = [...recomputed].sort();
|
|
3290
3318
|
} else if (waveResult.failedTaskIds.length === 0) {
|
|
3291
3319
|
// All failures recovered — no blocked tasks
|
|
@@ -3312,9 +3340,13 @@ export async function executeOrchBatch(
|
|
|
3312
3340
|
if (
|
|
3313
3341
|
waveResult.failedTaskIds.length === 0 &&
|
|
3314
3342
|
batchState.pauseSignal.paused &&
|
|
3343
|
+
// Positive check: Tier-0 clears ONLY a policy-caused pause. Operator,
|
|
3344
|
+
// abort and merge-failure pauses are never cleared by a recovered retry.
|
|
3345
|
+
(batchState.pauseSignal.cause === undefined || batchState.pauseSignal.cause === "stop-wave") &&
|
|
3315
3346
|
waveResult.policyApplied === "stop-wave"
|
|
3316
3347
|
) {
|
|
3317
3348
|
batchState.pauseSignal.paused = false;
|
|
3349
|
+
batchState.pauseSignal.cause = undefined;
|
|
3318
3350
|
execLog(
|
|
3319
3351
|
"batch",
|
|
3320
3352
|
batchState.batchId,
|
|
@@ -3748,9 +3780,14 @@ export async function executeOrchBatch(
|
|
|
3748
3780
|
batchState.failedTasks += waveResult.failedTaskIds.length;
|
|
3749
3781
|
batchState.skippedTasks += waveResult.skippedTaskIds.length;
|
|
3750
3782
|
|
|
3751
|
-
// Add newly blocked tasks (after retry so recovered tasks don't block dependents)
|
|
3752
|
-
|
|
3753
|
-
|
|
3783
|
+
// Add newly blocked tasks (after retry so recovered tasks don't block dependents).
|
|
3784
|
+
// #629: the dependency graph is repo-wide — only record IDs that belong to
|
|
3785
|
+
// this batch (the blockedTasks counter is already wave-scoped).
|
|
3786
|
+
{
|
|
3787
|
+
const scope = batchTaskScope(wavePlan);
|
|
3788
|
+
for (const blocked of waveResult.blockedTaskIds) {
|
|
3789
|
+
if (scope.has(blocked)) batchState.blockedTaskIds.add(blocked);
|
|
3790
|
+
}
|
|
3754
3791
|
}
|
|
3755
3792
|
|
|
3756
3793
|
// ── TP-040: Emit task_complete / task_failed events ──────
|
|
@@ -3919,6 +3956,51 @@ export async function executeOrchBatch(
|
|
|
3919
3956
|
break;
|
|
3920
3957
|
}
|
|
3921
3958
|
|
|
3959
|
+
// ── Pause finalizer (penster 20260906T194514) ───────────────
|
|
3960
|
+
// A pause that lands DURING a wave leaves tasks pending (never skipped).
|
|
3961
|
+
// Previously pause was only honoured before the NEXT wave, so a paused
|
|
3962
|
+
// single-wave batch "completed" 0/1, merged nothing and cleaned up its
|
|
3963
|
+
// worktree. Finalize as paused here: persist the pending outcomes and
|
|
3964
|
+
// frontier, preserve worktrees, emit batch_paused, and stop — no merge.
|
|
3965
|
+
// A stop-all abort also leaves peers pending — that is the abort path's
|
|
3966
|
+
// business, never a pause. The exclusion applies to the WHOLE predicate.
|
|
3967
|
+
const notAborting =
|
|
3968
|
+
batchState.pauseSignal.cause !== "abort" && waveResult.overallStatus !== "aborted";
|
|
3969
|
+
const operatorPaused = batchState.pauseSignal.paused && notAborting;
|
|
3970
|
+
if (notAborting && ((waveResult.pausedTaskIds?.length ?? 0) > 0 || operatorPaused)) {
|
|
3971
|
+
batchState.phase = "paused";
|
|
3972
|
+
preserveWorktreesForResume = true;
|
|
3973
|
+
const pausedIds = waveResult.pausedTaskIds ?? [];
|
|
3974
|
+
execLog("batch", batchState.batchId, `batch paused during wave ${waveIdx + 1}`, {
|
|
3975
|
+
cause: batchState.pauseSignal.cause ?? "operator",
|
|
3976
|
+
pendingTasks: pausedIds.join(",") || "(none)",
|
|
3977
|
+
succeeded: waveResult.succeededTaskIds.length,
|
|
3978
|
+
failed: waveResult.failedTaskIds.length,
|
|
3979
|
+
});
|
|
3980
|
+
// (Succeeded/failed/skipped counters were already accumulated above; paused
|
|
3981
|
+
// tasks are pending and intentionally not counted.)
|
|
3982
|
+
persistRuntimeState(
|
|
3983
|
+
"pause-during-wave",
|
|
3984
|
+
batchState,
|
|
3985
|
+
wavePlan,
|
|
3986
|
+
latestAllocatedLanes,
|
|
3987
|
+
allTaskOutcomes,
|
|
3988
|
+
discoveryRef,
|
|
3989
|
+
stateRoot,
|
|
3990
|
+
);
|
|
3991
|
+
{
|
|
3992
|
+
const { displayWave } = resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount);
|
|
3993
|
+
onNotify(
|
|
3994
|
+
`⏸️ Batch paused during wave ${displayWave}: ${pausedIds.length} task(s) remain pending` +
|
|
3995
|
+
`${waveResult.succeededTaskIds.length > 0 ? `, ${waveResult.succeededTaskIds.length} succeeded (unmerged until resume)` : ""}. ` +
|
|
3996
|
+
`Worktrees preserved. Use orch_resume() to continue.`,
|
|
3997
|
+
"warning",
|
|
3998
|
+
);
|
|
3999
|
+
}
|
|
4000
|
+
emitTerminalEvent(`Paused during wave ${waveIdx + 1}`);
|
|
4001
|
+
break;
|
|
4002
|
+
}
|
|
4003
|
+
|
|
3922
4004
|
// ── TS-009: Persist state after wave execution ──
|
|
3923
4005
|
persistRuntimeState(
|
|
3924
4006
|
"wave-execution-complete",
|
|
@@ -4915,12 +4997,28 @@ export async function executeOrchBatch(
|
|
|
4915
4997
|
});
|
|
4916
4998
|
// If reset fails, remove this worktree so the next wave can recreate it cleanly.
|
|
4917
4999
|
try {
|
|
4918
|
-
removeWorktree(wt, perRepoRoot);
|
|
4919
|
-
|
|
4920
|
-
|
|
4921
|
-
|
|
4922
|
-
|
|
4923
|
-
|
|
5000
|
+
const rm = removeWorktree(wt, perRepoRoot);
|
|
5001
|
+
if (rm.refusedDirty) {
|
|
5002
|
+
// #628: refusal is NOT success — the worktree still exists with
|
|
5003
|
+
// uncommitted work. Track it for the cleanup gate and surface loudly;
|
|
5004
|
+
// do NOT force-clean (that would destroy the work being protected).
|
|
5005
|
+
execLog(
|
|
5006
|
+
"batch",
|
|
5007
|
+
batchState.batchId,
|
|
5008
|
+
`worktree removal REFUSED for lane ${wt.laneNumber}: ${rm.dirtyFileCount} uncommitted change(s) — preserve progress before cleanup (#628)`,
|
|
5009
|
+
{ path: wt.path },
|
|
5010
|
+
);
|
|
5011
|
+
if (!failedRemovalWorktrees.has(perRepoRoot)) {
|
|
5012
|
+
failedRemovalWorktrees.set(perRepoRoot, { repoId: perRepoId, paths: [] });
|
|
5013
|
+
}
|
|
5014
|
+
failedRemovalWorktrees.get(perRepoRoot)!.paths.push(wt.path);
|
|
5015
|
+
} else {
|
|
5016
|
+
execLog(
|
|
5017
|
+
"batch",
|
|
5018
|
+
batchState.batchId,
|
|
5019
|
+
`removed unrecoverable worktree for lane ${wt.laneNumber}`,
|
|
5020
|
+
);
|
|
5021
|
+
}
|
|
4924
5022
|
} catch (removeErr: unknown) {
|
|
4925
5023
|
execLog(
|
|
4926
5024
|
"batch",
|
|
@@ -5841,14 +5939,36 @@ export async function executeOrchBatch(
|
|
|
5841
5939
|
? `${Math.floor(batchDurationMs / 60000)}m ${Math.round((batchDurationMs % 60000) / 1000)}s`
|
|
5842
5940
|
: "unknown";
|
|
5843
5941
|
if (batchState.phase === "completed" && batchState.failedTasks === 0) {
|
|
5942
|
+
// Report outcomes and branch state SEPARATELY and truthfully (penster
|
|
5943
|
+
// 20260906T194514 saw "Merged … Ready for integration" on 0/1 succeeded
|
|
5944
|
+
// with an empty orch branch). Never say "merged" unless the orch branch is
|
|
5945
|
+
// verifiably ahead of base; a failed comparison is "unknown", not "nothing".
|
|
5946
|
+
const branchState = describeOrchBranchStateAcrossRepos(
|
|
5947
|
+
batchState.orchBranch,
|
|
5948
|
+
batchState.baseBranch,
|
|
5949
|
+
encounteredRepoRoots.keys(),
|
|
5950
|
+
);
|
|
5951
|
+
const hasSuccess = batchState.succeededTasks > 0;
|
|
5952
|
+
const outcomeLine =
|
|
5953
|
+
` ${batchState.succeededTasks}/${batchState.totalTasks} tasks succeeded` +
|
|
5954
|
+
(batchState.skippedTasks > 0 ? `, ${batchState.skippedTasks} skipped` : "") +
|
|
5955
|
+
"\n";
|
|
5956
|
+
const nextStep =
|
|
5957
|
+
hasSuccess && branchState.kind === "ahead"
|
|
5958
|
+
? `Ready for integration. Run orch_integrate() or review first.`
|
|
5959
|
+
: branchState.kind === "ahead"
|
|
5960
|
+
? `⚠️ No task succeeded, yet ${branchState.detail} — partial work was merged; inspect before integrating.`
|
|
5961
|
+
: branchState.kind === "unknown"
|
|
5962
|
+
? `⚠️ Could not verify the orch branch (${branchState.detail}). Inspect before integrating.`
|
|
5963
|
+
: `Nothing to integrate: ${branchState.detail}.`;
|
|
5844
5964
|
emitAlert({
|
|
5845
5965
|
category: "batch-complete",
|
|
5846
5966
|
summary:
|
|
5847
|
-
|
|
5848
|
-
|
|
5967
|
+
`${hasSuccess ? "✅" : "⚠️"} Batch ${batchState.batchId} completed\n` +
|
|
5968
|
+
outcomeLine +
|
|
5849
5969
|
` ${batchState.taskLevelWaveCount ?? batchState.totalWaves} wave(s), duration: ${durationStr}\n` +
|
|
5850
|
-
`
|
|
5851
|
-
|
|
5970
|
+
` Orch branch ${batchState.orchBranch}: ${branchState.detail}\n\n` +
|
|
5971
|
+
nextStep,
|
|
5852
5972
|
context: {
|
|
5853
5973
|
batchProgress: buildBatchProgressSnapshot(batchState),
|
|
5854
5974
|
batchDurationMs,
|
|
@@ -35,6 +35,7 @@ import type {
|
|
|
35
35
|
ParsedTask,
|
|
36
36
|
TaskMonitorSnapshot,
|
|
37
37
|
WaveExecutionResult,
|
|
38
|
+
PauseSignal,
|
|
38
39
|
WorkspaceConfig,
|
|
39
40
|
ExecutionUnit,
|
|
40
41
|
PacketPaths,
|
|
@@ -1198,7 +1199,7 @@ export async function monitorLanes(
|
|
|
1198
1199
|
lanes: AllocatedLane[],
|
|
1199
1200
|
config: OrchestratorConfig,
|
|
1200
1201
|
repoRoot: string,
|
|
1201
|
-
pauseSignal:
|
|
1202
|
+
pauseSignal: PauseSignal,
|
|
1202
1203
|
waveNumber: number = 1,
|
|
1203
1204
|
onUpdate?: MonitorUpdateCallback,
|
|
1204
1205
|
isWorkspaceMode?: boolean,
|
|
@@ -1521,15 +1522,25 @@ export async function monitorLanes(
|
|
|
1521
1522
|
* The failed tasks themselves are NOT included in the output — only their
|
|
1522
1523
|
* downstream dependents.
|
|
1523
1524
|
*
|
|
1525
|
+
* #629 side-effect 3: the dependency graph is REPO-WIDE (built from all
|
|
1526
|
+
* discovered tasks), so without a scope the result can name tasks that are
|
|
1527
|
+
* not in the batch at all ("blocked=TP-2047" for a single-task batch). When
|
|
1528
|
+
* `scope` is given, traversal still walks THROUGH out-of-scope nodes (a
|
|
1529
|
+
* transitive dependent reached via one is still blocked) but only in-scope
|
|
1530
|
+
* task IDs are REPORTED.
|
|
1531
|
+
*
|
|
1524
1532
|
* @param failedTaskIds - Set of task IDs that failed
|
|
1525
1533
|
* @param dependencyGraph - Dependency graph with dependents map
|
|
1534
|
+
* @param scope - Optional batch task set; only these IDs are reported
|
|
1526
1535
|
* @returns Set of task IDs transitively blocked (excludes the failed tasks themselves)
|
|
1527
1536
|
*/
|
|
1528
1537
|
export function computeTransitiveDependents(
|
|
1529
1538
|
failedTaskIds: Set<string>,
|
|
1530
1539
|
dependencyGraph: DependencyGraph,
|
|
1540
|
+
scope?: Set<string>,
|
|
1531
1541
|
): Set<string> {
|
|
1532
1542
|
const blocked = new Set<string>();
|
|
1543
|
+
const visited = new Set<string>(); // traversal set — distinct from the reported set
|
|
1533
1544
|
const queue = [...failedTaskIds];
|
|
1534
1545
|
|
|
1535
1546
|
while (queue.length > 0) {
|
|
@@ -1540,9 +1551,10 @@ export function computeTransitiveDependents(
|
|
|
1540
1551
|
const sortedDependents = [...dependents].sort();
|
|
1541
1552
|
|
|
1542
1553
|
for (const dep of sortedDependents) {
|
|
1543
|
-
if (
|
|
1554
|
+
if (visited.has(dep)) continue;
|
|
1544
1555
|
if (failedTaskIds.has(dep)) continue; // Don't re-add failed tasks
|
|
1545
|
-
|
|
1556
|
+
visited.add(dep);
|
|
1557
|
+
if (!scope || scope.has(dep)) blocked.add(dep);
|
|
1546
1558
|
queue.push(dep); // Continue BFS for transitive closure
|
|
1547
1559
|
}
|
|
1548
1560
|
}
|
|
@@ -1550,6 +1562,13 @@ export function computeTransitiveDependents(
|
|
|
1550
1562
|
return blocked;
|
|
1551
1563
|
}
|
|
1552
1564
|
|
|
1565
|
+
/** Flatten a wave plan into the set of task IDs that belong to the batch. */
|
|
1566
|
+
export function batchTaskScope(wavePlan: string[][] | undefined | null): Set<string> {
|
|
1567
|
+
const scope = new Set<string>();
|
|
1568
|
+
for (const wave of wavePlan ?? []) for (const id of wave) scope.add(id);
|
|
1569
|
+
return scope;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1553
1572
|
// ── Pre-flight: Commit Untracked Task Files ─────────────────────────
|
|
1554
1573
|
|
|
1555
1574
|
/**
|
|
@@ -1914,7 +1933,7 @@ export async function executeWave(
|
|
|
1914
1933
|
config: OrchestratorConfig,
|
|
1915
1934
|
repoRoot: string,
|
|
1916
1935
|
batchId: string,
|
|
1917
|
-
pauseSignal:
|
|
1936
|
+
pauseSignal: PauseSignal,
|
|
1918
1937
|
dependencyGraph: DependencyGraph,
|
|
1919
1938
|
orchBranch: string,
|
|
1920
1939
|
onMonitorUpdate?: MonitorUpdateCallback,
|
|
@@ -1928,6 +1947,8 @@ export async function executeWave(
|
|
|
1928
1947
|
thinking?: string;
|
|
1929
1948
|
tools?: string;
|
|
1930
1949
|
excludeExtensions?: string[];
|
|
1950
|
+
severityLabels?: string[];
|
|
1951
|
+
spiral?: import("./config-schema.ts").ReviewSpiralConfig;
|
|
1931
1952
|
},
|
|
1932
1953
|
workerConfig?: {
|
|
1933
1954
|
model?: string;
|
|
@@ -2166,6 +2187,7 @@ export async function executeWave(
|
|
|
2166
2187
|
// ── Stage 5: Build WaveExecutionResult ───────────────────────
|
|
2167
2188
|
const failedTaskIds: string[] = [];
|
|
2168
2189
|
const skippedTaskIds: string[] = [];
|
|
2190
|
+
const pausedTaskIds: string[] = [];
|
|
2169
2191
|
const succeededTaskIds: string[] = [];
|
|
2170
2192
|
|
|
2171
2193
|
for (const lr of laneResults) {
|
|
@@ -2176,6 +2198,8 @@ export async function executeWave(
|
|
|
2176
2198
|
failedTaskIds.push(t.taskId);
|
|
2177
2199
|
} else if (t.status === "skipped") {
|
|
2178
2200
|
skippedTaskIds.push(t.taskId);
|
|
2201
|
+
} else if (t.status === "pending") {
|
|
2202
|
+
pausedTaskIds.push(t.taskId);
|
|
2179
2203
|
}
|
|
2180
2204
|
}
|
|
2181
2205
|
}
|
|
@@ -2184,6 +2208,7 @@ export async function executeWave(
|
|
|
2184
2208
|
failedTaskIds.sort();
|
|
2185
2209
|
skippedTaskIds.sort();
|
|
2186
2210
|
succeededTaskIds.sort();
|
|
2211
|
+
pausedTaskIds.sort();
|
|
2187
2212
|
|
|
2188
2213
|
// Compute blocked tasks for future waves (skip-dependents policy)
|
|
2189
2214
|
let blockedTaskIds: string[] = [];
|
|
@@ -2210,6 +2235,10 @@ export async function executeWave(
|
|
|
2210
2235
|
let overallStatus: WaveExecutionResult["overallStatus"];
|
|
2211
2236
|
if (policy === "stop-all" && failedTaskIds.length > 0) {
|
|
2212
2237
|
overallStatus = "aborted";
|
|
2238
|
+
} else if (pausedTaskIds.length > 0 && failedTaskIds.length === 0) {
|
|
2239
|
+
// Interrupted, not succeeded: a wave with paused (pending) tasks is not
|
|
2240
|
+
// complete. The engine finalizes as `paused` and does not merge.
|
|
2241
|
+
overallStatus = succeededTaskIds.length > 0 ? "partial" : "failed";
|
|
2213
2242
|
} else if (failedTaskIds.length === 0) {
|
|
2214
2243
|
overallStatus = "succeeded";
|
|
2215
2244
|
} else if (succeededTaskIds.length > 0) {
|
|
@@ -2225,6 +2254,7 @@ export async function executeWave(
|
|
|
2225
2254
|
succeeded: succeededTaskIds.length,
|
|
2226
2255
|
failed: failedTaskIds.length,
|
|
2227
2256
|
skipped: skippedTaskIds.length,
|
|
2257
|
+
paused: pausedTaskIds.length,
|
|
2228
2258
|
blocked: blockedTaskIds.length,
|
|
2229
2259
|
elapsed: `${elapsedSec}s`,
|
|
2230
2260
|
stoppedEarly,
|
|
@@ -2238,6 +2268,7 @@ export async function executeWave(
|
|
|
2238
2268
|
policyApplied: policy,
|
|
2239
2269
|
stoppedEarly,
|
|
2240
2270
|
failedTaskIds,
|
|
2271
|
+
pausedTaskIds,
|
|
2241
2272
|
skippedTaskIds,
|
|
2242
2273
|
succeededTaskIds,
|
|
2243
2274
|
blockedTaskIds,
|
|
@@ -2269,7 +2300,7 @@ export async function executeWave(
|
|
|
2269
2300
|
export async function executeWithStopAll(
|
|
2270
2301
|
lanes: AllocatedLane[],
|
|
2271
2302
|
lanePromises: Promise<LaneExecutionResult>[],
|
|
2272
|
-
pauseSignal:
|
|
2303
|
+
pauseSignal: PauseSignal,
|
|
2273
2304
|
waveIndex: number,
|
|
2274
2305
|
): Promise<LaneExecutionResult[]> {
|
|
2275
2306
|
// Track results as they complete
|
|
@@ -2290,6 +2321,7 @@ export async function executeWithStopAll(
|
|
|
2290
2321
|
// First failure detected — trigger stop-all
|
|
2291
2322
|
abortTriggered = true;
|
|
2292
2323
|
pauseSignal.paused = true;
|
|
2324
|
+
pauseSignal.cause = "abort";
|
|
2293
2325
|
|
|
2294
2326
|
// Determine which task failed first for logging
|
|
2295
2327
|
const firstFailed = result.tasks
|
|
@@ -2325,6 +2357,7 @@ export async function executeWithStopAll(
|
|
|
2325
2357
|
if (!abortTriggered) {
|
|
2326
2358
|
abortTriggered = true;
|
|
2327
2359
|
pauseSignal.paused = true;
|
|
2360
|
+
pauseSignal.cause = "abort";
|
|
2328
2361
|
execLog(
|
|
2329
2362
|
"wave",
|
|
2330
2363
|
`W${waveIndex}`,
|
|
@@ -2758,6 +2791,8 @@ export function buildReviewerEnv(
|
|
|
2758
2791
|
thinking?: string;
|
|
2759
2792
|
tools?: string;
|
|
2760
2793
|
excludeExtensions?: string[];
|
|
2794
|
+
severityLabels?: string[];
|
|
2795
|
+
spiral?: import("./config-schema.ts").ReviewSpiralConfig;
|
|
2761
2796
|
} | null,
|
|
2762
2797
|
): Record<string, string> {
|
|
2763
2798
|
const env: Record<string, string> = {};
|
|
@@ -2768,6 +2803,17 @@ export function buildReviewerEnv(
|
|
|
2768
2803
|
if (reviewerConfig?.excludeExtensions && reviewerConfig.excludeExtensions.length > 0) {
|
|
2769
2804
|
env.TASKPLANE_REVIEWER_EXCLUDE_EXTENSIONS = JSON.stringify(reviewerConfig.excludeExtensions);
|
|
2770
2805
|
}
|
|
2806
|
+
// Review-boundary notifications: forward the severity vocabulary + spiral
|
|
2807
|
+
// tuning as one JSON blob so the lane-runner can analyze reviews and detect
|
|
2808
|
+
// spirals. Absent fields fall back to lane-runner defaults.
|
|
2809
|
+
const analysis: Record<string, unknown> = {};
|
|
2810
|
+
if (reviewerConfig?.severityLabels && reviewerConfig.severityLabels.length > 0) {
|
|
2811
|
+
analysis.severityLabels = reviewerConfig.severityLabels;
|
|
2812
|
+
}
|
|
2813
|
+
if (reviewerConfig?.spiral) analysis.spiral = reviewerConfig.spiral;
|
|
2814
|
+
if (Object.keys(analysis).length > 0) {
|
|
2815
|
+
env.TASKPLANE_REVIEW_ANALYSIS = JSON.stringify(analysis);
|
|
2816
|
+
}
|
|
2771
2817
|
return env;
|
|
2772
2818
|
}
|
|
2773
2819
|
|
|
@@ -2785,12 +2831,21 @@ export function buildWorkerEnv(
|
|
|
2785
2831
|
thinking?: string;
|
|
2786
2832
|
tools?: string;
|
|
2787
2833
|
excludeExtensions?: string[];
|
|
2834
|
+
exitInterceptTimeoutSec?: number;
|
|
2788
2835
|
} | null,
|
|
2789
2836
|
): Record<string, string> {
|
|
2790
2837
|
const env: Record<string, string> = {};
|
|
2791
2838
|
if (workerConfig?.model) env.TASKPLANE_WORKER_MODEL = workerConfig.model;
|
|
2792
2839
|
if (workerConfig?.thinking) env.TASKPLANE_WORKER_THINKING = workerConfig.thinking;
|
|
2793
2840
|
if (workerConfig?.tools) env.TASKPLANE_WORKER_TOOLS = workerConfig.tools;
|
|
2841
|
+
if (
|
|
2842
|
+
typeof workerConfig?.exitInterceptTimeoutSec === "number" &&
|
|
2843
|
+
Number.isFinite(workerConfig.exitInterceptTimeoutSec)
|
|
2844
|
+
) {
|
|
2845
|
+
env.TASKPLANE_EXIT_INTERCEPT_TIMEOUT_SEC = String(
|
|
2846
|
+
Math.min(1800, Math.max(15, Math.round(workerConfig.exitInterceptTimeoutSec))),
|
|
2847
|
+
);
|
|
2848
|
+
}
|
|
2794
2849
|
|
|
2795
2850
|
return env;
|
|
2796
2851
|
}
|
|
@@ -2813,7 +2868,7 @@ export async function executeLaneV2(
|
|
|
2813
2868
|
lane: AllocatedLane,
|
|
2814
2869
|
config: OrchestratorConfig,
|
|
2815
2870
|
repoRoot: string,
|
|
2816
|
-
pauseSignal:
|
|
2871
|
+
pauseSignal: PauseSignal,
|
|
2817
2872
|
workspaceRoot?: string,
|
|
2818
2873
|
isWorkspaceMode?: boolean,
|
|
2819
2874
|
extraEnvVars?: Record<string, string>,
|
|
@@ -2892,12 +2947,14 @@ export async function executeLaneV2(
|
|
|
2892
2947
|
for (const task of lane.tasks) {
|
|
2893
2948
|
const taskSegmentId = task.task.activeSegmentId ?? null;
|
|
2894
2949
|
if (shouldSkipRemaining || pauseSignal.paused) {
|
|
2950
|
+
// A pause leaves the remaining lane tasks PENDING (they never ran); only a
|
|
2951
|
+
// prior failure in the lane skips them.
|
|
2895
2952
|
const reason = pauseSignal.paused
|
|
2896
|
-
? "
|
|
2953
|
+
? "Paused by user"
|
|
2897
2954
|
: "Skipped due to prior task failure in lane";
|
|
2898
2955
|
outcomes.push({
|
|
2899
2956
|
taskId: task.taskId,
|
|
2900
|
-
status: "skipped",
|
|
2957
|
+
status: pauseSignal.paused && !shouldSkipRemaining ? "pending" : "skipped",
|
|
2901
2958
|
segmentId: taskSegmentId,
|
|
2902
2959
|
startTime: null,
|
|
2903
2960
|
endTime: null,
|
|
@@ -2920,6 +2977,24 @@ export async function executeLaneV2(
|
|
|
2920
2977
|
? (rawAutonomy as LaneRunnerConfig["supervisorAutonomy"])
|
|
2921
2978
|
: "autonomous";
|
|
2922
2979
|
|
|
2980
|
+
// Review-boundary notifications: parse the severity vocabulary + spiral
|
|
2981
|
+
// tuning forwarded by buildReviewerEnv (best-effort; lane-runner applies
|
|
2982
|
+
// defaults when absent or unparseable).
|
|
2983
|
+
let reviewSeverityLabels: string[] | undefined;
|
|
2984
|
+
let reviewSpiral: import("./config-schema.ts").ReviewSpiralConfig | undefined;
|
|
2985
|
+
if (extraEnvVars?.TASKPLANE_REVIEW_ANALYSIS) {
|
|
2986
|
+
try {
|
|
2987
|
+
const parsed = JSON.parse(extraEnvVars.TASKPLANE_REVIEW_ANALYSIS) as {
|
|
2988
|
+
severityLabels?: string[];
|
|
2989
|
+
spiral?: import("./config-schema.ts").ReviewSpiralConfig;
|
|
2990
|
+
};
|
|
2991
|
+
if (Array.isArray(parsed.severityLabels)) reviewSeverityLabels = parsed.severityLabels;
|
|
2992
|
+
if (parsed.spiral && typeof parsed.spiral === "object") reviewSpiral = parsed.spiral;
|
|
2993
|
+
} catch {
|
|
2994
|
+
/* best effort — fall back to lane-runner defaults */
|
|
2995
|
+
}
|
|
2996
|
+
}
|
|
2997
|
+
|
|
2923
2998
|
const laneRunnerConfig: LaneRunnerConfig = {
|
|
2924
2999
|
batchId,
|
|
2925
3000
|
agentIdPrefix,
|
|
@@ -2928,12 +3003,18 @@ export async function executeLaneV2(
|
|
|
2928
3003
|
branch: lane.branch,
|
|
2929
3004
|
repoId: lane.repoId ?? "default",
|
|
2930
3005
|
stateRoot,
|
|
3006
|
+
reviewSeverityLabels,
|
|
3007
|
+
reviewSpiral,
|
|
2931
3008
|
workerModel: extraEnvVars?.TASKPLANE_WORKER_MODEL || "",
|
|
2932
3009
|
// TP-184: This is the user-tools default. Engine bridge tools are NOT
|
|
2933
3010
|
// added here — buildWorkerToolsAllowlist() at the lane-runner spawn
|
|
2934
3011
|
// site appends ENGINE_BRIDGE_TOOLS exactly once, regardless of source.
|
|
2935
3012
|
workerTools: extraEnvVars?.TASKPLANE_WORKER_TOOLS || DEFAULT_WORKER_USER_TOOLS,
|
|
2936
3013
|
workerThinking: extraEnvVars?.TASKPLANE_WORKER_THINKING || "",
|
|
3014
|
+
exitInterceptTimeoutSec: (() => {
|
|
3015
|
+
const n = Number.parseInt(extraEnvVars?.TASKPLANE_EXIT_INTERCEPT_TIMEOUT_SEC ?? "", 10);
|
|
3016
|
+
return Number.isFinite(n) && n >= 15 ? Math.min(1800, n) : 60;
|
|
3017
|
+
})(),
|
|
2937
3018
|
workerSystemPrompt,
|
|
2938
3019
|
workerSegmentPrompt,
|
|
2939
3020
|
reviewerModel: extraEnvVars?.TASKPLANE_REVIEWER_MODEL || "",
|