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.
@@ -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
- // Include opId to prevent collisions between concurrent operators.
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 = join(repoRoot, ".worktrees", `merge-workspace-${opId}`);
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
- // ── Fast-forward develop and clean up merge worktree ────────────
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
- // Fast-forward the real target branch to the temp merge branch.
762
- // The main repo may have dirty files (user edits) — stash if needed.
763
- const ffResult = spawnSync("git", ["merge", "--ff-only", tempBranch], { cwd: repoRoot });
764
-
765
- if (ffResult.status !== 0) {
766
- // Dirty working tree may block ff try stash + ff + pop
767
- execLog("merge", `W${waveIndex}`, "fast-forward blocked — stashing user changes");
768
- const stashMsg = `merge-agent-autostash-w${waveIndex}-${batchId}`;
769
- spawnSync("git", ["stash", "push", "--include-untracked", "-m", stashMsg], { cwd: repoRoot });
770
-
771
- const ffRetry = spawnSync("git", ["merge", "--ff-only", tempBranch], { cwd: repoRoot });
772
-
773
- // Always pop stash, regardless of ff result
774
- spawnSync("git", ["stash", "pop"], { cwd: repoRoot });
775
-
776
- if (ffRetry.status !== 0) {
777
- const err = ffRetry.stderr?.toString().trim() || "unknown error";
778
- execLog("merge", `W${waveIndex}`, `fast-forward failed even after stash: ${err}`);
779
- failedLane = failedLane ?? -1;
780
- failureReason = `Fast-forward of ${targetBranch} failed: ${err}`;
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
- execLog("merge", `W${waveIndex}`, "fast-forward succeeded after stash/pop");
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.baseBranch,
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.baseBranch,
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.baseBranch,
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
- const targetBranch = batchState.baseBranch;
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
- removeAllWorktrees(wtPrefix, perRepoRoot, cleanupOpId, targetBranch);
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
- rmSync(containerPath, { recursive: false });
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskplane",
3
- "version": "0.4.3",
3
+ "version": "0.5.1",
4
4
  "description": "AI agent orchestration for pi — parallel task execution with checkpoint discipline",
5
5
  "keywords": [
6
6
  "pi-package",