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,8 @@
|
|
|
2
2
|
* User-facing message templates (ORCH_MESSAGES)
|
|
3
3
|
* @module orch/messages
|
|
4
4
|
*/
|
|
5
|
-
import type { AbortMode, MergeWaveResult, OrchestratorConfig, RepoMergeOutcome } from "./types.ts";
|
|
5
|
+
import type { AbortMode, MergeFailureClassification, MergeRetryCallbacks, MergeRetryDecision, MergeRetryLoopOutcome, MergeRetryPolicy, MergeWaveResult, OrchestratorConfig, RepoMergeOutcome } from "./types.ts";
|
|
6
|
+
import { MERGE_RETRY_POLICY_MATRIX } from "./types.ts";
|
|
6
7
|
|
|
7
8
|
// ── Message Templates ────────────────────────────────────────────────
|
|
8
9
|
|
|
@@ -110,6 +111,13 @@ export const ORCH_MESSAGES = {
|
|
|
110
111
|
resumeComplete: (batchId: string, succeeded: number, failed: number, skipped: number, blocked: number, elapsedSec: number) =>
|
|
111
112
|
`\n🏁 Resumed batch ${batchId} complete: ${succeeded} succeeded, ${failed} failed, ${skipped} skipped, ${blocked} blocked (${elapsedSec}s total)`,
|
|
112
113
|
|
|
114
|
+
// /orch-resume --force
|
|
115
|
+
forceResumeStarting: (batchId: string, phase: string) =>
|
|
116
|
+
`⚠️ Force-resuming batch ${batchId} from ${phase} state. Running pre-resume diagnostics...`,
|
|
117
|
+
forceResumeDiagnosticsFailed: (batchId: string) =>
|
|
118
|
+
`❌ Cannot force-resume batch ${batchId}: pre-resume diagnostics failed.\n` +
|
|
119
|
+
` Fix the issues above, then retry /orch-resume --force.`,
|
|
120
|
+
|
|
113
121
|
// /orch-abort
|
|
114
122
|
abortGracefulStarting: (batchId: string, sessionCount: number) =>
|
|
115
123
|
`⏳ Graceful abort of batch ${batchId}: signaling ${sessionCount} session(s) to checkpoint and exit...`,
|
|
@@ -209,8 +217,9 @@ export function formatRepoMergeSummary(mergeResult: MergeWaveResult): string | n
|
|
|
209
217
|
const repoLines = repoResults.map(r => {
|
|
210
218
|
const repoLabel = r.repoId ?? "(default)";
|
|
211
219
|
const icon = repoStatusIcon(r.status);
|
|
220
|
+
// TP-032 R006-3: Exclude verification_new_failure lanes from success count
|
|
212
221
|
const mergedCount = r.laneResults.filter(
|
|
213
|
-
lr => lr.result?.status === "SUCCESS" || lr.result?.status === "CONFLICT_RESOLVED",
|
|
222
|
+
lr => !lr.error && (lr.result?.status === "SUCCESS" || lr.result?.status === "CONFLICT_RESOLVED"),
|
|
214
223
|
).length;
|
|
215
224
|
const totalCount = r.laneResults.length;
|
|
216
225
|
let detail = `${mergedCount}/${totalCount} lane(s) merged`;
|
|
@@ -354,6 +363,589 @@ export function computeMergeFailurePolicy(
|
|
|
354
363
|
}
|
|
355
364
|
|
|
356
365
|
|
|
366
|
+
// ── Cleanup Gate Policy (TP-029 Step 2) ──────────────────────────────
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Per-repo cleanup failure detail.
|
|
370
|
+
* Collected during post-merge inter-wave verification.
|
|
371
|
+
*/
|
|
372
|
+
export interface CleanupGateRepoFailure {
|
|
373
|
+
/** Repo root path that has stale worktrees */
|
|
374
|
+
repoRoot: string;
|
|
375
|
+
/** Repo ID (undefined for primary/repo-mode) */
|
|
376
|
+
repoId: string | undefined;
|
|
377
|
+
/** Paths of stale worktrees still registered after cleanup */
|
|
378
|
+
staleWorktrees: string[];
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Result of applying the cleanup gate policy.
|
|
383
|
+
*
|
|
384
|
+
* Pure function output — callers use this to perform state mutations
|
|
385
|
+
* and notifications consistently. Ensures engine.ts and resume.ts
|
|
386
|
+
* apply identical pause transitions on cleanup failure.
|
|
387
|
+
*/
|
|
388
|
+
export interface CleanupGatePolicyResult {
|
|
389
|
+
/** Always "pause" — cleanup failures block next wave but preserve merged work */
|
|
390
|
+
policy: "pause";
|
|
391
|
+
/** Target phase for batchState.phase */
|
|
392
|
+
targetPhase: "paused";
|
|
393
|
+
/** Error message to push to batchState.errors */
|
|
394
|
+
errorMessage: string;
|
|
395
|
+
/** Persistence trigger label — matches spec classification naming */
|
|
396
|
+
persistTrigger: "cleanup_post_merge_failed";
|
|
397
|
+
/** User-facing notification message */
|
|
398
|
+
notifyMessage: string;
|
|
399
|
+
/** Notification level for onNotify */
|
|
400
|
+
notifyLevel: "error";
|
|
401
|
+
/** Structured log details for execLog */
|
|
402
|
+
logDetails: {
|
|
403
|
+
waveNumber: number;
|
|
404
|
+
failedRepoCount: number;
|
|
405
|
+
totalStaleWorktrees: number;
|
|
406
|
+
repos: Array<{ repoId: string; staleCount: number }>;
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Compute the cleanup gate policy result for post-merge verification failure.
|
|
412
|
+
*
|
|
413
|
+
* This is a **pure function** — it computes all outputs deterministically
|
|
414
|
+
* from the wave index and per-repo failure details, without performing any
|
|
415
|
+
* side effects.
|
|
416
|
+
*
|
|
417
|
+
* Both engine.ts and resume.ts MUST use this function to guarantee
|
|
418
|
+
* identical failure attribution, phase transitions, error messages,
|
|
419
|
+
* and notifications when post-merge cleanup leaves stale worktrees.
|
|
420
|
+
*
|
|
421
|
+
* The cleanup gate always pauses (never aborts) because:
|
|
422
|
+
* - Merged commits are already on the orch branch and must not be lost
|
|
423
|
+
* - The operator can manually remove stale worktrees and `/orch-resume`
|
|
424
|
+
*
|
|
425
|
+
* @param waveIndex - 0-based wave index (displayed as 1-indexed)
|
|
426
|
+
* @param failures - Per-repo cleanup failure details
|
|
427
|
+
* @returns Policy result object for callers to apply
|
|
428
|
+
*/
|
|
429
|
+
export function computeCleanupGatePolicy(
|
|
430
|
+
waveIndex: number,
|
|
431
|
+
failures: CleanupGateRepoFailure[],
|
|
432
|
+
): CleanupGatePolicyResult {
|
|
433
|
+
const waveNum = waveIndex + 1;
|
|
434
|
+
const failedRepoCount = failures.length;
|
|
435
|
+
const totalStaleWorktrees = failures.reduce((sum, f) => sum + f.staleWorktrees.length, 0);
|
|
436
|
+
|
|
437
|
+
const repos = failures.map(f => ({
|
|
438
|
+
repoId: f.repoId ?? "(default)",
|
|
439
|
+
staleCount: f.staleWorktrees.length,
|
|
440
|
+
}));
|
|
441
|
+
|
|
442
|
+
const repoDetail = repos.map(r => `${r.repoId} (${r.staleCount} stale)`).join(", ");
|
|
443
|
+
|
|
444
|
+
const errorMessage =
|
|
445
|
+
`Post-merge cleanup failed at wave ${waveNum}: ${totalStaleWorktrees} stale worktree(s) ` +
|
|
446
|
+
`in ${failedRepoCount} repo(s) [${repoDetail}]. ` +
|
|
447
|
+
`Batch paused. Remove stale worktrees manually and use /orch-resume to continue.`;
|
|
448
|
+
|
|
449
|
+
// Build recovery commands for each failed repo
|
|
450
|
+
const recoveryLines: string[] = [];
|
|
451
|
+
for (const f of failures) {
|
|
452
|
+
const label = f.repoId ?? "default";
|
|
453
|
+
for (const wt of f.staleWorktrees) {
|
|
454
|
+
recoveryLines.push(` git worktree remove --force "${wt}" # repo: ${label}`);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const notifyMessage =
|
|
459
|
+
`⏸️ Batch paused: post-merge cleanup failed at wave ${waveNum}.\n` +
|
|
460
|
+
` ${totalStaleWorktrees} stale worktree(s) in ${failedRepoCount} repo(s): ${repoDetail}\n` +
|
|
461
|
+
` Manual recovery:\n` +
|
|
462
|
+
recoveryLines.join("\n") + "\n" +
|
|
463
|
+
` Then: /orch-resume`;
|
|
464
|
+
|
|
465
|
+
return {
|
|
466
|
+
policy: "pause",
|
|
467
|
+
targetPhase: "paused",
|
|
468
|
+
errorMessage,
|
|
469
|
+
persistTrigger: "cleanup_post_merge_failed",
|
|
470
|
+
notifyMessage,
|
|
471
|
+
notifyLevel: "error",
|
|
472
|
+
logDetails: {
|
|
473
|
+
waveNumber: waveNum,
|
|
474
|
+
failedRepoCount,
|
|
475
|
+
totalStaleWorktrees,
|
|
476
|
+
repos,
|
|
477
|
+
},
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// ── Merge Retry Policy (TP-033 Step 2) ───────────────────────────────
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Classify a merge failure into a MergeFailureClassification.
|
|
485
|
+
*
|
|
486
|
+
* Inspects the MergeWaveResult — lane errors, failure reasons, and merge
|
|
487
|
+
* result statuses — to determine which retry policy class applies.
|
|
488
|
+
*
|
|
489
|
+
* Classification priority (first match wins):
|
|
490
|
+
* 1. `verification_new_failure` — any lane error starts with "verification_new_failure"
|
|
491
|
+
* 2. `merge_conflict_unresolved` — any lane result has CONFLICT_UNRESOLVED status
|
|
492
|
+
* 3. `cleanup_post_merge_failed` — failure reason contains "cleanup" or "stale worktree"
|
|
493
|
+
* 4. `git_lock_file` — failure reason contains "lock" or ".lock"
|
|
494
|
+
* 5. `git_worktree_dirty` — failure reason contains "dirty" or "worktree"
|
|
495
|
+
* 6. `null` — unclassifiable (treated as non-retriable by callers)
|
|
496
|
+
*
|
|
497
|
+
* This is a **pure function** — no side effects.
|
|
498
|
+
*
|
|
499
|
+
* @param mergeResult - The failed MergeWaveResult to classify
|
|
500
|
+
* @returns Classification or null if no merge-retry class matches
|
|
501
|
+
* @since TP-033
|
|
502
|
+
*/
|
|
503
|
+
export function classifyMergeFailure(mergeResult: MergeWaveResult): MergeFailureClassification | null {
|
|
504
|
+
// Check lane-level errors first (most specific)
|
|
505
|
+
for (const lr of mergeResult.laneResults) {
|
|
506
|
+
if (lr.error && lr.error.startsWith("verification_new_failure")) {
|
|
507
|
+
return "verification_new_failure";
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Check lane result statuses
|
|
512
|
+
for (const lr of mergeResult.laneResults) {
|
|
513
|
+
if (lr.result?.status === "CONFLICT_UNRESOLVED") {
|
|
514
|
+
return "merge_conflict_unresolved";
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Check failure reason string patterns
|
|
519
|
+
const reason = (mergeResult.failureReason || "").toLowerCase();
|
|
520
|
+
|
|
521
|
+
// Lock file detection: git operations fail with "Unable to create '.../.git/index.lock': File exists"
|
|
522
|
+
if (reason.includes("lock") || reason.includes(".lock")) {
|
|
523
|
+
return "git_lock_file";
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Cleanup failures: stale worktrees or cleanup errors
|
|
527
|
+
if (reason.includes("cleanup") || reason.includes("stale worktree")) {
|
|
528
|
+
return "cleanup_post_merge_failed";
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Dirty worktree: git operations fail due to uncommitted changes
|
|
532
|
+
if (reason.includes("dirty") || reason.includes("worktree")) {
|
|
533
|
+
return "git_worktree_dirty";
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Compute the retry decision for a merge failure.
|
|
541
|
+
*
|
|
542
|
+
* Given the failure classification and the current retry count for the
|
|
543
|
+
* relevant scope, returns a decision indicating whether to retry, the
|
|
544
|
+
* cooldown to wait, or the exhaustion action to take.
|
|
545
|
+
*
|
|
546
|
+
* This is a **pure function** — both engine.ts and resume.ts MUST use
|
|
547
|
+
* this function to guarantee identical retry behavior.
|
|
548
|
+
*
|
|
549
|
+
* @param classification - The classified merge failure (null = unclassifiable)
|
|
550
|
+
* @param currentRetryCount - Current retry attempts for this scope (0 = first failure)
|
|
551
|
+
* @returns Retry decision with all fields populated
|
|
552
|
+
* @since TP-033
|
|
553
|
+
*/
|
|
554
|
+
export function computeMergeRetryDecision(
|
|
555
|
+
classification: MergeFailureClassification | null,
|
|
556
|
+
currentRetryCount: number,
|
|
557
|
+
): MergeRetryDecision {
|
|
558
|
+
// Unclassifiable failures are never retried
|
|
559
|
+
if (classification === null) {
|
|
560
|
+
return {
|
|
561
|
+
shouldRetry: false,
|
|
562
|
+
cooldownMs: 0,
|
|
563
|
+
reason: "Unclassifiable merge failure — no retry policy available",
|
|
564
|
+
currentAttempt: currentRetryCount,
|
|
565
|
+
maxAttempts: 0,
|
|
566
|
+
classification: "merge_conflict_unresolved", // placeholder for type safety
|
|
567
|
+
exhaustionAction: "pause",
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const policy: MergeRetryPolicy = MERGE_RETRY_POLICY_MATRIX[classification];
|
|
572
|
+
|
|
573
|
+
if (!policy.retriable) {
|
|
574
|
+
return {
|
|
575
|
+
shouldRetry: false,
|
|
576
|
+
cooldownMs: 0,
|
|
577
|
+
reason: `${classification} is not retriable — immediate ${policy.exhaustionAction}`,
|
|
578
|
+
currentAttempt: currentRetryCount,
|
|
579
|
+
maxAttempts: 0,
|
|
580
|
+
classification,
|
|
581
|
+
exhaustionAction: policy.exhaustionAction,
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
if (currentRetryCount >= policy.maxAttempts) {
|
|
586
|
+
return {
|
|
587
|
+
shouldRetry: false,
|
|
588
|
+
cooldownMs: 0,
|
|
589
|
+
reason: `${classification} retry exhausted (${currentRetryCount}/${policy.maxAttempts}) — ${policy.exhaustionAction}`,
|
|
590
|
+
currentAttempt: currentRetryCount,
|
|
591
|
+
maxAttempts: policy.maxAttempts,
|
|
592
|
+
classification,
|
|
593
|
+
exhaustionAction: policy.exhaustionAction,
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
return {
|
|
598
|
+
shouldRetry: true,
|
|
599
|
+
cooldownMs: policy.cooldownMs,
|
|
600
|
+
reason: `${classification} retry ${currentRetryCount + 1}/${policy.maxAttempts}` +
|
|
601
|
+
(policy.cooldownMs > 0 ? ` (cooldown: ${policy.cooldownMs}ms)` : ""),
|
|
602
|
+
currentAttempt: currentRetryCount + 1,
|
|
603
|
+
maxAttempts: policy.maxAttempts,
|
|
604
|
+
classification,
|
|
605
|
+
exhaustionAction: policy.exhaustionAction,
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Build the merge retry scope key for persisted retry counters.
|
|
611
|
+
*
|
|
612
|
+
* Format: `{repoId}:w{waveIndex}:l{laneNumber}`
|
|
613
|
+
* - In workspace mode: uses the repo ID (e.g., "api:w0:l1")
|
|
614
|
+
* - In repo mode (repoId undefined/null): uses "default" (e.g., "default:w0:l1")
|
|
615
|
+
*
|
|
616
|
+
* NOTE: This is a different key format from the task-scoped format in v3 types
|
|
617
|
+
* (`{taskId}:w{waveIndex}:l{laneNumber}`). The merge retry scope is intentionally
|
|
618
|
+
* repo-scoped because merge failures are per-repo, not per-task. Both formats
|
|
619
|
+
* coexist in `resilience.retryCountByScope` — the prefix disambiguates them.
|
|
620
|
+
*
|
|
621
|
+
* @param repoId - Repo ID (undefined/null in repo mode)
|
|
622
|
+
* @param waveIndex - 0-based wave index
|
|
623
|
+
* @param laneNumber - Lane number
|
|
624
|
+
* @returns Scope key string
|
|
625
|
+
* @since TP-033
|
|
626
|
+
*/
|
|
627
|
+
export function buildMergeRetryScopeKey(
|
|
628
|
+
repoId: string | undefined | null,
|
|
629
|
+
waveIndex: number,
|
|
630
|
+
laneNumber: number,
|
|
631
|
+
): string {
|
|
632
|
+
const repo = repoId ?? "default";
|
|
633
|
+
return `${repo}:w${waveIndex}:l${laneNumber}`;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Extract the repo ID for a failed merge from the MergeWaveResult.
|
|
638
|
+
*
|
|
639
|
+
* Priority:
|
|
640
|
+
* 1. Lane-level: find the failed lane result and use its repoId
|
|
641
|
+
* 2. Repo-level: when failedLane is null (setup failure), check repoResults
|
|
642
|
+
* for the first failed repo group
|
|
643
|
+
* 3. Fallback: undefined (will become "default" in scope key)
|
|
644
|
+
*
|
|
645
|
+
* This ensures workspace-mode setup failures (e.g., worktree dirty before
|
|
646
|
+
* any lane starts) still get repo-scoped counters rather than all collapsing
|
|
647
|
+
* into "default:w{N}:l0".
|
|
648
|
+
*
|
|
649
|
+
* @param mergeResult - The failed MergeWaveResult
|
|
650
|
+
* @returns Repo ID or undefined if not determinable
|
|
651
|
+
* @since TP-033 R006
|
|
652
|
+
*/
|
|
653
|
+
export function extractFailedRepoId(mergeResult: MergeWaveResult): string | undefined {
|
|
654
|
+
const failedLaneNum = mergeResult.failedLane;
|
|
655
|
+
|
|
656
|
+
// 1. Try lane-level extraction
|
|
657
|
+
if (failedLaneNum !== null && failedLaneNum !== undefined) {
|
|
658
|
+
const failedLaneResult = mergeResult.laneResults.find(
|
|
659
|
+
lr => lr.laneNumber === failedLaneNum &&
|
|
660
|
+
(lr.error || lr.result?.status === "CONFLICT_UNRESOLVED" || lr.result?.status === "BUILD_FAILURE"),
|
|
661
|
+
);
|
|
662
|
+
if (failedLaneResult?.repoId) return failedLaneResult.repoId;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// 2. Repo-level fallback for setup failures (failedLane === null)
|
|
666
|
+
if (mergeResult.repoResults && mergeResult.repoResults.length > 0) {
|
|
667
|
+
const failedRepo = mergeResult.repoResults.find(
|
|
668
|
+
rr => rr.status === "failed" || rr.status === "partial",
|
|
669
|
+
);
|
|
670
|
+
if (failedRepo?.repoId) return failedRepo.repoId;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// 3. If failureReason mentions a specific repo path, we could parse it,
|
|
674
|
+
// but that's fragile. Return undefined → "default" in scope key.
|
|
675
|
+
return undefined;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Shared merge retry loop used by both engine.ts and resume.ts.
|
|
680
|
+
*
|
|
681
|
+
* Wraps the retry cycle in a loop: after each failed retry, re-classifies
|
|
682
|
+
* the latest mergeResult, recomputes the retry decision using the persisted
|
|
683
|
+
* counter, and continues until success, safe-stop, or exhaustion/non-retriable.
|
|
684
|
+
*
|
|
685
|
+
* This is the **single implementation** of retry loop semantics.
|
|
686
|
+
* Engine.ts and resume.ts provide callbacks for their specific side effects
|
|
687
|
+
* (persistence, merge invocation, notification) to guarantee parity.
|
|
688
|
+
*
|
|
689
|
+
* **Important:** On retry exhaustion, this returns `kind: "exhausted"` which
|
|
690
|
+
* the caller MUST handle by forcing `paused` phase regardless of
|
|
691
|
+
* `on_merge_failure` config. The exhaustion action from the matrix takes
|
|
692
|
+
* precedence over config policy.
|
|
693
|
+
*
|
|
694
|
+
* @param mergeResult - The initial failed merge result
|
|
695
|
+
* @param waveIdx - 0-based wave index (for logging)
|
|
696
|
+
* @param retryCountByScope - Mutable reference to persisted retry counters
|
|
697
|
+
* @param callbacks - Side-effect callbacks for persistence/merge/logging
|
|
698
|
+
* @returns Outcome describing what happened during the retry cycle
|
|
699
|
+
* @since TP-033 R006
|
|
700
|
+
*/
|
|
701
|
+
export function applyMergeRetryLoop(
|
|
702
|
+
mergeResult: MergeWaveResult,
|
|
703
|
+
waveIdx: number,
|
|
704
|
+
retryCountByScope: Record<string, number>,
|
|
705
|
+
callbacks: MergeRetryCallbacks,
|
|
706
|
+
): MergeRetryLoopOutcome {
|
|
707
|
+
let currentResult = mergeResult;
|
|
708
|
+
|
|
709
|
+
// Classify the initial failure
|
|
710
|
+
let classification = classifyMergeFailure(currentResult);
|
|
711
|
+
const failedRepoId = extractFailedRepoId(currentResult);
|
|
712
|
+
const failedLaneNum = currentResult.failedLane ?? 0;
|
|
713
|
+
const scopeKey = buildMergeRetryScopeKey(failedRepoId, waveIdx, failedLaneNum);
|
|
714
|
+
const currentRetryCount = retryCountByScope[scopeKey] ?? 0;
|
|
715
|
+
|
|
716
|
+
// Check if any retry is possible at all
|
|
717
|
+
const initialDecision = computeMergeRetryDecision(classification, currentRetryCount);
|
|
718
|
+
|
|
719
|
+
if (!initialDecision.shouldRetry) {
|
|
720
|
+
// Non-retriable or already exhausted before we start
|
|
721
|
+
if (classification !== null && initialDecision.currentAttempt > 0) {
|
|
722
|
+
// Previously had retries — this is exhaustion
|
|
723
|
+
return {
|
|
724
|
+
kind: "exhausted",
|
|
725
|
+
mergeResult: currentResult,
|
|
726
|
+
classification,
|
|
727
|
+
scopeKey,
|
|
728
|
+
lastDecision: initialDecision,
|
|
729
|
+
errorMessage: `Merge retry exhausted at wave ${waveIdx + 1}: ${initialDecision.reason}`,
|
|
730
|
+
notifyMessage: `⏸️ Merge retry exhausted at wave ${waveIdx + 1}. ${initialDecision.reason}`,
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
// No retry was ever possible
|
|
734
|
+
return {
|
|
735
|
+
kind: "no_retry",
|
|
736
|
+
mergeResult: currentResult,
|
|
737
|
+
classification,
|
|
738
|
+
scopeKey,
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// Enter retry loop
|
|
743
|
+
let lastDecision = initialDecision;
|
|
744
|
+
|
|
745
|
+
while (lastDecision.shouldRetry) {
|
|
746
|
+
// Increment counter in persisted state
|
|
747
|
+
retryCountByScope[scopeKey] = lastDecision.currentAttempt;
|
|
748
|
+
|
|
749
|
+
callbacks.log(`merge retry: ${lastDecision.reason}`, {
|
|
750
|
+
classification,
|
|
751
|
+
scopeKey,
|
|
752
|
+
attempt: lastDecision.currentAttempt,
|
|
753
|
+
maxAttempts: lastDecision.maxAttempts,
|
|
754
|
+
cooldownMs: lastDecision.cooldownMs,
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
callbacks.persist("merge-retry-increment");
|
|
758
|
+
callbacks.notify(
|
|
759
|
+
`🔄 Merge retry (${lastDecision.reason}) at wave ${waveIdx + 1}. ` +
|
|
760
|
+
(lastDecision.cooldownMs > 0 ? `Waiting ${lastDecision.cooldownMs}ms before retry...` : "Retrying immediately..."),
|
|
761
|
+
"warning",
|
|
762
|
+
);
|
|
763
|
+
|
|
764
|
+
if (lastDecision.cooldownMs > 0) {
|
|
765
|
+
callbacks.sleep(lastDecision.cooldownMs);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// Re-invoke merge
|
|
769
|
+
callbacks.persist("merge-retry-start");
|
|
770
|
+
currentResult = callbacks.performMerge();
|
|
771
|
+
callbacks.updateMergeResult(currentResult);
|
|
772
|
+
callbacks.persist("merge-retry-complete");
|
|
773
|
+
|
|
774
|
+
// Check outcome
|
|
775
|
+
if (currentResult.status === "succeeded") {
|
|
776
|
+
callbacks.notify(`✅ Merge retry succeeded at wave ${waveIdx + 1}.`, "info");
|
|
777
|
+
return {
|
|
778
|
+
kind: "retry_succeeded",
|
|
779
|
+
mergeResult: currentResult,
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
if (currentResult.rollbackFailed) {
|
|
784
|
+
// Safe-stop takes priority
|
|
785
|
+
const hasPersistErrors = currentResult.persistenceErrors && currentResult.persistenceErrors.length > 0;
|
|
786
|
+
const persistWarning = hasPersistErrors
|
|
787
|
+
? ` WARNING: ${currentResult.persistenceErrors!.length} transaction record(s) failed to persist.`
|
|
788
|
+
: "";
|
|
789
|
+
|
|
790
|
+
return {
|
|
791
|
+
kind: "safe_stop",
|
|
792
|
+
mergeResult: currentResult,
|
|
793
|
+
errorMessage:
|
|
794
|
+
`Safe-stop at wave ${waveIdx + 1}: verification rollback failed after retry. ` +
|
|
795
|
+
`Merge worktree and temp branch preserved for recovery.` + persistWarning,
|
|
796
|
+
notifyMessage:
|
|
797
|
+
`🛑 Safe-stop: verification rollback failed at wave ${waveIdx + 1} after retry. ` +
|
|
798
|
+
`Batch force-paused.` + persistWarning,
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// Retry failed — re-classify and check if we can retry again
|
|
803
|
+
classification = classifyMergeFailure(currentResult);
|
|
804
|
+
const updatedCount = retryCountByScope[scopeKey] ?? 0;
|
|
805
|
+
lastDecision = computeMergeRetryDecision(classification, updatedCount);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// Loop ended: exhaustion
|
|
809
|
+
return {
|
|
810
|
+
kind: "exhausted",
|
|
811
|
+
mergeResult: currentResult,
|
|
812
|
+
classification,
|
|
813
|
+
scopeKey,
|
|
814
|
+
lastDecision,
|
|
815
|
+
errorMessage: `Merge retry exhausted at wave ${waveIdx + 1}: ${lastDecision.reason}`,
|
|
816
|
+
notifyMessage: `⏸️ Merge retry exhausted at wave ${waveIdx + 1}. ${lastDecision.reason}`,
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// ── Integrate Cleanup Acceptance (TP-029 Step 3) ─────────────────────
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Per-repo acceptance check findings after /orch-integrate.
|
|
824
|
+
* Collected by scanning all workspace repos (not just repos that had the orch branch).
|
|
825
|
+
*/
|
|
826
|
+
export interface IntegrateCleanupRepoFindings {
|
|
827
|
+
/** Repo root path */
|
|
828
|
+
repoRoot: string;
|
|
829
|
+
/** Repo ID (undefined for repo-mode / primary) */
|
|
830
|
+
repoId: string | undefined;
|
|
831
|
+
/** Stale lane worktrees still registered (git worktree list matches) */
|
|
832
|
+
staleWorktrees: string[];
|
|
833
|
+
/** Stale lane branches (task/{opId}-lane-*) */
|
|
834
|
+
staleLaneBranches: string[];
|
|
835
|
+
/** Stale orch branches (orch/{opId}-{batchId}) */
|
|
836
|
+
staleOrchBranches: string[];
|
|
837
|
+
/** Batch-scoped autostash entries still present */
|
|
838
|
+
staleAutostashEntries: string[];
|
|
839
|
+
/** Non-empty .worktrees/ containers */
|
|
840
|
+
nonEmptyWorktreeContainers: string[];
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* Result of the /orch-integrate cleanup acceptance check.
|
|
845
|
+
* Pure function output — callers use this to format the summary notification.
|
|
846
|
+
*/
|
|
847
|
+
export interface IntegrateCleanupResult {
|
|
848
|
+
/** True if all repos pass all acceptance criteria */
|
|
849
|
+
clean: boolean;
|
|
850
|
+
/** Notification severity level: "info" when clean, "warning" when dirty */
|
|
851
|
+
notifyLevel: "info" | "warning";
|
|
852
|
+
/** Per-repo findings (only repos with at least one finding) */
|
|
853
|
+
dirtyRepos: IntegrateCleanupRepoFindings[];
|
|
854
|
+
/** User-facing cleanup report (appended to integrate summary) */
|
|
855
|
+
report: string;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Compute the integrate cleanup result from per-repo acceptance findings.
|
|
860
|
+
*
|
|
861
|
+
* This is a **pure function** — computes all outputs deterministically
|
|
862
|
+
* from the per-repo findings without side effects.
|
|
863
|
+
*
|
|
864
|
+
* The acceptance criteria (roadmap 2d) are:
|
|
865
|
+
* 1. No registered lane worktrees remain in any workspace repo
|
|
866
|
+
* 2. No lane branches remain (task/{opId}-lane-*)
|
|
867
|
+
* 3. No orch branches remain (orch/{opId}-{batchId})
|
|
868
|
+
* 4. No stale autostash from current batch remains
|
|
869
|
+
* 5. No non-empty .worktrees/ containers remain
|
|
870
|
+
*
|
|
871
|
+
* @param repoFindings - Per-repo findings from scanning all workspace repos
|
|
872
|
+
* @returns Cleanup result with pass/fail verdict and human-readable report
|
|
873
|
+
*/
|
|
874
|
+
export function computeIntegrateCleanupResult(
|
|
875
|
+
repoFindings: IntegrateCleanupRepoFindings[],
|
|
876
|
+
): IntegrateCleanupResult {
|
|
877
|
+
// Filter to repos that have at least one issue
|
|
878
|
+
const dirtyRepos = repoFindings.filter(r =>
|
|
879
|
+
r.staleWorktrees.length > 0 ||
|
|
880
|
+
r.staleLaneBranches.length > 0 ||
|
|
881
|
+
r.staleOrchBranches.length > 0 ||
|
|
882
|
+
r.staleAutostashEntries.length > 0 ||
|
|
883
|
+
r.nonEmptyWorktreeContainers.length > 0,
|
|
884
|
+
);
|
|
885
|
+
|
|
886
|
+
if (dirtyRepos.length === 0) {
|
|
887
|
+
return {
|
|
888
|
+
clean: true,
|
|
889
|
+
notifyLevel: "info",
|
|
890
|
+
dirtyRepos: [],
|
|
891
|
+
report: "🧹 Cleanup verified: no stale worktrees, branches, or autostash entries remain.",
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// Build per-repo detail lines
|
|
896
|
+
const details: string[] = [];
|
|
897
|
+
for (const repo of dirtyRepos) {
|
|
898
|
+
const label = repo.repoId ?? "(default)";
|
|
899
|
+
const issues: string[] = [];
|
|
900
|
+
if (repo.staleWorktrees.length > 0) {
|
|
901
|
+
issues.push(`${repo.staleWorktrees.length} stale worktree(s)`);
|
|
902
|
+
}
|
|
903
|
+
if (repo.staleLaneBranches.length > 0) {
|
|
904
|
+
issues.push(`${repo.staleLaneBranches.length} lane branch(es)`);
|
|
905
|
+
}
|
|
906
|
+
if (repo.staleOrchBranches.length > 0) {
|
|
907
|
+
issues.push(`${repo.staleOrchBranches.length} orch branch(es)`);
|
|
908
|
+
}
|
|
909
|
+
if (repo.staleAutostashEntries.length > 0) {
|
|
910
|
+
issues.push(`${repo.staleAutostashEntries.length} autostash entr(ies)`);
|
|
911
|
+
}
|
|
912
|
+
if (repo.nonEmptyWorktreeContainers.length > 0) {
|
|
913
|
+
issues.push(`${repo.nonEmptyWorktreeContainers.length} non-empty .worktrees/ container(s)`);
|
|
914
|
+
}
|
|
915
|
+
details.push(` ${label}: ${issues.join(", ")}`);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// Build recovery commands
|
|
919
|
+
const recovery: string[] = [];
|
|
920
|
+
for (const repo of dirtyRepos) {
|
|
921
|
+
const label = repo.repoId ?? "default";
|
|
922
|
+
for (const wt of repo.staleWorktrees) {
|
|
923
|
+
recovery.push(` git worktree remove --force "${wt}" # repo: ${label}`);
|
|
924
|
+
}
|
|
925
|
+
for (const br of repo.staleLaneBranches) {
|
|
926
|
+
recovery.push(` git branch -D "${br}" # repo: ${label}`);
|
|
927
|
+
}
|
|
928
|
+
for (const br of repo.staleOrchBranches) {
|
|
929
|
+
recovery.push(` git branch -D "${br}" # repo: ${label}`);
|
|
930
|
+
}
|
|
931
|
+
for (const entry of repo.staleAutostashEntries) {
|
|
932
|
+
recovery.push(` git stash drop "${entry}" # repo: ${label}`);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
const report =
|
|
937
|
+
`⚠️ Cleanup incomplete — residual artifacts found:\n` +
|
|
938
|
+
details.join("\n") +
|
|
939
|
+
(recovery.length > 0 ? `\n Manual cleanup:\n${recovery.join("\n")}` : "");
|
|
940
|
+
|
|
941
|
+
return {
|
|
942
|
+
clean: false,
|
|
943
|
+
notifyLevel: "warning",
|
|
944
|
+
dirtyRepos,
|
|
945
|
+
report,
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
|
|
357
949
|
// ── Resume ORCH_MESSAGES ─────────────────────────────────────────────
|
|
358
950
|
|
|
359
951
|
// Note: These are added via extension to the ORCH_MESSAGES object below.
|