taskplane 0.5.12 → 0.6.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/README.md +1 -1
- package/bin/rpc-wrapper.mjs +777 -0
- package/dashboard/public/app.js +45 -6
- package/dashboard/public/style.css +31 -0
- package/dashboard/server.cjs +326 -1
- package/extensions/task-runner.ts +1111 -30
- package/extensions/taskplane/config-loader.ts +31 -0
- package/extensions/taskplane/config-schema.ts +88 -0
- package/extensions/taskplane/diagnostic-reports.ts +463 -0
- package/extensions/taskplane/diagnostics.ts +323 -0
- package/extensions/taskplane/engine.ts +407 -62
- package/extensions/taskplane/extension.ts +259 -8
- package/extensions/taskplane/index.ts +1 -0
- package/extensions/taskplane/merge.ts +786 -66
- package/extensions/taskplane/messages.ts +594 -2
- package/extensions/taskplane/persistence.ts +342 -19
- package/extensions/taskplane/quality-gate.ts +1033 -0
- package/extensions/taskplane/resume.ts +505 -36
- package/extensions/taskplane/supervisor-primer.md +664 -0
- package/extensions/taskplane/types.ts +534 -6
- package/extensions/taskplane/verification.ts +537 -0
- package/extensions/taskplane/worktree.ts +329 -1
- package/package.json +1 -1
- package/skills/create-taskplane-task/references/prompt-template.md +0 -2
- package/templates/agents/task-reviewer.md +54 -2
- package/templates/agents/task-worker.md +8 -4
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Main batch execution engine
|
|
3
3
|
* @module orch/engine
|
|
4
4
|
*/
|
|
5
|
-
import {
|
|
5
|
+
import { existsSync, readdirSync, readFileSync, unlinkSync } from "fs";
|
|
6
6
|
import { join, resolve } from "path";
|
|
7
7
|
|
|
8
8
|
import { formatDiscoveryResults, runDiscovery } from "./discovery.ts";
|
|
@@ -10,14 +10,16 @@ import { execLog, executeWave, tmuxKillSession } from "./execution.ts";
|
|
|
10
10
|
import type { MonitorUpdateCallback } from "./execution.ts";
|
|
11
11
|
import { getCurrentBranch, runGit } from "./git.ts";
|
|
12
12
|
import { attemptAutoIntegration, mergeWaveByRepo } from "./merge.ts";
|
|
13
|
-
import { computeMergeFailurePolicy, formatRepoMergeSummary, ORCH_MESSAGES } from "./messages.ts";
|
|
13
|
+
import { applyMergeRetryLoop, computeCleanupGatePolicy, computeMergeFailurePolicy, formatRepoMergeSummary, ORCH_MESSAGES } from "./messages.ts";
|
|
14
|
+
import type { CleanupGateRepoFailure } from "./messages.ts";
|
|
15
|
+
import { assembleDiagnosticInput, emitDiagnosticReports } from "./diagnostic-reports.ts";
|
|
14
16
|
import { resolveOperatorId } from "./naming.ts";
|
|
15
|
-
import { deleteBatchState, loadBatchHistory, persistRuntimeState, saveBatchHistory, seedPendingOutcomesForAllocatedLanes, syncTaskOutcomesFromMonitor, upsertTaskOutcome } from "./persistence.ts";
|
|
17
|
+
import { applyPartialProgressToOutcomes, deleteBatchState, loadBatchHistory, persistRuntimeState, saveBatchHistory, seedPendingOutcomesForAllocatedLanes, syncTaskOutcomesFromMonitor, upsertTaskOutcome } from "./persistence.ts";
|
|
16
18
|
import { listOrchSessions } from "./sessions.ts";
|
|
17
|
-
import { FATAL_DISCOVERY_CODES, generateBatchId } from "./types.ts";
|
|
19
|
+
import { defaultResilienceState, FATAL_DISCOVERY_CODES, generateBatchId } from "./types.ts";
|
|
18
20
|
import type { AllocatedLane, BatchHistorySummary, BatchTaskSummary, BatchWaveSummary, DiscoveryResult, LaneExecutionResult, LaneTaskOutcome, MergeWaveResult, OrchBatchPhase, OrchBatchRuntimeState, OrchestratorConfig, TaskRunnerConfig, TokenCounts, WorkspaceConfig } from "./types.ts";
|
|
19
|
-
import { buildDependencyGraph, computeWaves, resolveRepoRoot, validateGraph } from "./waves.ts";
|
|
20
|
-
import { deleteBranchBestEffort, forceCleanupWorktree, formatPreflightResults, listWorktrees, removeAllWorktrees, removeWorktree, runPreflight, safeResetWorktree, sleepSync } from "./worktree.ts";
|
|
21
|
+
import { buildDependencyGraph, computeWaves, resolveBaseBranch, resolveRepoRoot, validateGraph } from "./waves.ts";
|
|
22
|
+
import { deleteBranchBestEffort, forceCleanupWorktree, formatPreflightResults, listWorktrees, preserveFailedLaneProgress, removeAllWorktrees, removeWorktree, runPreflight, safeResetWorktree, sleepSync } from "./worktree.ts";
|
|
21
23
|
|
|
22
24
|
// ── /orch Execution Engine ───────────────────────────────────────────
|
|
23
25
|
|
|
@@ -87,6 +89,12 @@ export async function executeOrchBatch(
|
|
|
87
89
|
let wavePlan: string[][] = [];
|
|
88
90
|
// Reference to discovery result for enriching taskFolder paths.
|
|
89
91
|
let discoveryRef: DiscoveryResult | null = null;
|
|
92
|
+
// TP-029: Track all repo roots encountered during execution.
|
|
93
|
+
// Maps repoRoot → repoId (undefined for primary/repo-mode).
|
|
94
|
+
// Used by inter-wave reset and terminal cleanup to iterate ALL repos
|
|
95
|
+
// that had lanes, not just the primary repoRoot. Parity with resume.ts.
|
|
96
|
+
const encounteredRepoRoots = new Map<string, string | undefined>();
|
|
97
|
+
encounteredRepoRoots.set(repoRoot, undefined); // always include primary
|
|
90
98
|
|
|
91
99
|
execLog("batch", batchState.batchId, "starting batch planning");
|
|
92
100
|
|
|
@@ -303,6 +311,11 @@ export async function executeOrchBatch(
|
|
|
303
311
|
(lanes) => {
|
|
304
312
|
latestAllocatedLanes = lanes;
|
|
305
313
|
batchState.currentLanes = lanes;
|
|
314
|
+
// TP-029: Track repos from newly allocated lanes for cleanup coverage
|
|
315
|
+
for (const lane of lanes) {
|
|
316
|
+
const laneRepoRoot = resolveRepoRoot(lane.repoId, repoRoot, workspaceConfig);
|
|
317
|
+
encounteredRepoRoots.set(laneRepoRoot, lane.repoId);
|
|
318
|
+
}
|
|
306
319
|
if (seedPendingOutcomesForAllocatedLanes(lanes, allTaskOutcomes)) {
|
|
307
320
|
persistRuntimeState("wave-lanes-allocated", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot);
|
|
308
321
|
}
|
|
@@ -410,6 +423,7 @@ export async function executeOrchBatch(
|
|
|
410
423
|
workspaceConfig,
|
|
411
424
|
stateRoot,
|
|
412
425
|
agentRoot,
|
|
426
|
+
runnerConfig.testing_commands,
|
|
413
427
|
);
|
|
414
428
|
allMergeResults.push(mergeResult);
|
|
415
429
|
batchState.mergeResults.push(mergeResult);
|
|
@@ -420,14 +434,16 @@ export async function executeOrchBatch(
|
|
|
420
434
|
// Emit per-lane merge notifications
|
|
421
435
|
for (const lr of mergeResult.laneResults) {
|
|
422
436
|
const durationSec = Math.round(lr.durationMs / 1000);
|
|
423
|
-
|
|
437
|
+
// TP-032 R006-3: Check lr.error first — verification_new_failure lanes
|
|
438
|
+
// have error set even though lr.result.status may be SUCCESS/CONFLICT_RESOLVED.
|
|
439
|
+
if (lr.error) {
|
|
440
|
+
onNotify(ORCH_MESSAGES.orchMergeLaneFailed(lr.laneNumber, lr.error), "error");
|
|
441
|
+
} else if (lr.result?.status === "SUCCESS") {
|
|
424
442
|
onNotify(ORCH_MESSAGES.orchMergeLaneSuccess(lr.laneNumber, lr.result.merge_commit, durationSec), "info");
|
|
425
443
|
} else if (lr.result?.status === "CONFLICT_RESOLVED") {
|
|
426
444
|
onNotify(ORCH_MESSAGES.orchMergeLaneConflictResolved(lr.laneNumber, lr.result.conflicts.length, durationSec), "info");
|
|
427
445
|
} else if (lr.result?.status === "CONFLICT_UNRESOLVED" || lr.result?.status === "BUILD_FAILURE") {
|
|
428
|
-
onNotify(ORCH_MESSAGES.orchMergeLaneFailed(lr.laneNumber, lr.
|
|
429
|
-
} else if (lr.error) {
|
|
430
|
-
onNotify(ORCH_MESSAGES.orchMergeLaneFailed(lr.laneNumber, lr.error), "error");
|
|
446
|
+
onNotify(ORCH_MESSAGES.orchMergeLaneFailed(lr.laneNumber, lr.result.status), "error");
|
|
431
447
|
}
|
|
432
448
|
}
|
|
433
449
|
|
|
@@ -450,8 +466,9 @@ export async function executeOrchBatch(
|
|
|
450
466
|
}
|
|
451
467
|
|
|
452
468
|
// Emit overall merge result notification
|
|
469
|
+
// TP-032 R006-3: Exclude verification_new_failure lanes from success count
|
|
453
470
|
const mergedCount = mergeResult.laneResults.filter(
|
|
454
|
-
r => r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED",
|
|
471
|
+
r => !r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED"),
|
|
455
472
|
).length;
|
|
456
473
|
const mergeTotalSec = Math.round(mergeResult.totalDurationMs / 1000);
|
|
457
474
|
|
|
@@ -501,50 +518,244 @@ export async function executeOrchBatch(
|
|
|
501
518
|
onNotify(ORCH_MESSAGES.orchMergeSkipped(waveIdx + 1), "info");
|
|
502
519
|
}
|
|
503
520
|
|
|
521
|
+
// ── TP-033: Safe-stop on rollback failure ─────────────────
|
|
522
|
+
// When a verification rollback failed, force paused regardless of
|
|
523
|
+
// on_merge_failure policy. The merge worktree and temp branch are
|
|
524
|
+
// preserved for manual recovery using commands in the transaction record.
|
|
525
|
+
if (mergeResult?.rollbackFailed) {
|
|
526
|
+
// TP-033 R004-2: Include persistence error warning when transaction
|
|
527
|
+
// record files may be missing, so operator knows to inspect manually
|
|
528
|
+
const hasPersistErrors = mergeResult.persistenceErrors && mergeResult.persistenceErrors.length > 0;
|
|
529
|
+
const persistWarning = hasPersistErrors
|
|
530
|
+
? ` WARNING: ${mergeResult.persistenceErrors!.length} transaction record(s) failed to persist — recovery file(s) may be missing.`
|
|
531
|
+
: "";
|
|
532
|
+
|
|
533
|
+
execLog("batch", batchState.batchId, "SAFE-STOP: verification rollback failed — forcing paused regardless of policy", {
|
|
534
|
+
waveIndex: waveIdx,
|
|
535
|
+
configPolicy: orchConfig.failure.on_merge_failure,
|
|
536
|
+
...(hasPersistErrors ? { persistenceErrors: mergeResult.persistenceErrors } : {}),
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
batchState.phase = "paused";
|
|
540
|
+
batchState.errors.push(
|
|
541
|
+
`Safe-stop at wave ${waveIdx + 1}: verification rollback failed. ` +
|
|
542
|
+
`Merge worktree and temp branch preserved for recovery. ` +
|
|
543
|
+
`Check transaction records in .pi/verification/ for recovery commands.` +
|
|
544
|
+
persistWarning
|
|
545
|
+
);
|
|
546
|
+
persistRuntimeState("merge-rollback-safe-stop", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot);
|
|
547
|
+
onNotify(
|
|
548
|
+
`🛑 Safe-stop: verification rollback failed at wave ${waveIdx + 1}. ` +
|
|
549
|
+
`Batch force-paused. Merge worktree preserved for manual recovery. ` +
|
|
550
|
+
`See .pi/verification/ transaction records for recovery commands.` +
|
|
551
|
+
persistWarning,
|
|
552
|
+
"error",
|
|
553
|
+
);
|
|
554
|
+
preserveWorktreesForResume = true;
|
|
555
|
+
break;
|
|
556
|
+
}
|
|
557
|
+
|
|
504
558
|
// ── Handle merge failure ─────────────────────────────────
|
|
505
|
-
//
|
|
506
|
-
//
|
|
559
|
+
// TP-033 Step 2 (R006): Retry policy matrix via shared applyMergeRetryLoop.
|
|
560
|
+
// Classifies the failure, loops retries per the matrix (supports maxAttempts>1),
|
|
561
|
+
// and on exhaustion forces paused regardless of on_merge_failure config.
|
|
507
562
|
if (mergeResult && (mergeResult.status === "failed" || mergeResult.status === "partial")) {
|
|
508
|
-
|
|
563
|
+
// Initialize resilience state if not yet present (fresh batch)
|
|
564
|
+
if (!batchState.resilience) {
|
|
565
|
+
batchState.resilience = defaultResilienceState();
|
|
566
|
+
}
|
|
509
567
|
|
|
510
|
-
|
|
568
|
+
const retryOutcome = applyMergeRetryLoop(
|
|
569
|
+
mergeResult,
|
|
570
|
+
waveIdx,
|
|
571
|
+
batchState.resilience.retryCountByScope,
|
|
572
|
+
{
|
|
573
|
+
performMerge: () => {
|
|
574
|
+
batchState.phase = "merging";
|
|
575
|
+
return mergeWaveByRepo(
|
|
576
|
+
waveResult.allocatedLanes,
|
|
577
|
+
waveResult,
|
|
578
|
+
waveIdx + 1,
|
|
579
|
+
orchConfig,
|
|
580
|
+
repoRoot,
|
|
581
|
+
batchState.batchId,
|
|
582
|
+
batchState.orchBranch,
|
|
583
|
+
workspaceConfig,
|
|
584
|
+
stateRoot,
|
|
585
|
+
agentRoot,
|
|
586
|
+
runnerConfig.testing_commands,
|
|
587
|
+
);
|
|
588
|
+
},
|
|
589
|
+
persist: (trigger) => persistRuntimeState(trigger, batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot),
|
|
590
|
+
log: (message, details) => execLog("batch", batchState.batchId, message, details),
|
|
591
|
+
notify: (message, level) => onNotify(message, level),
|
|
592
|
+
updateMergeResult: (result) => {
|
|
593
|
+
mergeResult = result;
|
|
594
|
+
allMergeResults[allMergeResults.length - 1] = result;
|
|
595
|
+
batchState.mergeResults[batchState.mergeResults.length - 1] = result;
|
|
596
|
+
},
|
|
597
|
+
sleep: sleepSync,
|
|
598
|
+
},
|
|
599
|
+
);
|
|
511
600
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
601
|
+
if (retryOutcome.kind === "retry_succeeded") {
|
|
602
|
+
mergeResult = retryOutcome.mergeResult;
|
|
603
|
+
batchState.phase = "executing";
|
|
604
|
+
persistRuntimeState("merge-retry-succeeded", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot);
|
|
605
|
+
// Fall through to normal post-merge flow (worktree cleanup, etc.)
|
|
606
|
+
} else if (retryOutcome.kind === "safe_stop") {
|
|
607
|
+
mergeResult = retryOutcome.mergeResult;
|
|
608
|
+
batchState.phase = "paused";
|
|
609
|
+
batchState.errors.push(retryOutcome.errorMessage);
|
|
610
|
+
persistRuntimeState("merge-rollback-safe-stop", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot);
|
|
611
|
+
onNotify(retryOutcome.notifyMessage, "error");
|
|
612
|
+
preserveWorktreesForResume = true;
|
|
613
|
+
break;
|
|
614
|
+
} else if (retryOutcome.kind === "exhausted") {
|
|
615
|
+
// TP-033 R006-2: Force paused regardless of on_merge_failure config.
|
|
616
|
+
// Retry exhaustion takes precedence over config policy.
|
|
617
|
+
mergeResult = retryOutcome.mergeResult;
|
|
618
|
+
const exhaustionMsg = retryOutcome.errorMessage +
|
|
619
|
+
` [${retryOutcome.classification ?? "unknown"} ${retryOutcome.lastDecision.currentAttempt}/${retryOutcome.lastDecision.maxAttempts}, scope=${retryOutcome.scopeKey}]`;
|
|
620
|
+
|
|
621
|
+
execLog("batch", batchState.batchId, `merge retry exhausted — forcing paused`, {
|
|
622
|
+
classification: retryOutcome.classification,
|
|
623
|
+
scopeKey: retryOutcome.scopeKey,
|
|
624
|
+
attempts: retryOutcome.lastDecision.currentAttempt,
|
|
625
|
+
maxAttempts: retryOutcome.lastDecision.maxAttempts,
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
batchState.phase = "paused";
|
|
629
|
+
batchState.errors.push(exhaustionMsg);
|
|
630
|
+
persistRuntimeState("merge-retry-exhausted", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot);
|
|
631
|
+
onNotify(retryOutcome.notifyMessage, "error");
|
|
632
|
+
preserveWorktreesForResume = true;
|
|
633
|
+
break;
|
|
634
|
+
} else {
|
|
635
|
+
// kind === "no_retry": fall through to standard on_merge_failure policy
|
|
636
|
+
mergeResult = retryOutcome.mergeResult;
|
|
637
|
+
const policyResult = computeMergeFailurePolicy(mergeResult, waveIdx, orchConfig);
|
|
638
|
+
const classNote = retryOutcome.classification
|
|
639
|
+
? ` [not retriable: ${retryOutcome.classification}, scope=${retryOutcome.scopeKey}]`
|
|
640
|
+
: "";
|
|
641
|
+
|
|
642
|
+
execLog("batch", batchState.batchId, `merge failure — applying ${policyResult.policy} policy${classNote}`, policyResult.logDetails);
|
|
643
|
+
|
|
644
|
+
batchState.phase = policyResult.targetPhase;
|
|
645
|
+
batchState.errors.push(policyResult.errorMessage + classNote);
|
|
646
|
+
persistRuntimeState(policyResult.persistTrigger, batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot);
|
|
647
|
+
onNotify(policyResult.notifyMessage + classNote, policyResult.notifyLevel);
|
|
648
|
+
// DO NOT cleanup/reset worktrees — preserve state for debugging/resume
|
|
649
|
+
preserveWorktreesForResume = true;
|
|
650
|
+
break;
|
|
651
|
+
}
|
|
519
652
|
}
|
|
520
653
|
|
|
521
654
|
// NOTE: Merged branch cleanup is deferred to Phase 3, AFTER worktree
|
|
522
655
|
// removal. git branch -D fails if a worktree has the branch checked out.
|
|
523
656
|
|
|
657
|
+
// ── TP-028: Preserve partial progress before inter-wave reset ──
|
|
658
|
+
// Failed tasks may have commits on their lane branch that would be lost
|
|
659
|
+
// when the worktree is reset for the next wave. Save these as named
|
|
660
|
+
// branches before any branch-destructive reset/removal occurs.
|
|
661
|
+
// Hoisted outside the if-block so unsafeBranches is accessible to the
|
|
662
|
+
// reset loop below — both blocks share the same guard condition.
|
|
663
|
+
let ppUnsafeBranches = new Set<string>();
|
|
664
|
+
if (waveIdx < rawWaves.length - 1 && !batchState.pauseSignal.paused) {
|
|
665
|
+
const ppOpId = resolveOperatorId(orchConfig);
|
|
666
|
+
const ppResult = preserveFailedLaneProgress(
|
|
667
|
+
latestAllocatedLanes,
|
|
668
|
+
allTaskOutcomes,
|
|
669
|
+
ppOpId,
|
|
670
|
+
batchState.batchId,
|
|
671
|
+
(repoId) => {
|
|
672
|
+
const perRepoRoot = resolveRepoRoot(repoId, repoRoot, workspaceConfig);
|
|
673
|
+
let targetBranch = batchState.orchBranch;
|
|
674
|
+
if (repoId && perRepoRoot !== repoRoot) {
|
|
675
|
+
try {
|
|
676
|
+
targetBranch = resolveBaseBranch(repoId, perRepoRoot, batchState.orchBranch, workspaceConfig);
|
|
677
|
+
} catch { /* fall back to orchBranch */ }
|
|
678
|
+
}
|
|
679
|
+
return { repoRoot: perRepoRoot, targetBranch };
|
|
680
|
+
},
|
|
681
|
+
);
|
|
682
|
+
ppUnsafeBranches = ppResult.unsafeBranches;
|
|
683
|
+
if (ppResult.results.some(r => r.saved)) {
|
|
684
|
+
execLog("batch", batchState.batchId,
|
|
685
|
+
`preserved partial progress for ${ppResult.results.filter(r => r.saved).length} failed task(s) before inter-wave reset`);
|
|
686
|
+
}
|
|
687
|
+
// Log per-task warnings for failed preservation attempts
|
|
688
|
+
for (const r of ppResult.results) {
|
|
689
|
+
if (!r.saved && (r.commitCount > 0 || r.error)) {
|
|
690
|
+
execLog("batch", batchState.batchId,
|
|
691
|
+
`WARNING: Failed to preserve partial progress for task ${r.taskId} ` +
|
|
692
|
+
`(${r.commitCount} commit(s) at risk on lane branch)`,
|
|
693
|
+
{ taskId: r.taskId, commitCount: r.commitCount, error: r.error ?? "unknown" });
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
if (ppUnsafeBranches.size > 0) {
|
|
697
|
+
execLog("batch", batchState.batchId,
|
|
698
|
+
`WARNING: ${ppUnsafeBranches.size} lane branch(es) could not be preserved — skipping reset for those lanes to prevent commit loss`,
|
|
699
|
+
{ unsafeBranches: [...ppUnsafeBranches] });
|
|
700
|
+
}
|
|
701
|
+
// TP-028: Stamp task outcomes with partial progress data for persistence
|
|
702
|
+
applyPartialProgressToOutcomes(ppResult, allTaskOutcomes);
|
|
703
|
+
}
|
|
704
|
+
|
|
524
705
|
// ── Post-merge: Reset worktrees for next wave ────────────
|
|
525
|
-
// Only reset if merge succeeded AND there are more waves
|
|
706
|
+
// Only reset if merge succeeded AND there are more waves.
|
|
707
|
+
// TP-029: Iterate ALL encountered repo roots (not just primary repoRoot)
|
|
708
|
+
// so that repos active in wave N but not in the final wave still get reset.
|
|
709
|
+
// Follows the resume.ts encounteredRepoRoots pattern for parity.
|
|
526
710
|
if (waveIdx < rawWaves.length - 1 && !batchState.pauseSignal.paused) {
|
|
527
|
-
const
|
|
711
|
+
const resetPrefix = orchConfig.orchestrator.worktree_prefix;
|
|
528
712
|
const resetOpId = resolveOperatorId(orchConfig);
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
713
|
+
let totalResetWorktrees = 0;
|
|
714
|
+
// TP-029 R006: Track worktrees that failed reset AND removal
|
|
715
|
+
// so the cleanup gate only fires on true stale state, not
|
|
716
|
+
// successfully-reset reusable worktrees.
|
|
717
|
+
const failedRemovalWorktrees = new Map<string, { repoId: string | undefined; paths: string[] }>();
|
|
718
|
+
|
|
719
|
+
for (const [perRepoRoot, perRepoId] of encounteredRepoRoots) {
|
|
720
|
+
const existingWorktrees = listWorktrees(resetPrefix, perRepoRoot, resetOpId, batchState.batchId);
|
|
721
|
+
if (existingWorktrees.length === 0) continue;
|
|
722
|
+
totalResetWorktrees += existingWorktrees.length;
|
|
723
|
+
|
|
724
|
+
// Per-repo target branch: primary repo uses orchBranch,
|
|
725
|
+
// secondary repos resolve their own branch (parity with resume.ts).
|
|
726
|
+
let targetBranch: string;
|
|
727
|
+
if (perRepoRoot === repoRoot) {
|
|
728
|
+
targetBranch = batchState.orchBranch;
|
|
729
|
+
} else {
|
|
730
|
+
try {
|
|
731
|
+
targetBranch = resolveBaseBranch(perRepoId, perRepoRoot, batchState.orchBranch, workspaceConfig);
|
|
732
|
+
} catch {
|
|
733
|
+
// If resolution fails, fall back to orchBranch (reset will
|
|
734
|
+
// fail gracefully and trigger worktree removal)
|
|
735
|
+
targetBranch = batchState.orchBranch;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
536
738
|
|
|
537
|
-
const targetBranch = batchState.orchBranch;
|
|
538
739
|
for (const wt of existingWorktrees) {
|
|
539
|
-
|
|
740
|
+
// TP-028: Skip reset for worktrees whose lane branch has
|
|
741
|
+
// unsaved partial progress (preservation failed with commits)
|
|
742
|
+
if (ppUnsafeBranches.has(wt.branch)) {
|
|
743
|
+
execLog("batch", batchState.batchId,
|
|
744
|
+
`skipping worktree reset for lane ${wt.laneNumber} — branch "${wt.branch}" has unsaved partial progress`,
|
|
745
|
+
{ path: wt.path, branch: wt.branch });
|
|
746
|
+
continue;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const resetResult = safeResetWorktree(wt, targetBranch, perRepoRoot);
|
|
540
750
|
if (!resetResult.success) {
|
|
541
751
|
execLog("batch", batchState.batchId, `worktree reset failed for lane ${wt.laneNumber}`, {
|
|
542
752
|
error: resetResult.error || "unknown",
|
|
543
753
|
path: wt.path,
|
|
754
|
+
repoId: perRepoId ?? "(default)",
|
|
544
755
|
});
|
|
545
756
|
// If reset fails, remove this worktree so the next wave can recreate it cleanly.
|
|
546
757
|
try {
|
|
547
|
-
removeWorktree(wt,
|
|
758
|
+
removeWorktree(wt, perRepoRoot);
|
|
548
759
|
execLog("batch", batchState.batchId, `removed unrecoverable worktree for lane ${wt.laneNumber}`);
|
|
549
760
|
} catch (removeErr: unknown) {
|
|
550
761
|
execLog("batch", batchState.batchId, `removeWorktree failed for lane ${wt.laneNumber}, attempting force cleanup`, {
|
|
@@ -552,15 +763,62 @@ export async function executeOrchBatch(
|
|
|
552
763
|
path: wt.path,
|
|
553
764
|
});
|
|
554
765
|
// Last resort: force-remove the directory and prune git worktree state.
|
|
555
|
-
|
|
556
|
-
//
|
|
557
|
-
|
|
766
|
+
forceCleanupWorktree(wt, perRepoRoot, batchState.batchId);
|
|
767
|
+
// Track this worktree for the cleanup gate — it may still be registered
|
|
768
|
+
if (!failedRemovalWorktrees.has(perRepoRoot)) {
|
|
769
|
+
failedRemovalWorktrees.set(perRepoRoot, { repoId: perRepoId, paths: [] });
|
|
770
|
+
}
|
|
771
|
+
failedRemovalWorktrees.get(perRepoRoot)!.paths.push(wt.path);
|
|
558
772
|
}
|
|
559
773
|
} else {
|
|
560
774
|
execLog("batch", batchState.batchId, `worktree reset OK for lane ${wt.laneNumber}`);
|
|
561
775
|
}
|
|
562
776
|
}
|
|
563
777
|
}
|
|
778
|
+
|
|
779
|
+
if (totalResetWorktrees > 0) {
|
|
780
|
+
onNotify(
|
|
781
|
+
ORCH_MESSAGES.orchWorktreeReset(waveIdx + 1, totalResetWorktrees),
|
|
782
|
+
"info",
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// ── TP-029: Post-merge cleanup gate ──────────────────────
|
|
787
|
+
// Only gate on worktrees that the reset loop tried and failed
|
|
788
|
+
// to remove. Successfully-reset reusable worktrees are expected
|
|
789
|
+
// to remain registered — they will be reused in the next wave.
|
|
790
|
+
// For each failed-removal worktree, verify it is still registered
|
|
791
|
+
// before classifying it as truly stale.
|
|
792
|
+
const cleanupGateFailures: CleanupGateRepoFailure[] = [];
|
|
793
|
+
if (failedRemovalWorktrees.size > 0) {
|
|
794
|
+
for (const [perRepoRoot, { repoId: perRepoId, paths: failedPaths }] of failedRemovalWorktrees) {
|
|
795
|
+
const remaining = listWorktrees(resetPrefix, perRepoRoot, resetOpId, batchState.batchId);
|
|
796
|
+
const remainingPaths = new Set(remaining.map(wt => wt.path));
|
|
797
|
+
// Only report worktrees that were targeted for removal but are still registered
|
|
798
|
+
const stale = failedPaths.filter(p => remainingPaths.has(p));
|
|
799
|
+
if (stale.length > 0) {
|
|
800
|
+
cleanupGateFailures.push({
|
|
801
|
+
repoRoot: perRepoRoot,
|
|
802
|
+
repoId: perRepoId,
|
|
803
|
+
staleWorktrees: stale,
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
if (cleanupGateFailures.length > 0) {
|
|
810
|
+
const gatePolicyResult = computeCleanupGatePolicy(waveIdx, cleanupGateFailures);
|
|
811
|
+
|
|
812
|
+
execLog("batch", batchState.batchId, `cleanup gate failed — pausing batch`, gatePolicyResult.logDetails);
|
|
813
|
+
|
|
814
|
+
batchState.phase = gatePolicyResult.targetPhase;
|
|
815
|
+
batchState.errors.push(gatePolicyResult.errorMessage);
|
|
816
|
+
persistRuntimeState(gatePolicyResult.persistTrigger, batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot);
|
|
817
|
+
onNotify(gatePolicyResult.notifyMessage, gatePolicyResult.notifyLevel);
|
|
818
|
+
// Preserve remaining worktrees for manual cleanup — do NOT remove them
|
|
819
|
+
preserveWorktreesForResume = true;
|
|
820
|
+
break;
|
|
821
|
+
}
|
|
564
822
|
}
|
|
565
823
|
}
|
|
566
824
|
|
|
@@ -681,6 +939,17 @@ export async function executeOrchBatch(
|
|
|
681
939
|
execLog("batch", batchState.batchId, `failed to save batch history: ${err}`);
|
|
682
940
|
}
|
|
683
941
|
|
|
942
|
+
// ── Pre-cleanup: Determine if worktrees should be preserved ──
|
|
943
|
+
// TP-031 (R006): This check MUST run before cleanup so that worktrees
|
|
944
|
+
// survive when failedTasks > 0. Without this, cleanup deletes worktrees
|
|
945
|
+
// before the batch is marked "paused", breaking resumability.
|
|
946
|
+
if (!preserveWorktreesForResume &&
|
|
947
|
+
((batchState.phase as OrchBatchPhase) === "executing" || (batchState.phase as OrchBatchPhase) === "merging") &&
|
|
948
|
+
batchState.failedTasks > 0) {
|
|
949
|
+
preserveWorktreesForResume = true;
|
|
950
|
+
execLog("batch", batchState.batchId, "pre-cleanup: failedTasks > 0 detected, preserving worktrees for resume");
|
|
951
|
+
}
|
|
952
|
+
|
|
684
953
|
// ── Phase 3: Cleanup ─────────────────────────────────────────
|
|
685
954
|
const prefix = orchConfig.orchestrator.worktree_prefix;
|
|
686
955
|
|
|
@@ -717,34 +986,99 @@ export async function executeOrchBatch(
|
|
|
717
986
|
}
|
|
718
987
|
} catch { /* .pi dir may not exist */ }
|
|
719
988
|
|
|
720
|
-
//
|
|
721
|
-
//
|
|
722
|
-
//
|
|
723
|
-
|
|
989
|
+
// ── TP-028: Preserve partial progress before terminal cleanup ──
|
|
990
|
+
// Save failed task commits as named branches before worktree removal
|
|
991
|
+
// destroys the lane branches. Uses the last wave's allocated lanes
|
|
992
|
+
// to map failed tasks to their lane branches.
|
|
993
|
+
{
|
|
994
|
+
const ppOpId = resolveOperatorId(orchConfig);
|
|
995
|
+
const ppResult = preserveFailedLaneProgress(
|
|
996
|
+
latestAllocatedLanes,
|
|
997
|
+
allTaskOutcomes,
|
|
998
|
+
ppOpId,
|
|
999
|
+
batchState.batchId,
|
|
1000
|
+
(repoId) => {
|
|
1001
|
+
const perRepoRoot = resolveRepoRoot(repoId, repoRoot, workspaceConfig);
|
|
1002
|
+
let targetBranch = batchState.orchBranch;
|
|
1003
|
+
if (repoId && perRepoRoot !== repoRoot) {
|
|
1004
|
+
try {
|
|
1005
|
+
targetBranch = resolveBaseBranch(repoId, perRepoRoot, batchState.orchBranch, workspaceConfig);
|
|
1006
|
+
} catch { /* fall back to orchBranch */ }
|
|
1007
|
+
}
|
|
1008
|
+
return { repoRoot: perRepoRoot, targetBranch };
|
|
1009
|
+
},
|
|
1010
|
+
);
|
|
1011
|
+
if (ppResult.results.some(r => r.saved)) {
|
|
1012
|
+
execLog("batch", batchState.batchId,
|
|
1013
|
+
`preserved partial progress for ${ppResult.results.filter(r => r.saved).length} failed task(s) before terminal cleanup`);
|
|
1014
|
+
}
|
|
1015
|
+
// Log warnings for failed preservation attempts — at terminal cleanup
|
|
1016
|
+
// we cannot skip deletion (batch is ending), but operators need to know
|
|
1017
|
+
// that commits may become unreachable via reflog only.
|
|
1018
|
+
for (const r of ppResult.results) {
|
|
1019
|
+
if (!r.saved && (r.commitCount > 0 || r.error)) {
|
|
1020
|
+
execLog("batch", batchState.batchId,
|
|
1021
|
+
`WARNING: Failed to preserve partial progress for task ${r.taskId} ` +
|
|
1022
|
+
`(${r.commitCount} commit(s) may become unreachable after cleanup)`,
|
|
1023
|
+
{ taskId: r.taskId, commitCount: r.commitCount, error: r.error ?? "unknown" });
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
// TP-028: Stamp task outcomes with partial progress data for persistence
|
|
1027
|
+
applyPartialProgressToOutcomes(ppResult, allTaskOutcomes);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// TP-029: Clean up worktrees across ALL encountered repos (not just primary).
|
|
1031
|
+
// Per-repo target branch resolution: primary repo uses orchBranch,
|
|
1032
|
+
// secondary repos resolve their own branch via resolveBaseBranch.
|
|
1033
|
+
// Parity with resume.ts:1475-1507.
|
|
724
1034
|
const cleanupOpId = resolveOperatorId(orchConfig);
|
|
725
1035
|
execLog("batch", batchState.batchId, "cleaning up worktrees");
|
|
726
|
-
const removeResult = removeAllWorktrees(prefix, repoRoot, cleanupOpId, targetBranch, batchState.batchId, orchConfig);
|
|
727
|
-
|
|
728
|
-
// Log preserved branches
|
|
729
|
-
for (const p of removeResult.preserved) {
|
|
730
|
-
execLog("batch", batchState.batchId, `preserving unmerged branch as saved ref`, {
|
|
731
|
-
branch: p.branch,
|
|
732
|
-
savedBranch: p.savedBranch,
|
|
733
|
-
lane: p.laneNumber,
|
|
734
|
-
target: targetBranch,
|
|
735
|
-
commitCount: p.unmergedCount ?? 0,
|
|
736
|
-
});
|
|
737
|
-
}
|
|
738
1036
|
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
1037
|
+
for (const [perRepoRoot, perRepoId] of encounteredRepoRoots) {
|
|
1038
|
+
let targetBranch: string | undefined;
|
|
1039
|
+
if (perRepoRoot === repoRoot) {
|
|
1040
|
+
// Primary repo: lane branches were merged into orchBranch
|
|
1041
|
+
targetBranch = batchState.orchBranch;
|
|
1042
|
+
} else {
|
|
1043
|
+
// Secondary repo (workspace mode): resolve the repo's own branch
|
|
1044
|
+
try {
|
|
1045
|
+
targetBranch = resolveBaseBranch(perRepoId, perRepoRoot, batchState.orchBranch, workspaceConfig);
|
|
1046
|
+
} catch {
|
|
1047
|
+
// Fall back to undefined — skips branch protection
|
|
1048
|
+
// (safe because successfully merged branches were already cleaned)
|
|
1049
|
+
targetBranch = undefined;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
const removeResult = removeAllWorktrees(prefix, perRepoRoot, cleanupOpId, targetBranch, batchState.batchId, orchConfig);
|
|
1053
|
+
|
|
1054
|
+
// Log preserved branches
|
|
1055
|
+
for (const p of removeResult.preserved) {
|
|
1056
|
+
execLog("batch", batchState.batchId, `preserving unmerged branch as saved ref`, {
|
|
1057
|
+
branch: p.branch,
|
|
1058
|
+
savedBranch: p.savedBranch,
|
|
1059
|
+
lane: p.laneNumber,
|
|
1060
|
+
target: targetBranch,
|
|
1061
|
+
commitCount: p.unmergedCount ?? 0,
|
|
1062
|
+
repoId: perRepoId ?? "(default)",
|
|
1063
|
+
});
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
if (removeResult.failed.length > 0) {
|
|
1067
|
+
const failedPaths = removeResult.failed.map(f => f.worktree.path).join(", ");
|
|
1068
|
+
execLog("batch", batchState.batchId, `worktree cleanup: ${removeResult.removed.length} removed, ${removeResult.failed.length} failed, ${removeResult.preserved.length} preserved`, {
|
|
1069
|
+
failedPaths,
|
|
1070
|
+
repoId: perRepoId ?? "(default)",
|
|
1071
|
+
});
|
|
1072
|
+
} else if (removeResult.totalAttempted > 0) {
|
|
1073
|
+
execLog("batch", batchState.batchId, `worktree cleanup: ${removeResult.removed.length} removed, ${removeResult.preserved.length} preserved`, {
|
|
1074
|
+
repoId: perRepoId ?? "(default)",
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
746
1077
|
}
|
|
747
1078
|
|
|
1079
|
+
// NOTE: Empty .worktrees base-dir cleanup (subdirectory mode) is handled
|
|
1080
|
+
// inside removeAllWorktrees() when config is passed — no duplicate pass needed here.
|
|
1081
|
+
|
|
748
1082
|
// ── Post-worktree-removal: Clean up merged branches ──────
|
|
749
1083
|
// This MUST run after worktree removal because git branch -D
|
|
750
1084
|
// fails if any worktree still has the branch checked out.
|
|
@@ -753,7 +1087,9 @@ export async function executeOrchBatch(
|
|
|
753
1087
|
for (const mergeResult of allMergeResults) {
|
|
754
1088
|
if (mergeResult.status === "succeeded" || mergeResult.status === "partial") {
|
|
755
1089
|
for (const lr of mergeResult.laneResults) {
|
|
756
|
-
|
|
1090
|
+
// TP-032 R006-3: Exclude verification_new_failure lanes from branch cleanup
|
|
1091
|
+
// (their merge commits were rolled back, so the branch is NOT merged)
|
|
1092
|
+
if (!lr.error && (lr.result?.status === "SUCCESS" || lr.result?.status === "CONFLICT_RESOLVED")) {
|
|
757
1093
|
const laneRepoRoot = resolveRepoRoot(lr.repoId, repoRoot, workspaceConfig);
|
|
758
1094
|
const ancestorCheck = runGit(
|
|
759
1095
|
["merge-base", "--is-ancestor", lr.sourceBranch, lr.targetBranch],
|
|
@@ -791,7 +1127,12 @@ export async function executeOrchBatch(
|
|
|
791
1127
|
if ((batchState.phase as OrchBatchPhase) === "executing" || (batchState.phase as OrchBatchPhase) === "merging") {
|
|
792
1128
|
// Normal completion (not stopped, paused, or aborted)
|
|
793
1129
|
if (batchState.failedTasks > 0) {
|
|
794
|
-
|
|
1130
|
+
// TP-031: Default to "paused" so the batch is resumable without --force.
|
|
1131
|
+
// "failed" is reserved for unrecoverable invariant violations after retry
|
|
1132
|
+
// exhaustion (not yet implemented — will be added when retry logic lands).
|
|
1133
|
+
// NOTE: preserveWorktreesForResume was already set pre-cleanup to ensure
|
|
1134
|
+
// worktrees survive; this just sets the phase for state persistence.
|
|
1135
|
+
batchState.phase = "paused";
|
|
795
1136
|
} else {
|
|
796
1137
|
batchState.phase = "completed";
|
|
797
1138
|
}
|
|
@@ -829,6 +1170,10 @@ export async function executeOrchBatch(
|
|
|
829
1170
|
// ── TS-009: Persist terminal state ──
|
|
830
1171
|
persistRuntimeState("batch-terminal", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot);
|
|
831
1172
|
|
|
1173
|
+
// ── TP-031: Emit diagnostic reports (JSONL + markdown) ──
|
|
1174
|
+
// Non-fatal: errors are logged but never crash batch finalization.
|
|
1175
|
+
emitDiagnosticReports(assembleDiagnosticInput(orchConfig, batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, stateRoot));
|
|
1176
|
+
|
|
832
1177
|
if (batchState.phase === "paused" || batchState.phase === "stopped") {
|
|
833
1178
|
execLog("batch", batchState.batchId, "batch ended in non-terminal execution state; completion banner suppressed", {
|
|
834
1179
|
phase: batchState.phase,
|