taskplane 0.4.3 → 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/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/resume.ts +128 -11
- package/extensions/taskplane/waves.ts +16 -2
- package/extensions/taskplane/worktree.ts +3 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ The taskplane dashboard runs on a local port on your system and gives you elegan
|
|
|
18
18
|
|
|
19
19
|
### Key Features
|
|
20
20
|
|
|
21
|
-
- **Task Orchestrator** — Parallel multi-task execution using git worktrees for full filesystem isolation. Dependency-aware wave scheduling. Automated merges
|
|
21
|
+
- **Task Orchestrator** — Parallel multi-task execution using git worktrees for full filesystem isolation. Dependency-aware wave scheduling. Automated merges into a dedicated orch branch — your working branch stays stable until you choose to integrate.
|
|
22
22
|
- **Task Runner** — What the Orchestrator uses for autonomous single-task execution. Worker agents run in fresh-context loops with STATUS.md as persistent memory. Every checkbox gets a git checkpoint. Cross-model reviewer agents catch what the worker agents missed.
|
|
23
23
|
- **Web Dashboard** — Live browser-based monitoring via `taskplane dashboard`. SSE streaming, lane/task progress, wave visualization, batch history.
|
|
24
24
|
- **Structured Tasks** — PROMPT.md defines the mission, steps, and constraints. STATUS.md tracks progress. Agents follow the plan, not vibes.
|
|
@@ -128,7 +128,7 @@ The default scaffold includes two independent example tasks, so `/orch all` give
|
|
|
128
128
|
Important distinction:
|
|
129
129
|
|
|
130
130
|
- `/task` runs in your **current branch/worktree**.
|
|
131
|
-
- `/orch` runs tasks in **isolated worktrees**
|
|
131
|
+
- `/orch` runs tasks in **isolated worktrees** on a dedicated orch branch — your working branch is never touched until you integrate.
|
|
132
132
|
|
|
133
133
|
Because workers checkpoint with git commits, `/task` can capture unrelated local edits if you're changing files in parallel. For safer isolation (even with one task), prefer:
|
|
134
134
|
|
|
@@ -156,6 +156,7 @@ Orchestrator lanes execute tasks through task-runner under the hood, so `/task`
|
|
|
156
156
|
| `/orch-abort [--hard]` | Abort batch (graceful or immediate) |
|
|
157
157
|
| `/orch-deps <areas\|paths\|all>` | Show dependency graph |
|
|
158
158
|
| `/orch-sessions` | List active worker sessions |
|
|
159
|
+
| `/orch-integrate` | Integrate completed orch batch into your working branch |
|
|
159
160
|
| `/taskplane-settings` | View and edit taskplane configuration interactively |
|
|
160
161
|
|
|
161
162
|
### CLI Commands
|
|
@@ -189,14 +190,18 @@ Orchestrator lanes execute tasks through task-runner under the hood, so `/task`
|
|
|
189
190
|
│
|
|
190
191
|
┌──────▼──────┐
|
|
191
192
|
│ Merge Agent │ ← Conflict resolution
|
|
192
|
-
│
|
|
193
|
-
|
|
193
|
+
│ Orch Branch │ & verification
|
|
194
|
+
└──────┬──────┘
|
|
195
|
+
│
|
|
196
|
+
┌──────▼──────┐
|
|
197
|
+
│ /orch- │ ← User integrates into
|
|
198
|
+
│ integrate │ working branch
|
|
194
199
|
└─────────────┘
|
|
195
200
|
```
|
|
196
201
|
|
|
197
202
|
**Single task** (`/task`): Worker iterates in fresh-context loops. STATUS.md is persistent memory. Each checkbox → git checkpoint. Reviewer validates on completion.
|
|
198
203
|
|
|
199
|
-
**Parallel batch** (`/orch`): Tasks are sorted into dependency waves. Each wave runs in parallel across lanes (git worktrees). Completed lanes merge into
|
|
204
|
+
**Parallel batch** (`/orch`): Tasks are sorted into dependency waves. Each wave runs in parallel across lanes (git worktrees). Completed lanes merge into a dedicated orch branch. When the batch completes, use `/orch-integrate` to bring the results into your working branch (or configure auto-integration).
|
|
200
205
|
|
|
201
206
|
## Documentation
|
|
202
207
|
|
|
@@ -9,7 +9,7 @@ import { formatDiscoveryResults, runDiscovery } from "./discovery.ts";
|
|
|
9
9
|
import { execLog, executeWave, tmuxKillSession } from "./execution.ts";
|
|
10
10
|
import type { MonitorUpdateCallback } from "./execution.ts";
|
|
11
11
|
import { getCurrentBranch, runGit } from "./git.ts";
|
|
12
|
-
import { mergeWaveByRepo } from "./merge.ts";
|
|
12
|
+
import { attemptAutoIntegration, mergeWaveByRepo } from "./merge.ts";
|
|
13
13
|
import { computeMergeFailurePolicy, formatRepoMergeSummary, ORCH_MESSAGES } from "./messages.ts";
|
|
14
14
|
import { resolveOperatorId } from "./naming.ts";
|
|
15
15
|
import { deleteBatchState, loadBatchHistory, persistRuntimeState, saveBatchHistory, seedPendingOutcomesForAllocatedLanes, syncTaskOutcomesFromMonitor, upsertTaskOutcome } from "./persistence.ts";
|
|
@@ -182,6 +182,26 @@ export async function executeOrchBatch(
|
|
|
182
182
|
wavePlan = rawWaves;
|
|
183
183
|
discoveryRef = discovery;
|
|
184
184
|
|
|
185
|
+
// ── Create orchestrator-managed branch ───────────────────────
|
|
186
|
+
// Created after all planning validations pass (preflight, discovery,
|
|
187
|
+
// graph validation, wave computation) to avoid orphan branches on
|
|
188
|
+
// planning-phase early exits.
|
|
189
|
+
// The orch branch isolates all batch work from the user's current branch.
|
|
190
|
+
// Worktrees branch from it; merges target it via update-ref.
|
|
191
|
+
const opId = resolveOperatorId(orchConfig);
|
|
192
|
+
const orchBranch = `orch/${opId}-${batchState.batchId}`;
|
|
193
|
+
const branchResult = runGit(["branch", orchBranch, batchState.baseBranch], repoRoot);
|
|
194
|
+
if (!branchResult.ok) {
|
|
195
|
+
batchState.phase = "failed";
|
|
196
|
+
batchState.endedAt = Date.now();
|
|
197
|
+
const errDetail = branchResult.stderr || branchResult.stdout || "unknown error";
|
|
198
|
+
batchState.errors.push(`Failed to create orch branch '${orchBranch}': ${errDetail}`);
|
|
199
|
+
onNotify(`❌ Failed to create orch branch '${orchBranch}': ${errDetail}`, "error");
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
batchState.orchBranch = orchBranch;
|
|
203
|
+
execLog("batch", batchState.batchId, "created orch branch", { orchBranch, baseBranch: batchState.baseBranch });
|
|
204
|
+
|
|
185
205
|
onNotify(
|
|
186
206
|
ORCH_MESSAGES.orchStarting(batchState.batchId, rawWaves.length, batchState.totalTasks),
|
|
187
207
|
"info",
|
|
@@ -253,7 +273,7 @@ export async function executeOrchBatch(
|
|
|
253
273
|
batchState.batchId,
|
|
254
274
|
batchState.pauseSignal,
|
|
255
275
|
depGraph,
|
|
256
|
-
batchState.
|
|
276
|
+
batchState.orchBranch,
|
|
257
277
|
handleWaveMonitorUpdate,
|
|
258
278
|
(lanes) => {
|
|
259
279
|
latestAllocatedLanes = lanes;
|
|
@@ -361,7 +381,7 @@ export async function executeOrchBatch(
|
|
|
361
381
|
orchConfig,
|
|
362
382
|
repoRoot,
|
|
363
383
|
batchState.batchId,
|
|
364
|
-
batchState.
|
|
384
|
+
batchState.orchBranch,
|
|
365
385
|
workspaceConfig,
|
|
366
386
|
stateRoot,
|
|
367
387
|
agentRoot,
|
|
@@ -481,7 +501,7 @@ export async function executeOrchBatch(
|
|
|
481
501
|
if (waveIdx < rawWaves.length - 1 && !batchState.pauseSignal.paused) {
|
|
482
502
|
const prefix = orchConfig.orchestrator.worktree_prefix;
|
|
483
503
|
const resetOpId = resolveOperatorId(orchConfig);
|
|
484
|
-
const existingWorktrees = listWorktrees(prefix, repoRoot, resetOpId);
|
|
504
|
+
const existingWorktrees = listWorktrees(prefix, repoRoot, resetOpId, batchState.batchId);
|
|
485
505
|
|
|
486
506
|
if (existingWorktrees.length > 0) {
|
|
487
507
|
onNotify(
|
|
@@ -489,7 +509,7 @@ export async function executeOrchBatch(
|
|
|
489
509
|
"info",
|
|
490
510
|
);
|
|
491
511
|
|
|
492
|
-
const targetBranch = batchState.
|
|
512
|
+
const targetBranch = batchState.orchBranch;
|
|
493
513
|
for (const wt of existingWorktrees) {
|
|
494
514
|
const resetResult = safeResetWorktree(wt, targetBranch, repoRoot);
|
|
495
515
|
if (!resetResult.success) {
|
|
@@ -672,11 +692,13 @@ export async function executeOrchBatch(
|
|
|
672
692
|
}
|
|
673
693
|
} catch { /* .pi dir may not exist */ }
|
|
674
694
|
|
|
675
|
-
// Clean up worktrees —
|
|
676
|
-
|
|
695
|
+
// Clean up worktrees — use orchBranch to protect unmerged work.
|
|
696
|
+
// Lane branches were merged into orchBranch (not baseBranch), so
|
|
697
|
+
// unmerged-branch detection must compare against orchBranch.
|
|
698
|
+
const targetBranch = batchState.orchBranch;
|
|
677
699
|
const cleanupOpId = resolveOperatorId(orchConfig);
|
|
678
700
|
execLog("batch", batchState.batchId, "cleaning up worktrees");
|
|
679
|
-
const removeResult = removeAllWorktrees(prefix, repoRoot, cleanupOpId, targetBranch);
|
|
701
|
+
const removeResult = removeAllWorktrees(prefix, repoRoot, cleanupOpId, targetBranch, batchState.batchId, orchConfig);
|
|
680
702
|
|
|
681
703
|
// Log preserved branches
|
|
682
704
|
for (const p of removeResult.preserved) {
|
|
@@ -750,6 +772,35 @@ export async function executeOrchBatch(
|
|
|
750
772
|
}
|
|
751
773
|
}
|
|
752
774
|
|
|
775
|
+
// ── Auto-Integration & Orch Branch Preservation (TP-022 Step 4) ──
|
|
776
|
+
// After all waves are done, optionally fast-forward baseBranch to orchBranch.
|
|
777
|
+
// Auto-integration never converts a successful batch into "failed" — failures
|
|
778
|
+
// are warnings that preserve the orch branch for manual integration.
|
|
779
|
+
// Gate: only run for terminal phases (completed/failed). Paused/stopped batches
|
|
780
|
+
// are not yet done — integration would mutate refs prematurely.
|
|
781
|
+
let autoIntegrated = false;
|
|
782
|
+
const mergedTaskCount = batchState.succeededTasks;
|
|
783
|
+
const isTerminalPhase = batchState.phase === "completed" || batchState.phase === "failed";
|
|
784
|
+
if (isTerminalPhase && !preserveWorktreesForResume && batchState.orchBranch && mergedTaskCount > 0) {
|
|
785
|
+
if (orchConfig.orchestrator.integration === "auto") {
|
|
786
|
+
autoIntegrated = attemptAutoIntegration(
|
|
787
|
+
batchState.orchBranch,
|
|
788
|
+
batchState.baseBranch,
|
|
789
|
+
repoRoot,
|
|
790
|
+
batchState.batchId,
|
|
791
|
+
"batch",
|
|
792
|
+
onNotify,
|
|
793
|
+
);
|
|
794
|
+
}
|
|
795
|
+
// Manual mode (default) or auto-integration skipped: show integration guidance
|
|
796
|
+
if (!autoIntegrated) {
|
|
797
|
+
onNotify(
|
|
798
|
+
ORCH_MESSAGES.orchIntegrationManual(batchState.orchBranch, batchState.baseBranch, mergedTaskCount),
|
|
799
|
+
"info",
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
753
804
|
// ── TS-009: Persist terminal state ──
|
|
754
805
|
persistRuntimeState("batch-terminal", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot);
|
|
755
806
|
|
|
@@ -783,5 +834,6 @@ export async function executeOrchBatch(
|
|
|
783
834
|
}
|
|
784
835
|
}
|
|
785
836
|
|
|
837
|
+
|
|
786
838
|
// ── Dashboard Widget (Step 6) ────────────────────────────────────────
|
|
787
839
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
2
|
|
|
3
|
-
import { execSync } from "child_process";
|
|
3
|
+
import { execSync, execFileSync } from "child_process";
|
|
4
4
|
import { writeFileSync, unlinkSync, mkdirSync } from "fs";
|
|
5
5
|
import { join } from "path";
|
|
6
6
|
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
DEFAULT_TASK_RUNNER_CONFIG,
|
|
10
10
|
FATAL_DISCOVERY_CODES,
|
|
11
11
|
ORCH_MESSAGES,
|
|
12
|
+
StateFileError,
|
|
12
13
|
WorkspaceConfigError,
|
|
13
14
|
computeWaveAssignments,
|
|
14
15
|
createOrchWidget,
|
|
@@ -22,6 +23,7 @@ import {
|
|
|
22
23
|
formatPreflightResults,
|
|
23
24
|
formatWavePlan,
|
|
24
25
|
freshOrchBatchState,
|
|
26
|
+
getCurrentBranch,
|
|
25
27
|
listOrchSessions,
|
|
26
28
|
loadBatchState,
|
|
27
29
|
loadOrchestratorConfig,
|
|
@@ -29,6 +31,7 @@ import {
|
|
|
29
31
|
parseOrchSessionNames,
|
|
30
32
|
resumeOrchBatch,
|
|
31
33
|
runDiscovery,
|
|
34
|
+
runGit,
|
|
32
35
|
runPreflight,
|
|
33
36
|
} from "./index.ts";
|
|
34
37
|
import { buildExecutionContext } from "./workspace.ts";
|
|
@@ -42,6 +45,425 @@ import type {
|
|
|
42
45
|
TaskRunnerConfig,
|
|
43
46
|
} from "./index.ts";
|
|
44
47
|
|
|
48
|
+
// ── Integrate Args Parsing ────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
export type IntegrateMode = "ff" | "merge" | "pr";
|
|
51
|
+
|
|
52
|
+
export interface IntegrateArgs {
|
|
53
|
+
mode: IntegrateMode;
|
|
54
|
+
force: boolean;
|
|
55
|
+
orchBranchArg?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Parse `/orch-integrate` command arguments.
|
|
60
|
+
*
|
|
61
|
+
* Supported flags: --merge, --pr, --force
|
|
62
|
+
* Optional positional: orch branch name (e.g., orch/op-batchid)
|
|
63
|
+
*
|
|
64
|
+
* Returns parsed args or an error string if arguments are invalid.
|
|
65
|
+
*/
|
|
66
|
+
export function parseIntegrateArgs(raw: string | undefined): IntegrateArgs | { error: string } {
|
|
67
|
+
const input = raw?.trim() ?? "";
|
|
68
|
+
const tokens = input.split(/\s+/).filter(Boolean);
|
|
69
|
+
|
|
70
|
+
let mode: IntegrateMode = "ff";
|
|
71
|
+
let force = false;
|
|
72
|
+
const positionals: string[] = [];
|
|
73
|
+
let hasMerge = false;
|
|
74
|
+
let hasPr = false;
|
|
75
|
+
|
|
76
|
+
for (const token of tokens) {
|
|
77
|
+
if (token === "--merge") {
|
|
78
|
+
hasMerge = true;
|
|
79
|
+
} else if (token === "--pr") {
|
|
80
|
+
hasPr = true;
|
|
81
|
+
} else if (token === "--force") {
|
|
82
|
+
force = true;
|
|
83
|
+
} else if (token.startsWith("--")) {
|
|
84
|
+
return { error: `Unknown flag: ${token}` };
|
|
85
|
+
} else {
|
|
86
|
+
positionals.push(token);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Mutual exclusion: --merge and --pr cannot be used together
|
|
91
|
+
if (hasMerge && hasPr) {
|
|
92
|
+
return { error: "Cannot use --merge and --pr together. Choose one integration mode." };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (hasMerge) mode = "merge";
|
|
96
|
+
if (hasPr) mode = "pr";
|
|
97
|
+
|
|
98
|
+
if (positionals.length > 1) {
|
|
99
|
+
return { error: `Expected at most one branch argument, got ${positionals.length}: ${positionals.join(", ")}` };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
mode,
|
|
104
|
+
force,
|
|
105
|
+
orchBranchArg: positionals[0],
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Integration Context Resolution ────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Successful result from resolveIntegrationContext.
|
|
113
|
+
*/
|
|
114
|
+
export interface IntegrationContext {
|
|
115
|
+
orchBranch: string;
|
|
116
|
+
baseBranch: string;
|
|
117
|
+
batchId: string;
|
|
118
|
+
currentBranch: string;
|
|
119
|
+
/** Informational messages generated during resolution (e.g., auto-detect notices) */
|
|
120
|
+
notices: string[];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Error result from resolveIntegrationContext.
|
|
125
|
+
*/
|
|
126
|
+
export interface IntegrationContextError {
|
|
127
|
+
error: string;
|
|
128
|
+
/** "info" for non-error states (legacy mode), "error" for real failures */
|
|
129
|
+
severity: "info" | "error";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Dependencies injected into resolveIntegrationContext for testability.
|
|
134
|
+
*/
|
|
135
|
+
export interface IntegrationDeps {
|
|
136
|
+
loadBatchState: () => PersistedBatchState | null;
|
|
137
|
+
getCurrentBranch: () => string | null;
|
|
138
|
+
listOrchBranches: () => string[];
|
|
139
|
+
orchBranchExists: (branch: string) => boolean;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Pure function to resolve all context needed for /orch-integrate.
|
|
144
|
+
*
|
|
145
|
+
* Resolution order:
|
|
146
|
+
* 1. Try loading persisted batch state → extract orchBranch/baseBranch
|
|
147
|
+
* 2. If state unavailable, use positional CLI arg
|
|
148
|
+
* 3. If neither, scan for orch/* branches
|
|
149
|
+
*
|
|
150
|
+
* Also performs: phase gating, legacy mode detection, branch existence check,
|
|
151
|
+
* detached HEAD check, and branch safety validation.
|
|
152
|
+
*
|
|
153
|
+
* Returns either a fully-resolved IntegrationContext or an IntegrationContextError.
|
|
154
|
+
*/
|
|
155
|
+
export function resolveIntegrationContext(
|
|
156
|
+
parsed: IntegrateArgs,
|
|
157
|
+
deps: IntegrationDeps,
|
|
158
|
+
): IntegrationContext | IntegrationContextError {
|
|
159
|
+
let orchBranch = "";
|
|
160
|
+
let baseBranch = "";
|
|
161
|
+
let batchId = "";
|
|
162
|
+
const notices: string[] = [];
|
|
163
|
+
|
|
164
|
+
// Source 1: Try loading batch state
|
|
165
|
+
try {
|
|
166
|
+
const state = deps.loadBatchState();
|
|
167
|
+
if (state) {
|
|
168
|
+
orchBranch = state.orchBranch ?? "";
|
|
169
|
+
baseBranch = state.baseBranch ?? "";
|
|
170
|
+
batchId = state.batchId;
|
|
171
|
+
|
|
172
|
+
// Phase gate: batch must be completed before integration
|
|
173
|
+
if (state.phase !== "completed") {
|
|
174
|
+
return {
|
|
175
|
+
error:
|
|
176
|
+
`⏳ Batch ${batchId} is currently in "${state.phase}" phase.\n` +
|
|
177
|
+
`Integration requires a completed batch.\n` +
|
|
178
|
+
`Run /orch-status to check progress, or wait for the batch to finish.`,
|
|
179
|
+
severity: "info",
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Legacy merge mode check
|
|
184
|
+
if (!orchBranch) {
|
|
185
|
+
return {
|
|
186
|
+
error:
|
|
187
|
+
`ℹ️ Batch ${batchId} used legacy merge mode — work was already merged directly into ${baseBranch || "the base branch"}.\n` +
|
|
188
|
+
`There is no separate orch branch to integrate.`,
|
|
189
|
+
severity: "info",
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
} catch (err: unknown) {
|
|
194
|
+
// Capture the error but don't return yet — user may have provided a branch arg
|
|
195
|
+
const msg = err instanceof StateFileError
|
|
196
|
+
? (err.code === "STATE_FILE_IO_ERROR"
|
|
197
|
+
? `Could not read batch state file: ${err.message}`
|
|
198
|
+
: err.code === "STATE_FILE_PARSE_ERROR"
|
|
199
|
+
? `Batch state file contains invalid JSON: ${err.message}`
|
|
200
|
+
: `Batch state file has invalid schema: ${err.message}`)
|
|
201
|
+
: `Unexpected error loading batch state: ${(err as Error).message}`;
|
|
202
|
+
if (!parsed.orchBranchArg) {
|
|
203
|
+
return {
|
|
204
|
+
error: `⚠️ ${msg}\nYou can specify the orch branch directly: /orch-integrate <orch-branch>`,
|
|
205
|
+
severity: "error",
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
notices.push(`⚠️ ${msg} — using provided branch arg instead.`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Source 2: CLI positional branch arg overrides or fills in
|
|
212
|
+
if (parsed.orchBranchArg) {
|
|
213
|
+
orchBranch = parsed.orchBranchArg;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Source 3: Neither state nor arg — scan for orch/* branches
|
|
217
|
+
if (!orchBranch) {
|
|
218
|
+
const candidates = deps.listOrchBranches();
|
|
219
|
+
if (candidates.length === 0) {
|
|
220
|
+
return {
|
|
221
|
+
error:
|
|
222
|
+
"❌ No completed batch found and no orch branches exist.\n" +
|
|
223
|
+
"Run /orch first to create a batch, or specify a branch: /orch-integrate <orch-branch>",
|
|
224
|
+
severity: "error",
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
if (candidates.length === 1) {
|
|
228
|
+
orchBranch = candidates[0];
|
|
229
|
+
notices.push(`ℹ️ No batch state found. Auto-detected orch branch: ${orchBranch}`);
|
|
230
|
+
} else {
|
|
231
|
+
return {
|
|
232
|
+
error:
|
|
233
|
+
`❌ No batch state found and multiple orch branches exist:\n` +
|
|
234
|
+
candidates.map(b => ` • ${b}`).join("\n") +
|
|
235
|
+
`\n\nSpecify which branch to integrate: /orch-integrate <orch-branch>`,
|
|
236
|
+
severity: "error",
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Verify orch branch exists
|
|
242
|
+
if (!deps.orchBranchExists(orchBranch)) {
|
|
243
|
+
return {
|
|
244
|
+
error: `❌ Branch "${orchBranch}" does not exist locally.\nCheck the branch name and try again.`,
|
|
245
|
+
severity: "error",
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Detached HEAD check
|
|
250
|
+
const currentBranch = deps.getCurrentBranch();
|
|
251
|
+
if (currentBranch === null) {
|
|
252
|
+
return {
|
|
253
|
+
error:
|
|
254
|
+
"❌ HEAD is detached — cannot integrate.\n" +
|
|
255
|
+
"Check out a branch first (e.g., `git checkout main`), then retry.",
|
|
256
|
+
severity: "error",
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Infer baseBranch from current branch when state is unavailable
|
|
261
|
+
if (!baseBranch) {
|
|
262
|
+
baseBranch = currentBranch;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Branch safety: current branch must match baseBranch (unless --force)
|
|
266
|
+
if (currentBranch !== baseBranch && !parsed.force) {
|
|
267
|
+
return {
|
|
268
|
+
error:
|
|
269
|
+
`⚠️ Batch was started from ${baseBranch}, but you're on ${currentBranch}.\n` +
|
|
270
|
+
`Switch to ${baseBranch} first, or use /orch-integrate --force to skip this check.`,
|
|
271
|
+
severity: "error",
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
orchBranch,
|
|
277
|
+
baseBranch,
|
|
278
|
+
batchId,
|
|
279
|
+
currentBranch,
|
|
280
|
+
notices,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ── Integration Execution ─────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Result of an integration attempt.
|
|
288
|
+
*/
|
|
289
|
+
export interface IntegrationResult {
|
|
290
|
+
/** Whether the integration succeeded overall */
|
|
291
|
+
success: boolean;
|
|
292
|
+
/** True if work was integrated locally (ff/merge) — controls cleanup eligibility */
|
|
293
|
+
integratedLocally: boolean;
|
|
294
|
+
/** Number of commits applied (informational) */
|
|
295
|
+
commitCount: string;
|
|
296
|
+
/** User-facing success message */
|
|
297
|
+
message: string;
|
|
298
|
+
/** User-facing error message (only when success=false) */
|
|
299
|
+
error?: string;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Dependencies injected into executeIntegration for testability.
|
|
304
|
+
*/
|
|
305
|
+
export interface IntegrationExecDeps {
|
|
306
|
+
runGit: (args: string[]) => { ok: boolean; stdout: string; stderr: string };
|
|
307
|
+
runCommand: (cmd: string, args: string[]) => { ok: boolean; stdout: string; stderr: string };
|
|
308
|
+
deleteBatchState: () => void;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Execute the integration operation for the resolved context.
|
|
313
|
+
*
|
|
314
|
+
* Mode-specific behavior:
|
|
315
|
+
* - ff: `git merge --ff-only {orchBranch}`. On failure → suggest --merge/--pr.
|
|
316
|
+
* - merge: `git merge {orchBranch} --no-edit`. On failure → show stderr.
|
|
317
|
+
* - pr: `git push origin {orchBranch}` then `gh pr create`. Never cleans up locally.
|
|
318
|
+
*
|
|
319
|
+
* Cleanup (local branch deletion + state file removal) is gated on integratedLocally === true.
|
|
320
|
+
* Cleanup failures are non-fatal (included as warnings in the message).
|
|
321
|
+
*/
|
|
322
|
+
export function executeIntegration(
|
|
323
|
+
mode: IntegrateMode,
|
|
324
|
+
context: IntegrationContext,
|
|
325
|
+
deps: IntegrationExecDeps,
|
|
326
|
+
): IntegrationResult {
|
|
327
|
+
const { orchBranch, currentBranch, batchId } = context;
|
|
328
|
+
|
|
329
|
+
if (mode === "ff") {
|
|
330
|
+
// Fast-forward merge
|
|
331
|
+
const result = deps.runGit(["merge", "--ff-only", orchBranch]);
|
|
332
|
+
if (!result.ok) {
|
|
333
|
+
return {
|
|
334
|
+
success: false,
|
|
335
|
+
integratedLocally: false,
|
|
336
|
+
commitCount: "0",
|
|
337
|
+
message: "",
|
|
338
|
+
error:
|
|
339
|
+
`❌ Fast-forward failed — branches have diverged.\n` +
|
|
340
|
+
`${result.stderr}\n\n` +
|
|
341
|
+
`Try:\n` +
|
|
342
|
+
` /orch-integrate --merge Create a merge commit\n` +
|
|
343
|
+
` /orch-integrate --pr Create a pull request instead`,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
// Count commits that were applied
|
|
347
|
+
const countResult = deps.runGit(["rev-list", "--count", `${orchBranch}..HEAD`]);
|
|
348
|
+
// After ff, HEAD === orchBranch tip so we use a different measurement
|
|
349
|
+
// The rev-list before the merge was computed in the handler; pass commitCount through context
|
|
350
|
+
// Actually, for ff: commits applied = what was ahead before merge.
|
|
351
|
+
// After ff merge HEAD moved forward, so we measure from the merge-base.
|
|
352
|
+
// Simplest: use "merge was successful" and the pre-computed count from the handler.
|
|
353
|
+
return performCleanup(deps, orchBranch, {
|
|
354
|
+
success: true,
|
|
355
|
+
integratedLocally: true,
|
|
356
|
+
commitCount: "?", // Overridden by caller with pre-computed count
|
|
357
|
+
message: `✅ Fast-forwarded ${currentBranch} to ${orchBranch}.`,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (mode === "merge") {
|
|
362
|
+
const result = deps.runGit(["merge", orchBranch, "--no-edit"]);
|
|
363
|
+
if (!result.ok) {
|
|
364
|
+
return {
|
|
365
|
+
success: false,
|
|
366
|
+
integratedLocally: false,
|
|
367
|
+
commitCount: "0",
|
|
368
|
+
message: "",
|
|
369
|
+
error:
|
|
370
|
+
`❌ Merge failed — there may be conflicts.\n` +
|
|
371
|
+
`${result.stderr}\n\n` +
|
|
372
|
+
`Resolve conflicts manually, or try:\n` +
|
|
373
|
+
` /orch-integrate --pr Create a pull request instead`,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
return performCleanup(deps, orchBranch, {
|
|
377
|
+
success: true,
|
|
378
|
+
integratedLocally: true,
|
|
379
|
+
commitCount: "?",
|
|
380
|
+
message: `✅ Merged ${orchBranch} into ${currentBranch} (merge commit created).`,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// PR mode
|
|
385
|
+
// Step 1: Push the orch branch to origin
|
|
386
|
+
const pushResult = deps.runGit(["push", "origin", orchBranch]);
|
|
387
|
+
if (!pushResult.ok) {
|
|
388
|
+
return {
|
|
389
|
+
success: false,
|
|
390
|
+
integratedLocally: false,
|
|
391
|
+
commitCount: "0",
|
|
392
|
+
message: "",
|
|
393
|
+
error:
|
|
394
|
+
`❌ Failed to push ${orchBranch} to origin.\n` +
|
|
395
|
+
`${pushResult.stderr}\n\n` +
|
|
396
|
+
`Check your remote configuration and try again.`,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Step 2: Create pull request via gh CLI
|
|
401
|
+
const prTitle = batchId
|
|
402
|
+
? `Integrate orch batch ${batchId}`
|
|
403
|
+
: `Integrate ${orchBranch}`;
|
|
404
|
+
const ghResult = deps.runCommand("gh", [
|
|
405
|
+
"pr", "create",
|
|
406
|
+
"--base", currentBranch,
|
|
407
|
+
"--head", orchBranch,
|
|
408
|
+
"--title", prTitle,
|
|
409
|
+
"--fill",
|
|
410
|
+
]);
|
|
411
|
+
if (!ghResult.ok) {
|
|
412
|
+
return {
|
|
413
|
+
success: false,
|
|
414
|
+
integratedLocally: false,
|
|
415
|
+
commitCount: "0",
|
|
416
|
+
message: "",
|
|
417
|
+
error:
|
|
418
|
+
`❌ Branch pushed but PR creation failed.\n` +
|
|
419
|
+
`${ghResult.stderr}\n\n` +
|
|
420
|
+
`The branch ${orchBranch} is on origin — create the PR manually.`,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const prUrl = ghResult.stdout.trim();
|
|
425
|
+
return {
|
|
426
|
+
success: true,
|
|
427
|
+
integratedLocally: false, // PR mode: branch must survive
|
|
428
|
+
commitCount: "0",
|
|
429
|
+
message:
|
|
430
|
+
`✅ Pull request created for ${orchBranch} → ${currentBranch}.\n` +
|
|
431
|
+
(prUrl ? ` ${prUrl}\n` : "") +
|
|
432
|
+
`\nThe orch branch has been kept (needed for the PR).`,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Perform post-integration cleanup: delete local orch branch and batch state.
|
|
438
|
+
* Cleanup failures are non-fatal — warnings are appended to the result message.
|
|
439
|
+
*/
|
|
440
|
+
function performCleanup(
|
|
441
|
+
deps: IntegrationExecDeps,
|
|
442
|
+
orchBranch: string,
|
|
443
|
+
result: IntegrationResult,
|
|
444
|
+
): IntegrationResult {
|
|
445
|
+
const warnings: string[] = [];
|
|
446
|
+
|
|
447
|
+
// Delete local orch branch
|
|
448
|
+
const branchDelete = deps.runGit(["branch", "-D", orchBranch]);
|
|
449
|
+
if (!branchDelete.ok) {
|
|
450
|
+
warnings.push(`⚠️ Could not delete local branch ${orchBranch}: ${branchDelete.stderr}`);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Delete batch state file
|
|
454
|
+
try {
|
|
455
|
+
deps.deleteBatchState();
|
|
456
|
+
} catch (err: unknown) {
|
|
457
|
+
warnings.push(`⚠️ Could not clean up batch state: ${(err as Error).message}`);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (warnings.length > 0) {
|
|
461
|
+
result.message += "\n" + warnings.join("\n");
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return result;
|
|
465
|
+
}
|
|
466
|
+
|
|
45
467
|
// ── Extension ────────────────────────────────────────────────────────
|
|
46
468
|
|
|
47
469
|
export default function (pi: ExtensionAPI) {
|
|
@@ -647,6 +1069,143 @@ export default function (pi: ExtensionAPI) {
|
|
|
647
1069
|
},
|
|
648
1070
|
});
|
|
649
1071
|
|
|
1072
|
+
pi.registerCommand("orch-integrate", {
|
|
1073
|
+
description: "Integrate completed orch batch into your working branch",
|
|
1074
|
+
handler: async (args, ctx) => {
|
|
1075
|
+
// Show usage if no args and no active batch state to infer from
|
|
1076
|
+
if (args?.trim() === "--help" || args?.trim() === "-h") {
|
|
1077
|
+
ctx.ui.notify(
|
|
1078
|
+
"Usage: /orch-integrate [<orch-branch>] [--merge] [--pr] [--force]\n\n" +
|
|
1079
|
+
"Integrate a completed orch batch into your working branch.\n\n" +
|
|
1080
|
+
"Modes:\n" +
|
|
1081
|
+
" (default) Fast-forward merge (cleanest history)\n" +
|
|
1082
|
+
" --merge Create a real merge commit\n" +
|
|
1083
|
+
" --pr Push orch branch and create a pull request\n\n" +
|
|
1084
|
+
"Options:\n" +
|
|
1085
|
+
" --force Skip branch safety check\n" +
|
|
1086
|
+
" <branch> Orch branch name (auto-detected from batch state if omitted)\n\n" +
|
|
1087
|
+
"Examples:\n" +
|
|
1088
|
+
" /orch-integrate Auto-detect and fast-forward\n" +
|
|
1089
|
+
" /orch-integrate --merge Auto-detect with merge commit\n" +
|
|
1090
|
+
" /orch-integrate orch/op-abc123 --pr Specific branch, create PR\n" +
|
|
1091
|
+
" /orch-integrate --force Skip branch safety check",
|
|
1092
|
+
"info",
|
|
1093
|
+
);
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
if (!requireExecCtx(ctx)) return;
|
|
1098
|
+
|
|
1099
|
+
// Parse arguments
|
|
1100
|
+
const parsed = parseIntegrateArgs(args);
|
|
1101
|
+
if ("error" in parsed) {
|
|
1102
|
+
ctx.ui.notify(`❌ ${parsed.error}\n\nRun /orch-integrate --help for usage.`, "error");
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// ── Step 2: Resolve integration context ──────────────────
|
|
1107
|
+
const { repoRoot } = execCtx!;
|
|
1108
|
+
const resolution = resolveIntegrationContext(parsed, {
|
|
1109
|
+
loadBatchState: () => loadBatchState(repoRoot),
|
|
1110
|
+
getCurrentBranch: () => getCurrentBranch(repoRoot),
|
|
1111
|
+
listOrchBranches: () => {
|
|
1112
|
+
const result = runGit(["branch", "--list", "orch/*"], repoRoot);
|
|
1113
|
+
return result.ok
|
|
1114
|
+
? result.stdout.split("\n").map(b => b.replace(/^\*?\s+/, "").trim()).filter(Boolean)
|
|
1115
|
+
: [];
|
|
1116
|
+
},
|
|
1117
|
+
orchBranchExists: (branch: string) => {
|
|
1118
|
+
return runGit(["rev-parse", "--verify", `refs/heads/${branch}`], repoRoot).ok;
|
|
1119
|
+
},
|
|
1120
|
+
});
|
|
1121
|
+
|
|
1122
|
+
if ("error" in resolution) {
|
|
1123
|
+
const severity = (resolution as IntegrationContextError).severity;
|
|
1124
|
+
ctx.ui.notify(resolution.error, severity === "info" ? "info" : "error");
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
const { orchBranch, baseBranch, batchId, currentBranch, notices } = resolution as IntegrationContext;
|
|
1129
|
+
|
|
1130
|
+
// Show any notices from resolution (auto-detection messages, warnings)
|
|
1131
|
+
for (const notice of notices) {
|
|
1132
|
+
ctx.ui.notify(notice, "info");
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// ── Step 2: Pre-integration summary ──────────────────────
|
|
1136
|
+
// Count commits ahead
|
|
1137
|
+
const revListResult = runGit(
|
|
1138
|
+
["rev-list", "--count", `${currentBranch}..${orchBranch}`],
|
|
1139
|
+
repoRoot,
|
|
1140
|
+
);
|
|
1141
|
+
const commitsAhead = revListResult.ok ? revListResult.stdout.trim() : "?";
|
|
1142
|
+
|
|
1143
|
+
// Get diff summary
|
|
1144
|
+
const diffStatResult = runGit(
|
|
1145
|
+
["diff", "--stat", `${currentBranch}...${orchBranch}`],
|
|
1146
|
+
repoRoot,
|
|
1147
|
+
);
|
|
1148
|
+
const diffSummary = diffStatResult.ok ? diffStatResult.stdout.trim() : "(unable to compute diff)";
|
|
1149
|
+
|
|
1150
|
+
ctx.ui.notify(
|
|
1151
|
+
`🔀 Integration Summary\n` +
|
|
1152
|
+
`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n` +
|
|
1153
|
+
` Orch branch: ${orchBranch}\n` +
|
|
1154
|
+
` Target: ${currentBranch}\n` +
|
|
1155
|
+
` Commits: ${commitsAhead} ahead\n` +
|
|
1156
|
+
` Mode: ${parsed.mode === "ff" ? "fast-forward" : parsed.mode === "merge" ? "merge commit" : "pull request"}\n` +
|
|
1157
|
+
(batchId ? ` Batch: ${batchId}\n` : "") +
|
|
1158
|
+
(parsed.force ? ` ⚠ Force: branch safety check skipped\n` : "") +
|
|
1159
|
+
`\n` +
|
|
1160
|
+
(diffSummary ? `${diffSummary}\n` : "") +
|
|
1161
|
+
`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`,
|
|
1162
|
+
"info",
|
|
1163
|
+
);
|
|
1164
|
+
|
|
1165
|
+
// ── Step 3: Execute integration mode ─────────────────
|
|
1166
|
+
const integrationResult = executeIntegration(parsed.mode, resolution as IntegrationContext, {
|
|
1167
|
+
runGit: (gitArgs: string[]) => runGit(gitArgs, repoRoot),
|
|
1168
|
+
runCommand: (cmd: string, cmdArgs: string[]) => {
|
|
1169
|
+
try {
|
|
1170
|
+
const stdout = execFileSync(cmd, cmdArgs, {
|
|
1171
|
+
encoding: "utf-8",
|
|
1172
|
+
timeout: 60_000,
|
|
1173
|
+
cwd: repoRoot,
|
|
1174
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1175
|
+
}).trim();
|
|
1176
|
+
return { ok: true, stdout, stderr: "" };
|
|
1177
|
+
} catch (err: unknown) {
|
|
1178
|
+
const e = err as { stdout?: string; stderr?: string; message?: string };
|
|
1179
|
+
return {
|
|
1180
|
+
ok: false,
|
|
1181
|
+
stdout: (e.stdout ?? "").toString().trim(),
|
|
1182
|
+
stderr: (e.stderr ?? e.message ?? "unknown error").toString().trim(),
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1185
|
+
},
|
|
1186
|
+
deleteBatchState: () => deleteBatchState(repoRoot),
|
|
1187
|
+
});
|
|
1188
|
+
|
|
1189
|
+
if (!integrationResult.success) {
|
|
1190
|
+
ctx.ui.notify(integrationResult.error!, "error");
|
|
1191
|
+
return;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// Override commit count with pre-computed value for local integrations
|
|
1195
|
+
if (integrationResult.integratedLocally) {
|
|
1196
|
+
integrationResult.commitCount = commitsAhead;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
ctx.ui.notify(
|
|
1200
|
+
integrationResult.message +
|
|
1201
|
+
(integrationResult.integratedLocally
|
|
1202
|
+
? `\n${integrationResult.commitCount} commit(s) applied.`
|
|
1203
|
+
: ""),
|
|
1204
|
+
"info",
|
|
1205
|
+
);
|
|
1206
|
+
},
|
|
1207
|
+
});
|
|
1208
|
+
|
|
650
1209
|
// ── Settings TUI ─────────────────────────────────────────────────
|
|
651
1210
|
|
|
652
1211
|
pi.registerCommand("taskplane-settings", {
|
|
@@ -719,7 +1278,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
719
1278
|
"/orch <areas|all> Start batch execution\n" +
|
|
720
1279
|
"/orch-plan <areas|all> Preview execution plan\n" +
|
|
721
1280
|
"/orch-deps <areas|all> Show dependency graph\n" +
|
|
722
|
-
"/orch-sessions List TMUX sessions"
|
|
1281
|
+
"/orch-sessions List TMUX sessions\n" +
|
|
1282
|
+
"/orch-integrate Integrate orch branch into working branch",
|
|
723
1283
|
"info",
|
|
724
1284
|
);
|
|
725
1285
|
|
|
@@ -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
|
|
|
@@ -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);
|