taskplane 0.4.2 → 0.5.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 +10 -5
- package/extensions/taskplane/config-loader.ts +1 -0
- package/extensions/taskplane/config-schema.ts +3 -0
- package/extensions/taskplane/engine.ts +60 -8
- package/extensions/taskplane/extension.ts +562 -2
- package/extensions/taskplane/merge.ts +182 -27
- package/extensions/taskplane/messages.ts +18 -0
- package/extensions/taskplane/persistence.ts +13 -0
- package/extensions/taskplane/resume.ts +128 -10
- package/extensions/taskplane/settings-tui.ts +4 -2
- package/extensions/taskplane/types.ts +8 -0
- package/extensions/taskplane/waves.ts +16 -2
- package/extensions/taskplane/worktree.ts +257 -31
- 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
|
+
|
|
@@ -120,6 +120,24 @@ export const ORCH_MESSAGES = {
|
|
|
120
120
|
// /orch merge — repo-scoped partial summary (TP-005 Step 1)
|
|
121
121
|
orchMergePartialRepoSummary: (waveNum: number, repoLines: string[]) =>
|
|
122
122
|
`⚠️ [Wave ${waveNum}] Merge partially succeeded — repo outcomes diverged:\n${repoLines.join("\n")}`,
|
|
123
|
+
|
|
124
|
+
// /orch integration — post-batch integration guidance (TP-022 Step 4)
|
|
125
|
+
orchIntegrationAutoSuccess: (orchBranch: string, baseBranch: string) =>
|
|
126
|
+
`✅ Auto-integrated: ${baseBranch} fast-forwarded to ${orchBranch}.`,
|
|
127
|
+
orchIntegrationAutoFailed: (orchBranch: string, baseBranch: string, reason: string) =>
|
|
128
|
+
`⚠️ Auto-integration skipped: ${reason}\n` +
|
|
129
|
+
` Orch branch ${orchBranch} preserved. Integrate manually:\n` +
|
|
130
|
+
` git log ${baseBranch}..${orchBranch}\n` +
|
|
131
|
+
` git merge ${orchBranch}`,
|
|
132
|
+
orchIntegrationManual: (orchBranch: string, baseBranch: string, mergedTaskCount: number) => {
|
|
133
|
+
const lines = [
|
|
134
|
+
`ℹ️ Batch complete. Orch branch ${orchBranch} has ${mergedTaskCount} merged task(s).`,
|
|
135
|
+
` Review and integrate:`,
|
|
136
|
+
` git log ${baseBranch}..${orchBranch}`,
|
|
137
|
+
` git merge ${orchBranch}`,
|
|
138
|
+
];
|
|
139
|
+
return lines.join("\n");
|
|
140
|
+
},
|
|
123
141
|
} as const;
|
|
124
142
|
|
|
125
143
|
|
|
@@ -366,6 +366,18 @@ export function validatePersistedState(data: unknown): PersistedBatchState {
|
|
|
366
366
|
);
|
|
367
367
|
}
|
|
368
368
|
|
|
369
|
+
// ── Optional string fields: orchBranch ───────────────────────
|
|
370
|
+
// orchBranch was added after schema v2 shipped; default to "" if missing.
|
|
371
|
+
if (obj.orchBranch !== undefined && typeof obj.orchBranch !== "string") {
|
|
372
|
+
throw new StateFileError(
|
|
373
|
+
"STATE_SCHEMA_INVALID",
|
|
374
|
+
`Invalid "orchBranch" field (expected string, got ${typeof obj.orchBranch})`,
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
if (obj.orchBranch === undefined) {
|
|
378
|
+
obj.orchBranch = "";
|
|
379
|
+
}
|
|
380
|
+
|
|
369
381
|
// ── v2: mode field ───────────────────────────────────────────
|
|
370
382
|
// mode is required in v2, absent in v1 (defaults to "repo" via upconvert).
|
|
371
383
|
if (!isV1 && obj.mode === undefined) {
|
|
@@ -776,6 +788,7 @@ export function serializeBatchState(
|
|
|
776
788
|
phase: state.phase,
|
|
777
789
|
batchId: state.batchId,
|
|
778
790
|
baseBranch: state.baseBranch,
|
|
791
|
+
orchBranch: state.orchBranch ?? "",
|
|
779
792
|
mode: state.mode ?? "repo",
|
|
780
793
|
startedAt: state.startedAt,
|
|
781
794
|
updatedAt: now,
|
|
@@ -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,9 +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 || "";
|
|
661
|
+
batchState.orchBranch = persistedState.orchBranch;
|
|
662
|
+
|
|
615
663
|
batchState.mode = persistedState.mode;
|
|
616
664
|
batchState.startedAt = persistedState.startedAt;
|
|
617
665
|
batchState.pauseSignal = { paused: false };
|
|
@@ -901,7 +949,7 @@ export async function resumeOrchBatch(
|
|
|
901
949
|
orchConfig,
|
|
902
950
|
repoRoot,
|
|
903
951
|
batchState.batchId,
|
|
904
|
-
batchState.
|
|
952
|
+
batchState.orchBranch,
|
|
905
953
|
workspaceConfig,
|
|
906
954
|
stateRoot,
|
|
907
955
|
agentRoot,
|
|
@@ -1065,7 +1113,7 @@ export async function resumeOrchBatch(
|
|
|
1065
1113
|
batchState.batchId,
|
|
1066
1114
|
batchState.pauseSignal,
|
|
1067
1115
|
depGraph,
|
|
1068
|
-
batchState.
|
|
1116
|
+
batchState.orchBranch,
|
|
1069
1117
|
handleResumeMonitorUpdate,
|
|
1070
1118
|
(lanes) => {
|
|
1071
1119
|
latestAllocatedLanes = lanes;
|
|
@@ -1180,7 +1228,7 @@ export async function resumeOrchBatch(
|
|
|
1180
1228
|
orchConfig,
|
|
1181
1229
|
repoRoot,
|
|
1182
1230
|
batchState.batchId,
|
|
1183
|
-
batchState.
|
|
1231
|
+
batchState.orchBranch,
|
|
1184
1232
|
workspaceConfig,
|
|
1185
1233
|
stateRoot,
|
|
1186
1234
|
agentRoot,
|
|
@@ -1290,10 +1338,24 @@ export async function resumeOrchBatch(
|
|
|
1290
1338
|
// Use encounteredRepoRoots which includes both persisted lanes
|
|
1291
1339
|
// AND newly allocated lanes from resumed waves, ensuring repos
|
|
1292
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).
|
|
1293
1343
|
for (const perRepoRoot of encounteredRepoRoots) {
|
|
1294
|
-
const existingWorktrees = listWorktrees(wtPrefix, perRepoRoot, resetOpId);
|
|
1344
|
+
const existingWorktrees = listWorktrees(wtPrefix, perRepoRoot, resetOpId, batchState.batchId);
|
|
1295
1345
|
if (existingWorktrees.length > 0) {
|
|
1296
|
-
|
|
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
|
+
}
|
|
1297
1359
|
for (const wt of existingWorktrees) {
|
|
1298
1360
|
const resetResult = safeResetWorktree(wt, targetBranch, perRepoRoot);
|
|
1299
1361
|
if (!resetResult.success) {
|
|
@@ -1313,13 +1375,39 @@ export async function resumeOrchBatch(
|
|
|
1313
1375
|
if (!preserveWorktreesForResume) {
|
|
1314
1376
|
const wtPrefix = orchConfig.orchestrator.worktree_prefix;
|
|
1315
1377
|
const cleanupOpId = resolveOperatorId(orchConfig);
|
|
1316
|
-
const targetBranch = batchState.baseBranch;
|
|
1317
1378
|
|
|
1318
1379
|
// Use encounteredRepoRoots which includes both persisted lanes
|
|
1319
1380
|
// AND newly allocated lanes from resumed waves, ensuring repos
|
|
1320
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.
|
|
1321
1389
|
for (const perRepoRoot of encounteredRepoRoots) {
|
|
1322
|
-
|
|
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);
|
|
1323
1411
|
}
|
|
1324
1412
|
}
|
|
1325
1413
|
|
|
@@ -1334,6 +1422,32 @@ export async function resumeOrchBatch(
|
|
|
1334
1422
|
}
|
|
1335
1423
|
}
|
|
1336
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
|
+
|
|
1337
1451
|
persistRuntimeState("batch-terminal", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, stateRoot);
|
|
1338
1452
|
|
|
1339
1453
|
if (batchState.phase === "paused" || batchState.phase === "stopped") {
|
|
@@ -1362,3 +1476,7 @@ export async function resumeOrchBatch(
|
|
|
1362
1476
|
}
|
|
1363
1477
|
}
|
|
1364
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
|
+
|
|
@@ -98,9 +98,11 @@ export const SECTIONS: SectionDef[] = [
|
|
|
98
98
|
{ configPath: "orchestrator.orchestrator.worktreeLocation", label: "Worktree Location", control: "toggle", layer: "L1", fieldType: "enum", values: ["sibling", "subdirectory"], description: "Where lane worktree directories are created" },
|
|
99
99
|
{ configPath: "orchestrator.orchestrator.worktreePrefix", label: "Worktree Prefix", control: "input", layer: "L1", fieldType: "string", description: "Prefix for worktree directory names" },
|
|
100
100
|
{ configPath: "orchestrator.orchestrator.batchIdFormat", label: "Batch ID Format", control: "toggle", layer: "L1", fieldType: "enum", values: ["timestamp", "sequential"], description: "Batch ID format for logs/branch naming" },
|
|
101
|
-
|
|
101
|
+
// spawn_mode removed from Orchestrator section — /orch always requires tmux.
|
|
102
|
+
// The user-facing spawn mode setting is under Worker (controls /task behavior).
|
|
102
103
|
{ configPath: "orchestrator.orchestrator.tmuxPrefix", label: "Tmux Prefix", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "tmuxPrefix", description: "Prefix for orchestrator tmux sessions" },
|
|
103
104
|
{ configPath: "orchestrator.orchestrator.operatorId", label: "Operator ID", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "operatorId", description: "Operator identifier (empty = auto-detect)" },
|
|
105
|
+
{ configPath: "orchestrator.orchestrator.integration", label: "Integration", control: "toggle", layer: "L1", fieldType: "enum", values: ["manual", "auto"], description: "How completed batches are integrated. manual = user runs /orch-integrate. auto = fast-forward on completion." },
|
|
104
106
|
],
|
|
105
107
|
},
|
|
106
108
|
{
|
|
@@ -153,7 +155,7 @@ export const SECTIONS: SectionDef[] = [
|
|
|
153
155
|
{ configPath: "taskRunner.worker.model", label: "Worker Model", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "workerModel", description: "Worker model (empty = inherit session)" },
|
|
154
156
|
{ configPath: "taskRunner.worker.tools", label: "Worker Tools", control: "input", layer: "L1", fieldType: "string", description: "Worker tool allowlist" },
|
|
155
157
|
{ configPath: "taskRunner.worker.thinking", label: "Worker Thinking", control: "input", layer: "L1", fieldType: "string", description: "Worker thinking mode" },
|
|
156
|
-
{ configPath: "taskRunner.worker.spawnMode", label: "
|
|
158
|
+
{ configPath: "taskRunner.worker.spawnMode", label: "Spawn Mode", control: "toggle", layer: "L1", fieldType: "enum", values: ["subprocess", "tmux"], description: "How /task spawns workers and reviewers. subprocess = child process (simpler), tmux = named sessions (attachable for debugging)" },
|
|
157
159
|
],
|
|
158
160
|
},
|
|
159
161
|
{
|
|
@@ -17,6 +17,8 @@ export interface OrchestratorConfig {
|
|
|
17
17
|
tmux_prefix: string;
|
|
18
18
|
/** Optional operator identifier. Auto-detected from OS username if empty. */
|
|
19
19
|
operator_id: string;
|
|
20
|
+
/** How completed batches are integrated. manual = user runs /orch-integrate. auto = fast-forward on completion. */
|
|
21
|
+
integration: "manual" | "auto";
|
|
20
22
|
};
|
|
21
23
|
dependencies: {
|
|
22
24
|
source: "prompt" | "agent";
|
|
@@ -151,6 +153,7 @@ export const DEFAULT_ORCHESTRATOR_CONFIG: OrchestratorConfig = {
|
|
|
151
153
|
spawn_mode: "subprocess",
|
|
152
154
|
tmux_prefix: "orch",
|
|
153
155
|
operator_id: "",
|
|
156
|
+
integration: "manual",
|
|
154
157
|
},
|
|
155
158
|
dependencies: {
|
|
156
159
|
source: "prompt",
|
|
@@ -829,6 +832,8 @@ export interface OrchBatchRuntimeState {
|
|
|
829
832
|
batchId: string;
|
|
830
833
|
/** Branch that was active when /orch started — used as base for worktrees and merge target */
|
|
831
834
|
baseBranch: string;
|
|
835
|
+
/** Orchestrator-managed branch name (e.g., 'orch/henry-20260318T140000'). Empty = legacy mode (merge into baseBranch directly). */
|
|
836
|
+
orchBranch: string;
|
|
832
837
|
/** Workspace execution mode (v2). Defaults to "repo" for backward compatibility. */
|
|
833
838
|
mode: WorkspaceMode;
|
|
834
839
|
/** Shared pause signal — set by /orch-pause, read by executeLane/executeWave */
|
|
@@ -908,6 +913,7 @@ export function freshOrchBatchState(): OrchBatchRuntimeState {
|
|
|
908
913
|
phase: "idle",
|
|
909
914
|
batchId: "",
|
|
910
915
|
baseBranch: "",
|
|
916
|
+
orchBranch: "",
|
|
911
917
|
mode: "repo",
|
|
912
918
|
pauseSignal: { paused: false },
|
|
913
919
|
waveResults: [],
|
|
@@ -1367,6 +1373,8 @@ export interface PersistedBatchState {
|
|
|
1367
1373
|
batchId: string;
|
|
1368
1374
|
/** Branch that was active when /orch started — used as base for worktrees and merge target */
|
|
1369
1375
|
baseBranch: string;
|
|
1376
|
+
/** Orchestrator-managed branch name (e.g., 'orch/henry-20260318T140000'). Empty = legacy mode (merge into baseBranch directly). */
|
|
1377
|
+
orchBranch: string;
|
|
1370
1378
|
/**
|
|
1371
1379
|
* Workspace execution mode at batch start (v2).
|
|
1372
1380
|
* - "repo": Single-repo mode (default, backward-compatible).
|
|
@@ -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,
|