taskplane 0.4.3 → 0.5.1
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 +10 -5
- package/extensions/taskplane/engine.ts +79 -15
- package/extensions/taskplane/execution.ts +48 -1
- package/extensions/taskplane/extension.ts +562 -2
- package/extensions/taskplane/merge.ts +182 -27
- package/extensions/taskplane/messages.ts +30 -1
- package/extensions/taskplane/resume.ts +128 -11
- package/extensions/taskplane/waves.ts +16 -2
- package/extensions/taskplane/worktree.ts +3 -3
- package/package.json +1 -1
|
@@ -11,7 +11,9 @@ import { resolveOperatorId } from "./naming.ts";
|
|
|
11
11
|
import { MERGE_POLL_INTERVAL_MS, MERGE_RESULT_GRACE_MS, MERGE_RESULT_READ_RETRIES, MERGE_RESULT_READ_RETRY_DELAY_MS, MERGE_SPAWN_RETRY_MAX, MERGE_TIMEOUT_MS, MergeError, VALID_MERGE_STATUSES } from "./types.ts";
|
|
12
12
|
import type { AllocatedLane, LaneExecutionResult, MergeLaneResult, MergeResult, MergeResultStatus, MergeWaveResult, OrchestratorConfig, RepoMergeOutcome, WaveExecutionResult, WorkspaceConfig } from "./types.ts";
|
|
13
13
|
import { resolveBaseBranch, resolveRepoRoot } from "./waves.ts";
|
|
14
|
-
import { sleepSync } from "./worktree.ts";
|
|
14
|
+
import { generateMergeWorktreePath, sleepSync } from "./worktree.ts";
|
|
15
|
+
import { getCurrentBranch, runGit } from "./git.ts";
|
|
16
|
+
import { ORCH_MESSAGES } from "./messages.ts";
|
|
15
17
|
|
|
16
18
|
// ── Merge Implementation ─────────────────────────────────────────────
|
|
17
19
|
|
|
@@ -567,9 +569,10 @@ export function mergeWave(
|
|
|
567
569
|
// ── Create isolated merge worktree ──────────────────────────────
|
|
568
570
|
// Merging in a dedicated worktree prevents dirty-worktree failures
|
|
569
571
|
// caused by user edits or orchestrator-generated files in the main repo.
|
|
570
|
-
//
|
|
572
|
+
// The merge worktree lives inside the batch container alongside lane worktrees:
|
|
573
|
+
// {basePath}/{opId}-{batchId}/merge
|
|
571
574
|
const tempBranch = `_merge-temp-${opId}-${batchId}`;
|
|
572
|
-
const mergeWorkDir =
|
|
575
|
+
const mergeWorkDir = generateMergeWorktreePath(repoRoot, opId, batchId, config);
|
|
573
576
|
|
|
574
577
|
// Clean up stale merge worktree/branch from prior failed attempt
|
|
575
578
|
try {
|
|
@@ -752,37 +755,87 @@ export function mergeWave(
|
|
|
752
755
|
}
|
|
753
756
|
}
|
|
754
757
|
|
|
755
|
-
// ──
|
|
758
|
+
// ── Update target branch ref and clean up merge worktree ────────
|
|
756
759
|
const anySuccess = laneResults.some(
|
|
757
760
|
r => r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED",
|
|
758
761
|
);
|
|
759
762
|
|
|
760
763
|
if (anySuccess) {
|
|
761
|
-
//
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
//
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
764
|
+
// Get the temp branch HEAD commit — this is the merged result.
|
|
765
|
+
const revParseResult = spawnSync("git", ["rev-parse", tempBranch], { cwd: repoRoot });
|
|
766
|
+
|
|
767
|
+
if (revParseResult.status !== 0) {
|
|
768
|
+
const err = revParseResult.stderr?.toString().trim() || "unknown error";
|
|
769
|
+
execLog("merge", `W${waveIndex}`, `failed to resolve temp branch HEAD: ${err}`, { tempBranch });
|
|
770
|
+
failedLane = failedLane ?? -1;
|
|
771
|
+
failureReason = `Failed to resolve merge temp branch HEAD (${tempBranch}): ${err}`;
|
|
772
|
+
} else {
|
|
773
|
+
const tempBranchHead = revParseResult.stdout.toString().trim();
|
|
774
|
+
|
|
775
|
+
// Gate advancement strategy:
|
|
776
|
+
// - If targetBranch is NOT checked out in repoRoot, use update-ref
|
|
777
|
+
// (safe, does not touch the working tree). This is the common case
|
|
778
|
+
// for the orch branch in repo mode.
|
|
779
|
+
// - If targetBranch IS checked out in repoRoot (workspace mode, where
|
|
780
|
+
// resolveBaseBranch returns the repo's current branch), use
|
|
781
|
+
// git merge --ff-only to advance HEAD+index+worktree together.
|
|
782
|
+
const checkedOutBranch = getCurrentBranch(repoRoot);
|
|
783
|
+
const targetIsCheckedOut = checkedOutBranch === targetBranch;
|
|
784
|
+
|
|
785
|
+
if (targetIsCheckedOut) {
|
|
786
|
+
// Checked-out branch — must use ff-only to keep HEAD/index/worktree in sync.
|
|
787
|
+
// Dirty working tree may block ff — stash if needed.
|
|
788
|
+
const ffResult = spawnSync("git", ["merge", "--ff-only", tempBranch], { cwd: repoRoot });
|
|
789
|
+
|
|
790
|
+
if (ffResult.status !== 0) {
|
|
791
|
+
// Dirty working tree may block ff — try stash + ff + pop
|
|
792
|
+
execLog("merge", `W${waveIndex}`, "fast-forward blocked — stashing user changes");
|
|
793
|
+
const stashMsg = `merge-agent-autostash-w${waveIndex}-${batchId}`;
|
|
794
|
+
spawnSync("git", ["stash", "push", "--include-untracked", "-m", stashMsg], { cwd: repoRoot });
|
|
795
|
+
|
|
796
|
+
const ffRetry = spawnSync("git", ["merge", "--ff-only", tempBranch], { cwd: repoRoot });
|
|
797
|
+
|
|
798
|
+
// Always pop stash, regardless of ff result
|
|
799
|
+
spawnSync("git", ["stash", "pop"], { cwd: repoRoot });
|
|
800
|
+
|
|
801
|
+
if (ffRetry.status !== 0) {
|
|
802
|
+
const err = ffRetry.stderr?.toString().trim() || "unknown error";
|
|
803
|
+
execLog("merge", `W${waveIndex}`, `fast-forward failed even after stash: ${err}`);
|
|
804
|
+
failedLane = failedLane ?? -1;
|
|
805
|
+
failureReason = `Fast-forward of ${targetBranch} failed: ${err}`;
|
|
806
|
+
} else {
|
|
807
|
+
execLog("merge", `W${waveIndex}`, "fast-forward succeeded after stash/pop");
|
|
808
|
+
}
|
|
809
|
+
} else {
|
|
810
|
+
execLog("merge", `W${waveIndex}`, `fast-forwarded ${targetBranch} to merge result`);
|
|
811
|
+
}
|
|
781
812
|
} else {
|
|
782
|
-
|
|
813
|
+
// Not checked out — safe to use update-ref without touching the worktree.
|
|
814
|
+
// Use compare-and-swap (3-arg form) to guard against concurrent branch movement.
|
|
815
|
+
const oldRefResult = spawnSync("git", ["rev-parse", `refs/heads/${targetBranch}`], { cwd: repoRoot });
|
|
816
|
+
const oldRef = oldRefResult.status === 0 ? oldRefResult.stdout.toString().trim() : "";
|
|
817
|
+
|
|
818
|
+
const updateRefArgs = oldRef
|
|
819
|
+
? ["update-ref", `refs/heads/${targetBranch}`, tempBranchHead, oldRef]
|
|
820
|
+
: ["update-ref", `refs/heads/${targetBranch}`, tempBranchHead];
|
|
821
|
+
|
|
822
|
+
const updateRefResult = spawnSync("git", updateRefArgs, { cwd: repoRoot });
|
|
823
|
+
|
|
824
|
+
if (updateRefResult.status !== 0) {
|
|
825
|
+
const err = updateRefResult.stderr?.toString().trim() || "unknown error";
|
|
826
|
+
execLog("merge", `W${waveIndex}`, `update-ref failed for ${targetBranch}: ${err}`, {
|
|
827
|
+
targetBranch,
|
|
828
|
+
tempBranchHead: tempBranchHead.slice(0, 8),
|
|
829
|
+
});
|
|
830
|
+
failedLane = failedLane ?? -1;
|
|
831
|
+
failureReason = `update-ref of ${targetBranch} to ${tempBranchHead.slice(0, 8)} failed: ${err}`;
|
|
832
|
+
} else {
|
|
833
|
+
execLog("merge", `W${waveIndex}`, `updated ${targetBranch} ref to merge result`, {
|
|
834
|
+
targetBranch,
|
|
835
|
+
commit: tempBranchHead.slice(0, 8),
|
|
836
|
+
});
|
|
837
|
+
}
|
|
783
838
|
}
|
|
784
|
-
} else {
|
|
785
|
-
execLog("merge", `W${waveIndex}`, `fast-forwarded ${targetBranch} to merge result`);
|
|
786
839
|
}
|
|
787
840
|
}
|
|
788
841
|
|
|
@@ -1068,3 +1121,105 @@ export function mergeWaveByRepo(
|
|
|
1068
1121
|
};
|
|
1069
1122
|
}
|
|
1070
1123
|
|
|
1124
|
+
// ── Auto-Integration ─────────────────────────────────────────────────
|
|
1125
|
+
|
|
1126
|
+
/**
|
|
1127
|
+
* Attempt to fast-forward baseBranch to orchBranch in the main repo.
|
|
1128
|
+
*
|
|
1129
|
+
* Shared by engine.ts (fresh batch) and resume.ts (resumed batch).
|
|
1130
|
+
* The `logCategory` parameter distinguishes the calling context in execLog.
|
|
1131
|
+
*
|
|
1132
|
+
* Failure matrix — all failures are warnings, never batch-fatal:
|
|
1133
|
+
* - **Diverged**: baseBranch has commits not in orchBranch (not fast-forwardable)
|
|
1134
|
+
* - **Detached HEAD / missing base**: baseBranch not resolvable
|
|
1135
|
+
* - **Dirty worktree**: baseBranch is checked out with uncommitted changes
|
|
1136
|
+
* - **Branch not checked out**: baseBranch is not the current branch;
|
|
1137
|
+
* use update-ref (no worktree impact) with compare-and-swap
|
|
1138
|
+
*
|
|
1139
|
+
* @param orchBranch - The orch branch to integrate from
|
|
1140
|
+
* @param baseBranch - The user's branch to advance
|
|
1141
|
+
* @param repoRoot - Absolute path to the primary repo root
|
|
1142
|
+
* @param batchId - Batch identifier for logging
|
|
1143
|
+
* @param logCategory - execLog category ("batch" for engine, "resume" for resume)
|
|
1144
|
+
* @param onNotify - Notification callback
|
|
1145
|
+
* @returns true if integration succeeded, false otherwise
|
|
1146
|
+
*/
|
|
1147
|
+
export function attemptAutoIntegration(
|
|
1148
|
+
orchBranch: string,
|
|
1149
|
+
baseBranch: string,
|
|
1150
|
+
repoRoot: string,
|
|
1151
|
+
batchId: string,
|
|
1152
|
+
logCategory: string,
|
|
1153
|
+
onNotify: (message: string, level: "info" | "warning" | "error") => void,
|
|
1154
|
+
): boolean {
|
|
1155
|
+
// 1. Verify orchBranch exists
|
|
1156
|
+
const orchExists = runGit(["rev-parse", "--verify", `refs/heads/${orchBranch}`], repoRoot);
|
|
1157
|
+
if (!orchExists.ok) {
|
|
1158
|
+
const reason = `orch branch '${orchBranch}' not found`;
|
|
1159
|
+
execLog(logCategory, batchId, `auto-integration skipped: ${reason}`);
|
|
1160
|
+
onNotify(ORCH_MESSAGES.orchIntegrationAutoFailed(orchBranch, baseBranch, reason), "warning");
|
|
1161
|
+
return false;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// 2. Verify baseBranch exists
|
|
1165
|
+
const baseExists = runGit(["rev-parse", "--verify", `refs/heads/${baseBranch}`], repoRoot);
|
|
1166
|
+
if (!baseExists.ok) {
|
|
1167
|
+
const reason = `base branch '${baseBranch}' not found`;
|
|
1168
|
+
execLog(logCategory, batchId, `auto-integration skipped: ${reason}`);
|
|
1169
|
+
onNotify(ORCH_MESSAGES.orchIntegrationAutoFailed(orchBranch, baseBranch, reason), "warning");
|
|
1170
|
+
return false;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// 3. Check fast-forwardability: baseBranch must be an ancestor of orchBranch
|
|
1174
|
+
const isAncestor = runGit(["merge-base", "--is-ancestor", baseBranch, orchBranch], repoRoot);
|
|
1175
|
+
if (!isAncestor.ok) {
|
|
1176
|
+
const reason = `branches have diverged (${baseBranch} is not an ancestor of ${orchBranch})`;
|
|
1177
|
+
execLog(logCategory, batchId, `auto-integration skipped: ${reason}`);
|
|
1178
|
+
onNotify(ORCH_MESSAGES.orchIntegrationAutoFailed(orchBranch, baseBranch, reason), "warning");
|
|
1179
|
+
return false;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// 4. Gate on whether baseBranch is checked out (same pattern as merge advancement)
|
|
1183
|
+
const checkedOutBranch = getCurrentBranch(repoRoot);
|
|
1184
|
+
const baseIsCheckedOut = checkedOutBranch === baseBranch;
|
|
1185
|
+
|
|
1186
|
+
const orchHead = runGit(["rev-parse", orchBranch], repoRoot).stdout.trim();
|
|
1187
|
+
|
|
1188
|
+
if (baseIsCheckedOut) {
|
|
1189
|
+
// baseBranch is checked out — use merge --ff-only (updates worktree)
|
|
1190
|
+
// Check for dirty worktree first
|
|
1191
|
+
const statusCheck = runGit(["status", "--porcelain"], repoRoot);
|
|
1192
|
+
if (statusCheck.ok && statusCheck.stdout.trim()) {
|
|
1193
|
+
const reason = `working tree is dirty (${baseBranch} is checked out with uncommitted changes)`;
|
|
1194
|
+
execLog(logCategory, batchId, `auto-integration skipped: ${reason}`);
|
|
1195
|
+
onNotify(ORCH_MESSAGES.orchIntegrationAutoFailed(orchBranch, baseBranch, reason), "warning");
|
|
1196
|
+
return false;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
const ffResult = runGit(["merge", "--ff-only", orchBranch], repoRoot);
|
|
1200
|
+
if (!ffResult.ok) {
|
|
1201
|
+
const reason = `fast-forward failed: ${ffResult.stderr || ffResult.stdout || "unknown"}`;
|
|
1202
|
+
execLog(logCategory, batchId, `auto-integration failed: ${reason}`);
|
|
1203
|
+
onNotify(ORCH_MESSAGES.orchIntegrationAutoFailed(orchBranch, baseBranch, reason), "warning");
|
|
1204
|
+
return false;
|
|
1205
|
+
}
|
|
1206
|
+
} else {
|
|
1207
|
+
// baseBranch is NOT checked out — use update-ref with compare-and-swap
|
|
1208
|
+
const baseOldRef = runGit(["rev-parse", baseBranch], repoRoot).stdout.trim();
|
|
1209
|
+
const updateResult = runGit(
|
|
1210
|
+
["update-ref", `refs/heads/${baseBranch}`, orchHead, baseOldRef],
|
|
1211
|
+
repoRoot,
|
|
1212
|
+
);
|
|
1213
|
+
if (!updateResult.ok) {
|
|
1214
|
+
const reason = `update-ref failed: ${updateResult.stderr || updateResult.stdout || "unknown"}`;
|
|
1215
|
+
execLog(logCategory, batchId, `auto-integration failed: ${reason}`);
|
|
1216
|
+
onNotify(ORCH_MESSAGES.orchIntegrationAutoFailed(orchBranch, baseBranch, reason), "warning");
|
|
1217
|
+
return false;
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
execLog(logCategory, batchId, `auto-integrated: ${baseBranch} advanced to ${orchBranch}`, { orchHead });
|
|
1222
|
+
onNotify(ORCH_MESSAGES.orchIntegrationAutoSuccess(orchBranch, baseBranch), "info");
|
|
1223
|
+
return true;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
@@ -36,7 +36,7 @@ export const ORCH_MESSAGES = {
|
|
|
36
36
|
`🔀 [Wave ${waveNum}] Merge: placeholder — Step 3 (TS-008) will replace with mergeWave()`,
|
|
37
37
|
orchWorktreeReset: (waveNum: number, lanes: number) =>
|
|
38
38
|
`🔄 Resetting ${lanes} worktree(s) to target branch HEAD after wave ${waveNum}`,
|
|
39
|
-
orchBatchComplete: (batchId: string, succeeded: number, failed: number, skipped: number, blocked: number, elapsedSec: number) => {
|
|
39
|
+
orchBatchComplete: (batchId: string, succeeded: number, failed: number, skipped: number, blocked: number, elapsedSec: number, orchBranch?: string, baseBranch?: string) => {
|
|
40
40
|
const lines = [`\n🏁 Batch ${batchId} complete: ${succeeded} succeeded, ${failed} failed, ${skipped} skipped, ${blocked} blocked (${elapsedSec}s)`];
|
|
41
41
|
if (failed > 0 || blocked > 0) {
|
|
42
42
|
lines.push("");
|
|
@@ -48,6 +48,17 @@ export const ORCH_MESSAGES = {
|
|
|
48
48
|
lines.push(" • /orch-resume — retry from the failed wave");
|
|
49
49
|
lines.push(" • /orch-abort — clean up and start fresh");
|
|
50
50
|
}
|
|
51
|
+
if (orchBranch && succeeded > 0) {
|
|
52
|
+
lines.push("");
|
|
53
|
+
lines.push(` ℹ Orch branch: ${orchBranch}`);
|
|
54
|
+
if (baseBranch) {
|
|
55
|
+
lines.push(` Review changes: git log ${baseBranch}..${orchBranch}`);
|
|
56
|
+
}
|
|
57
|
+
lines.push(" Next steps:");
|
|
58
|
+
lines.push(" • /orch-integrate — fast-forward into your branch");
|
|
59
|
+
lines.push(" • /orch-integrate --merge — merge (if branches diverged)");
|
|
60
|
+
lines.push(" • /orch-integrate --pr — push and open a PR");
|
|
61
|
+
}
|
|
51
62
|
return lines.join("\n");
|
|
52
63
|
},
|
|
53
64
|
orchBatchFailed: (batchId: string, reason: string) =>
|
|
@@ -120,6 +131,24 @@ export const ORCH_MESSAGES = {
|
|
|
120
131
|
// /orch merge — repo-scoped partial summary (TP-005 Step 1)
|
|
121
132
|
orchMergePartialRepoSummary: (waveNum: number, repoLines: string[]) =>
|
|
122
133
|
`⚠️ [Wave ${waveNum}] Merge partially succeeded — repo outcomes diverged:\n${repoLines.join("\n")}`,
|
|
134
|
+
|
|
135
|
+
// /orch integration — post-batch integration guidance (TP-022 Step 4)
|
|
136
|
+
orchIntegrationAutoSuccess: (orchBranch: string, baseBranch: string) =>
|
|
137
|
+
`✅ Auto-integrated: ${baseBranch} fast-forwarded to ${orchBranch}.`,
|
|
138
|
+
orchIntegrationAutoFailed: (orchBranch: string, baseBranch: string, reason: string) =>
|
|
139
|
+
`⚠️ Auto-integration skipped: ${reason}\n` +
|
|
140
|
+
` Orch branch ${orchBranch} preserved. Integrate manually:\n` +
|
|
141
|
+
` git log ${baseBranch}..${orchBranch}\n` +
|
|
142
|
+
` git merge ${orchBranch}`,
|
|
143
|
+
orchIntegrationManual: (orchBranch: string, baseBranch: string, mergedTaskCount: number) => {
|
|
144
|
+
const lines = [
|
|
145
|
+
`ℹ️ Batch complete. Orch branch ${orchBranch} has ${mergedTaskCount} merged task(s).`,
|
|
146
|
+
` Review and integrate:`,
|
|
147
|
+
` git log ${baseBranch}..${orchBranch}`,
|
|
148
|
+
` git merge ${orchBranch}`,
|
|
149
|
+
];
|
|
150
|
+
return lines.join("\n");
|
|
151
|
+
},
|
|
123
152
|
} as const;
|
|
124
153
|
|
|
125
154
|
|
|
@@ -9,14 +9,14 @@ import { runDiscovery } from "./discovery.ts";
|
|
|
9
9
|
import { executeOrchBatch } from "./engine.ts";
|
|
10
10
|
import { computeTransitiveDependents, execLog, executeWave, pollUntilTaskComplete, spawnLaneSession, tmuxHasSession } from "./execution.ts";
|
|
11
11
|
import type { MonitorUpdateCallback } from "./execution.ts";
|
|
12
|
-
import { runGit } from "./git.ts";
|
|
13
|
-
import { mergeWaveByRepo } from "./merge.ts";
|
|
12
|
+
import { getCurrentBranch, runGit } from "./git.ts";
|
|
13
|
+
import { attemptAutoIntegration, mergeWaveByRepo } from "./merge.ts";
|
|
14
14
|
import { computeMergeFailurePolicy, formatRepoMergeSummary, ORCH_MESSAGES } from "./messages.ts";
|
|
15
15
|
import { resolveOperatorId } from "./naming.ts";
|
|
16
16
|
import { deleteBatchState, hasTaskDoneMarker, loadBatchState, persistRuntimeState, seedPendingOutcomesForAllocatedLanes, syncTaskOutcomesFromMonitor, upsertTaskOutcome } from "./persistence.ts";
|
|
17
17
|
import { StateFileError } from "./types.ts";
|
|
18
18
|
import type { AllocatedLane, AllocatedTask, LaneExecutionResult, LaneTaskOutcome, LaneTaskStatus, MergeWaveResult, OrchBatchPhase, OrchBatchRuntimeState, OrchestratorConfig, ParsedTask, PersistedBatchState, PersistedLaneRecord, ReconciledTaskState, ResumeEligibility, ResumePoint, TaskRunnerConfig, WaveExecutionResult, WorkspaceConfig } from "./types.ts";
|
|
19
|
-
import { buildDependencyGraph, resolveRepoRoot } from "./waves.ts";
|
|
19
|
+
import { buildDependencyGraph, resolveBaseBranch, resolveRepoRoot } from "./waves.ts";
|
|
20
20
|
import { deleteBranchBestEffort, forceCleanupWorktree, listWorktrees, removeAllWorktrees, removeWorktree, safeResetWorktree } from "./worktree.ts";
|
|
21
21
|
|
|
22
22
|
// ── Resume Repo Helpers ──────────────────────────────────────────────
|
|
@@ -56,6 +56,37 @@ export function collectRepoRoots(
|
|
|
56
56
|
return [...roots];
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Resolve a repoId from a resolved repo root path.
|
|
61
|
+
*
|
|
62
|
+
* In workspace mode, workspace config maps repoId → path. This performs
|
|
63
|
+
* the reverse lookup: given a resolved absolute path, find the repoId.
|
|
64
|
+
* Returns `undefined` if no workspace config or no matching repo is found
|
|
65
|
+
* (which is correct for repo mode or the primary/default repo).
|
|
66
|
+
*
|
|
67
|
+
* Used during cleanup to call `resolveBaseBranch()` per-repo with the
|
|
68
|
+
* correct repoId, ensuring unmerged-branch protection checks against
|
|
69
|
+
* the right target branch in workspace mode.
|
|
70
|
+
*
|
|
71
|
+
* @param repoRoot - Resolved absolute path of the repo
|
|
72
|
+
* @param workspaceConfig - Workspace configuration (null in repo mode)
|
|
73
|
+
* @returns The repoId or undefined if not found / not in workspace mode
|
|
74
|
+
*/
|
|
75
|
+
export function resolveRepoIdFromRoot(
|
|
76
|
+
repoRoot: string,
|
|
77
|
+
workspaceConfig?: WorkspaceConfig | null,
|
|
78
|
+
): string | undefined {
|
|
79
|
+
if (!workspaceConfig) return undefined;
|
|
80
|
+
|
|
81
|
+
for (const [repoId, repoConfig] of workspaceConfig.repos) {
|
|
82
|
+
if (repoConfig.path === repoRoot) {
|
|
83
|
+
return repoId;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
59
90
|
/**
|
|
60
91
|
* Reconstruct AllocatedLane[] from persisted lane records.
|
|
61
92
|
*
|
|
@@ -609,10 +640,26 @@ export async function resumeOrchBatch(
|
|
|
609
640
|
}
|
|
610
641
|
|
|
611
642
|
// ── 6. Reconstruct runtime state ─────────────────────────────
|
|
643
|
+
|
|
644
|
+
// Guard: orchBranch must be present for routing. Persisted states from
|
|
645
|
+
// pre-TP-022 runs may have orchBranch="" (TP-020 defaults).
|
|
646
|
+
// Check BEFORE mutating batchState so phase/batchId remain idle on rejection,
|
|
647
|
+
// allowing future /orch-resume or /orch-abort to proceed.
|
|
648
|
+
if (!persistedState.orchBranch) {
|
|
649
|
+
onNotify(
|
|
650
|
+
`❌ Cannot resume batch ${persistedState.batchId}: persisted state has no orch branch. ` +
|
|
651
|
+
`This batch was created before orch-branch routing was implemented. ` +
|
|
652
|
+
`Use /orch-abort to clean up, then start a new batch.`,
|
|
653
|
+
"error",
|
|
654
|
+
);
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
|
|
612
658
|
batchState.phase = "executing";
|
|
613
659
|
batchState.batchId = persistedState.batchId;
|
|
614
660
|
batchState.baseBranch = persistedState.baseBranch || "";
|
|
615
|
-
batchState.orchBranch = persistedState.orchBranch
|
|
661
|
+
batchState.orchBranch = persistedState.orchBranch;
|
|
662
|
+
|
|
616
663
|
batchState.mode = persistedState.mode;
|
|
617
664
|
batchState.startedAt = persistedState.startedAt;
|
|
618
665
|
batchState.pauseSignal = { paused: false };
|
|
@@ -902,7 +949,7 @@ export async function resumeOrchBatch(
|
|
|
902
949
|
orchConfig,
|
|
903
950
|
repoRoot,
|
|
904
951
|
batchState.batchId,
|
|
905
|
-
batchState.
|
|
952
|
+
batchState.orchBranch,
|
|
906
953
|
workspaceConfig,
|
|
907
954
|
stateRoot,
|
|
908
955
|
agentRoot,
|
|
@@ -1066,7 +1113,7 @@ export async function resumeOrchBatch(
|
|
|
1066
1113
|
batchState.batchId,
|
|
1067
1114
|
batchState.pauseSignal,
|
|
1068
1115
|
depGraph,
|
|
1069
|
-
batchState.
|
|
1116
|
+
batchState.orchBranch,
|
|
1070
1117
|
handleResumeMonitorUpdate,
|
|
1071
1118
|
(lanes) => {
|
|
1072
1119
|
latestAllocatedLanes = lanes;
|
|
@@ -1181,7 +1228,7 @@ export async function resumeOrchBatch(
|
|
|
1181
1228
|
orchConfig,
|
|
1182
1229
|
repoRoot,
|
|
1183
1230
|
batchState.batchId,
|
|
1184
|
-
batchState.
|
|
1231
|
+
batchState.orchBranch,
|
|
1185
1232
|
workspaceConfig,
|
|
1186
1233
|
stateRoot,
|
|
1187
1234
|
agentRoot,
|
|
@@ -1291,10 +1338,24 @@ export async function resumeOrchBatch(
|
|
|
1291
1338
|
// Use encounteredRepoRoots which includes both persisted lanes
|
|
1292
1339
|
// AND newly allocated lanes from resumed waves, ensuring repos
|
|
1293
1340
|
// introduced after resume starts are covered.
|
|
1341
|
+
// Per-repo target branch: primary repo uses orchBranch, secondary
|
|
1342
|
+
// repos resolve their own branch (same as cleanup — see section 11).
|
|
1294
1343
|
for (const perRepoRoot of encounteredRepoRoots) {
|
|
1295
|
-
const existingWorktrees = listWorktrees(wtPrefix, perRepoRoot, resetOpId);
|
|
1344
|
+
const existingWorktrees = listWorktrees(wtPrefix, perRepoRoot, resetOpId, batchState.batchId);
|
|
1296
1345
|
if (existingWorktrees.length > 0) {
|
|
1297
|
-
|
|
1346
|
+
let targetBranch: string;
|
|
1347
|
+
if (perRepoRoot === repoRoot) {
|
|
1348
|
+
targetBranch = batchState.orchBranch;
|
|
1349
|
+
} else {
|
|
1350
|
+
const repoId = resolveRepoIdFromRoot(perRepoRoot, workspaceConfig);
|
|
1351
|
+
try {
|
|
1352
|
+
targetBranch = resolveBaseBranch(repoId, perRepoRoot, batchState.orchBranch, workspaceConfig);
|
|
1353
|
+
} catch {
|
|
1354
|
+
// If resolution fails, fall back to orchBranch (reset will
|
|
1355
|
+
// fail gracefully and trigger worktree removal)
|
|
1356
|
+
targetBranch = batchState.orchBranch;
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1298
1359
|
for (const wt of existingWorktrees) {
|
|
1299
1360
|
const resetResult = safeResetWorktree(wt, targetBranch, perRepoRoot);
|
|
1300
1361
|
if (!resetResult.success) {
|
|
@@ -1314,13 +1375,39 @@ export async function resumeOrchBatch(
|
|
|
1314
1375
|
if (!preserveWorktreesForResume) {
|
|
1315
1376
|
const wtPrefix = orchConfig.orchestrator.worktree_prefix;
|
|
1316
1377
|
const cleanupOpId = resolveOperatorId(orchConfig);
|
|
1317
|
-
const targetBranch = batchState.baseBranch;
|
|
1318
1378
|
|
|
1319
1379
|
// Use encounteredRepoRoots which includes both persisted lanes
|
|
1320
1380
|
// AND newly allocated lanes from resumed waves, ensuring repos
|
|
1321
1381
|
// introduced after resume starts are cleaned up.
|
|
1382
|
+
//
|
|
1383
|
+
// Per-repo target branch resolution (workspace-mode correctness):
|
|
1384
|
+
// In repo mode, orchBranch is the correct target for all worktrees.
|
|
1385
|
+
// In workspace mode, the orchBranch only exists in the primary repo.
|
|
1386
|
+
// Secondary repos were merged against their own resolved base branch
|
|
1387
|
+
// (via resolveBaseBranch in mergeWaveByRepo), so unmerged-branch
|
|
1388
|
+
// protection must compare against that same per-repo branch.
|
|
1322
1389
|
for (const perRepoRoot of encounteredRepoRoots) {
|
|
1323
|
-
|
|
1390
|
+
let targetBranch: string | undefined;
|
|
1391
|
+
if (perRepoRoot === repoRoot) {
|
|
1392
|
+
// Primary repo: lane branches were merged into orchBranch
|
|
1393
|
+
targetBranch = batchState.orchBranch;
|
|
1394
|
+
} else {
|
|
1395
|
+
// Secondary repo (workspace mode): resolve the repo's own branch
|
|
1396
|
+
// using the same logic as mergeWaveByRepo. Find repoId by matching
|
|
1397
|
+
// the resolved path back to workspace config.
|
|
1398
|
+
const repoId = resolveRepoIdFromRoot(perRepoRoot, workspaceConfig);
|
|
1399
|
+
try {
|
|
1400
|
+
targetBranch = resolveBaseBranch(repoId, perRepoRoot, batchState.orchBranch, workspaceConfig);
|
|
1401
|
+
} catch {
|
|
1402
|
+
// resolveBaseBranch may throw if HEAD is detached and no
|
|
1403
|
+
// defaultBranch is configured. Fall back to undefined which
|
|
1404
|
+
// skips branch protection (branches are deleted without
|
|
1405
|
+
// merge-status check — safe because successfully merged
|
|
1406
|
+
// branches were already cleaned up in post-merge steps).
|
|
1407
|
+
targetBranch = undefined;
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
removeAllWorktrees(wtPrefix, perRepoRoot, cleanupOpId, targetBranch, batchState.batchId, orchConfig);
|
|
1324
1411
|
}
|
|
1325
1412
|
}
|
|
1326
1413
|
|
|
@@ -1335,6 +1422,32 @@ export async function resumeOrchBatch(
|
|
|
1335
1422
|
}
|
|
1336
1423
|
}
|
|
1337
1424
|
|
|
1425
|
+
// ── Auto-Integration & Orch Branch Preservation (TP-022 Step 4) ──
|
|
1426
|
+
// Parity with engine.ts: auto-integrate if configured, else show manual guidance.
|
|
1427
|
+
// Gate: only run for terminal phases (completed/failed). Paused/stopped batches
|
|
1428
|
+
// are not yet done — integration would mutate refs prematurely.
|
|
1429
|
+
let autoIntegrated = false;
|
|
1430
|
+
const mergedTaskCount = batchState.succeededTasks;
|
|
1431
|
+
const isTerminalPhase = batchState.phase === "completed" || batchState.phase === "failed";
|
|
1432
|
+
if (isTerminalPhase && !preserveWorktreesForResume && batchState.orchBranch && mergedTaskCount > 0) {
|
|
1433
|
+
if (orchConfig.orchestrator.integration === "auto") {
|
|
1434
|
+
autoIntegrated = attemptAutoIntegration(
|
|
1435
|
+
batchState.orchBranch,
|
|
1436
|
+
batchState.baseBranch,
|
|
1437
|
+
repoRoot,
|
|
1438
|
+
batchState.batchId,
|
|
1439
|
+
"resume",
|
|
1440
|
+
onNotify,
|
|
1441
|
+
);
|
|
1442
|
+
}
|
|
1443
|
+
if (!autoIntegrated) {
|
|
1444
|
+
onNotify(
|
|
1445
|
+
ORCH_MESSAGES.orchIntegrationManual(batchState.orchBranch, batchState.baseBranch, mergedTaskCount),
|
|
1446
|
+
"info",
|
|
1447
|
+
);
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1338
1451
|
persistRuntimeState("batch-terminal", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, stateRoot);
|
|
1339
1452
|
|
|
1340
1453
|
if (batchState.phase === "paused" || batchState.phase === "stopped") {
|
|
@@ -1363,3 +1476,7 @@ export async function resumeOrchBatch(
|
|
|
1363
1476
|
}
|
|
1364
1477
|
}
|
|
1365
1478
|
|
|
1479
|
+
|
|
1480
|
+
// attemptAutoIntegration is now a shared helper in merge.ts (TP-022 Step 4).
|
|
1481
|
+
// Both engine.ts and resume.ts import it from there to eliminate parity drift.
|
|
1482
|
+
|
|
@@ -588,7 +588,20 @@ export function resolveBaseBranch(
|
|
|
588
588
|
}
|
|
589
589
|
}
|
|
590
590
|
|
|
591
|
-
// Step 3: Ultimate fallback — batch-level base branch
|
|
591
|
+
// Step 3: Ultimate fallback — batch-level base branch.
|
|
592
|
+
// In workspace mode the batch base branch is the orch branch (e.g.
|
|
593
|
+
// "orch/op-batch123"), which only exists in the primary repo. Using it
|
|
594
|
+
// for a secondary repo would cause worktree creation failure because the
|
|
595
|
+
// ref doesn't exist there. Fail fast with an actionable message instead.
|
|
596
|
+
if (repoId && batchBaseBranch.startsWith("orch/")) {
|
|
597
|
+
throw new Error(
|
|
598
|
+
`Cannot resolve base branch for repo "${repoId}" at ${repoRoot}: ` +
|
|
599
|
+
`HEAD is detached and no defaultBranch is configured. ` +
|
|
600
|
+
`The batch base branch "${batchBaseBranch}" is an orch branch that does not exist in this repo. ` +
|
|
601
|
+
`Configure a defaultBranch for this repo in task-orchestrator.yaml workspace settings.`,
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
|
|
592
605
|
return batchBaseBranch;
|
|
593
606
|
}
|
|
594
607
|
|
|
@@ -1070,10 +1083,11 @@ export function allocateLanes(
|
|
|
1070
1083
|
// This should never happen if ensureLaneWorktrees and assignTasksToLanes
|
|
1071
1084
|
// agree on lane numbers, but handle defensively.
|
|
1072
1085
|
// Roll back all worktrees across all repos on this unexpected failure.
|
|
1086
|
+
// Pass batchId + config for batch-scoped cleanup (only remove this batch's worktrees).
|
|
1073
1087
|
for (const groupKey of createdGroupKeys) {
|
|
1074
1088
|
const groupRepoId = repoIdForGroup.get(groupKey);
|
|
1075
1089
|
const groupRepoRoot = resolveRepoRoot(groupRepoId, repoRoot, workspaceConfig);
|
|
1076
|
-
removeAllWorktrees(config.orchestrator.worktree_prefix, groupRepoRoot, opId);
|
|
1090
|
+
removeAllWorktrees(config.orchestrator.worktree_prefix, groupRepoRoot, opId, undefined, batchId, config);
|
|
1077
1091
|
}
|
|
1078
1092
|
return {
|
|
1079
1093
|
success: false,
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Worktree CRUD, bulk ops, branch protection, preflight
|
|
3
3
|
* @module orch/worktree
|
|
4
4
|
*/
|
|
5
|
-
import { existsSync, mkdirSync, readdirSync, realpathSync, rmSync } from "fs";
|
|
5
|
+
import { existsSync, mkdirSync, readdirSync, realpathSync, rmdirSync, rmSync } from "fs";
|
|
6
6
|
import { execSync } from "child_process";
|
|
7
7
|
import { join, basename, resolve } from "path";
|
|
8
8
|
|
|
@@ -202,7 +202,7 @@ export function removeBatchContainerIfEmpty(containerPath: string): boolean {
|
|
|
202
202
|
if (entries.length > 0) {
|
|
203
203
|
return false; // Non-empty — do not remove (partial failure safety)
|
|
204
204
|
}
|
|
205
|
-
|
|
205
|
+
rmdirSync(containerPath);
|
|
206
206
|
return true;
|
|
207
207
|
} catch {
|
|
208
208
|
// If we can't read or remove — leave it alone (safe default)
|
|
@@ -1390,7 +1390,7 @@ export function ensureLaneWorktrees(
|
|
|
1390
1390
|
const prefix = config.orchestrator.worktree_prefix;
|
|
1391
1391
|
const opId = resolveOperatorId(config);
|
|
1392
1392
|
|
|
1393
|
-
const existing = listWorktrees(prefix, repoRoot, opId);
|
|
1393
|
+
const existing = listWorktrees(prefix, repoRoot, opId, batchId);
|
|
1394
1394
|
const existingByLane = new Map<number, WorktreeInfo>();
|
|
1395
1395
|
for (const wt of existing) {
|
|
1396
1396
|
existingByLane.set(wt.laneNumber, wt);
|