Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions bin/taskplane.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,6 @@ orchestrator:
max_lanes: ${vars.max_lanes}
worktree_location: "subdirectory"
worktree_prefix: "${vars.worktree_prefix}"
integration_branch: "${vars.integration_branch}"
batch_id_format: "timestamp"
spawn_mode: "subprocess"
tmux_prefix: "${vars.tmux_prefix}"
Expand Down Expand Up @@ -707,7 +706,6 @@ function getPresetVars(preset, projectRoot, tasksRootOverride = null) {
const { test: test_cmd, build: build_cmd } = detectStack(projectRoot);
return {
project_name: dirName,
integration_branch: "main",
max_lanes: 3,
worktree_prefix: `${slug}-wt`,
tmux_prefix: `${slug}-orch`,
Expand All @@ -725,7 +723,6 @@ async function getInteractiveVars(projectRoot, tasksRootOverride = null) {
const detected = detectStack(projectRoot);

const project_name = await ask("Project name", dirName);
const integration_branch = await ask("Default branch (fallback — orchestrator uses your current branch at runtime)", "main");
const max_lanes = parseInt(await ask("Max parallel lanes", "3")) || 3;
const tasks_root = tasksRootOverride || await ask("Tasks directory", "taskplane-tasks");
const default_area = await ask("Default area name", "general");
Expand All @@ -736,7 +733,6 @@ async function getInteractiveVars(projectRoot, tasksRootOverride = null) {
const slug = slugify(project_name);
return {
project_name,
integration_branch,
max_lanes,
worktree_prefix: `${slug}-wt`,
tmux_prefix: `${slug}-orch`,
Expand Down
3 changes: 0 additions & 3 deletions docs/how-to/configure-task-orchestrator.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ orchestrator:
max_lanes: 3
worktree_location: "subdirectory"
worktree_prefix: "taskplane-wt"
integration_branch: "main"
batch_id_format: "timestamp"
spawn_mode: "subprocess"
tmux_prefix: "orch"
Expand All @@ -34,7 +33,6 @@ orchestrator:
- `worktree_location`:
- `subdirectory` → `.worktrees/<prefix>-N`
- `sibling` → `../<prefix>-N`
- `integration_branch`: branch merges target
- `spawn_mode`:
- `subprocess`: headless, no tmux dependency
- `tmux`: attachable sessions for deep visibility
Expand Down Expand Up @@ -132,7 +130,6 @@ Polling interval (seconds) for orchestrator monitoring loop.
orchestrator:
max_lanes: 3
spawn_mode: "subprocess"
integration_branch: "main"

failure:
on_task_failure: "skip-dependents"
Expand Down
1 change: 0 additions & 1 deletion docs/reference/configuration/task-orchestrator.yaml.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ monitoring:
| `orchestrator.max_lanes` | number | `3` | Maximum parallel execution lanes/worktrees. |
| `orchestrator.worktree_location` | `"sibling"` \| `"subdirectory"` | `"subdirectory"` | Where lane worktree directories are created. |
| `orchestrator.worktree_prefix` | string | `"taskplane-wt"` | Prefix used for worktree directory names and lane branch naming. |
| `orchestrator.integration_branch` | string | `"main"` | Branch that lane changes merge into. |
| `orchestrator.batch_id_format` | `"timestamp"` \| `"sequential"` | `"timestamp"` | Batch ID format used in logs/branch naming. |
| `orchestrator.spawn_mode` | `"tmux"` \| `"subprocess"` | `"subprocess"` | How lane sessions are spawned. |
| `orchestrator.tmux_prefix` | string | `"orch"` | Prefix for orchestrator tmux sessions (tmux mode). |
Expand Down
21 changes: 17 additions & 4 deletions extensions/taskplane/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { join } from "path";
import { formatDiscoveryResults, runDiscovery } from "./discovery.ts";
import { execLog, executeWave, tmuxKillSession } from "./execution.ts";
import type { MonitorUpdateCallback } from "./execution.ts";
import { runGit } from "./git.ts";
import { getCurrentBranch, runGit } from "./git.ts";
import { mergeWave } from "./merge.ts";
import { ORCH_MESSAGES } from "./messages.ts";
import { deleteBatchState, loadBatchHistory, persistRuntimeState, saveBatchHistory, seedPendingOutcomesForAllocatedLanes, syncTaskOutcomesFromMonitor, upsertTaskOutcome } from "./persistence.ts";
Expand Down Expand Up @@ -51,6 +51,17 @@ export async function executeOrchBatch(
batchState.pauseSignal = { paused: false };
batchState.mergeResults = [];

// Capture the current branch as the base for worktrees and merge target
const detectedBranch = getCurrentBranch(repoRoot);
if (!detectedBranch) {
batchState.phase = "failed";
batchState.endedAt = Date.now();
batchState.errors.push("Cannot determine current branch (detached HEAD or not a git repo)");
onNotify("❌ Cannot determine current branch. Ensure HEAD is on a branch (not detached).", "error");
return;
}
batchState.baseBranch = detectedBranch;

// When true, final cleanup is skipped so failed merge state is preserved
// for manual intervention and TS-009 resume flow.
let preserveWorktreesForResume = false;
Expand Down Expand Up @@ -215,6 +226,7 @@ export async function executeOrchBatch(
batchState.batchId,
batchState.pauseSignal,
depGraph,
batchState.baseBranch,
handleWaveMonitorUpdate,
(lanes) => {
latestAllocatedLanes = lanes;
Expand Down Expand Up @@ -321,6 +333,7 @@ export async function executeOrchBatch(
orchConfig,
repoRoot,
batchState.batchId,
batchState.baseBranch,
);
allMergeResults.push(mergeResult);
batchState.mergeResults.push(mergeResult);
Expand Down Expand Up @@ -474,7 +487,7 @@ export async function executeOrchBatch(
"info",
);

const targetBranch = orchConfig.orchestrator.integration_branch;
const targetBranch = batchState.baseBranch;
for (const wt of existingWorktrees) {
const resetResult = safeResetWorktree(wt, targetBranch, repoRoot);
if (!resetResult.success) {
Expand Down Expand Up @@ -653,8 +666,8 @@ export async function executeOrchBatch(
}
} catch { /* .pi dir may not exist */ }

// Clean up worktrees — pass integration branch to protect unmerged work
const targetBranch = orchConfig.orchestrator.integration_branch;
// Clean up worktrees — pass base branch to protect unmerged work
const targetBranch = batchState.baseBranch;
execLog("batch", batchState.batchId, "cleaning up worktrees");
const removeResult = removeAllWorktrees(prefix, repoRoot, targetBranch);

Expand Down
6 changes: 4 additions & 2 deletions extensions/taskplane/execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1403,7 +1403,7 @@ export function computeTransitiveDependents(
*
* This function checks each wave task's folder for untracked or modified files,
* stages them, and creates a commit on the current branch. This must run BEFORE
* allocateLanes() so that worktrees (which are based on the integration branch)
* allocateLanes() so that worktrees (which are based on the batch's base branch)
* include the task files.
*
* Only task-specific folders are staged — no other working tree changes are touched.
Expand Down Expand Up @@ -1522,6 +1522,7 @@ export function ensureTaskFilesCommitted(
* @param batchId - Batch ID for naming
* @param pauseSignal - Shared pause signal (mutated by stop-wave policy)
* @param dependencyGraph - Dependency graph for computing transitive dependents
* @param baseBranch - Branch to base worktrees on (captured at batch start)
* @param onMonitorUpdate - Optional callback for dashboard updates during monitoring
* @param onLanesAllocated - Optional callback fired after lane allocation succeeds
* @returns WaveExecutionResult with outcomes and blocked task IDs
Expand All @@ -1535,6 +1536,7 @@ export async function executeWave(
batchId: string,
pauseSignal: { paused: boolean },
dependencyGraph: DependencyGraph,
baseBranch: string,
onMonitorUpdate?: MonitorUpdateCallback,
onLanesAllocated?: (lanes: AllocatedLane[]) => void,
): Promise<WaveExecutionResult> {
Expand Down Expand Up @@ -1576,7 +1578,7 @@ export async function executeWave(
}

// ── Stage 1: Allocate lanes ──────────────────────────────────
const allocResult = allocateLanes(waveTasks, pending, config, repoRoot, batchId);
const allocResult = allocateLanes(waveTasks, pending, config, repoRoot, batchId, baseBranch);

if (!allocResult.success) {
const errMsg = allocResult.error?.message || "Unknown allocation failure";
Expand Down
18 changes: 18 additions & 0 deletions extensions/taskplane/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,24 @@
import { execFileSync } from "child_process";


// ── Branch Helpers ───────────────────────────────────────────────────

/**
* Get the current branch name (the branch checked out in the given directory).
*
* Uses `git rev-parse --abbrev-ref HEAD`. Returns the branch name or null
* if HEAD is detached or git fails.
*
* @param cwd - Working directory (defaults to process.cwd())
*/
export function getCurrentBranch(cwd?: string): string | null {
const result = runGit(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
if (!result.ok || !result.stdout.trim() || result.stdout.trim() === "HEAD") {
return null;
}
return result.stdout.trim();
}

// ── Git Command Runner ───────────────────────────────────────────────

/**
Expand Down
8 changes: 5 additions & 3 deletions extensions/taskplane/merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,7 @@ export function waitForMergeResult(
}

/**
* Merge a completed wave's lane branches into the integration branch.
* Merge a completed wave's lane branches into the base branch.
*
* Orchestration flow:
* 1. Filter to only succeeded lanes (failed lanes are not merged)
Expand All @@ -464,7 +464,7 @@ export function waitForMergeResult(
* e. Handle result (continue, log, or pause)
* 4. Return MergeWaveResult
*
* Sequential execution is mandatory — the integration branch is a shared
* Sequential execution is mandatory — the base branch is a shared
* resource, and each merge must see the prior merge's result.
*
* On CONFLICT_UNRESOLVED or BUILD_FAILURE: stops merging remaining lanes
Expand All @@ -479,6 +479,7 @@ export function waitForMergeResult(
* @param config - Orchestrator configuration
* @param repoRoot - Main repository root
* @param batchId - Batch ID for session naming
* @param baseBranch - Branch to merge into (captured at batch start)
* @returns MergeWaveResult with per-lane outcomes
*/
export function mergeWave(
Expand All @@ -488,10 +489,11 @@ export function mergeWave(
config: OrchestratorConfig,
repoRoot: string,
batchId: string,
baseBranch: string,
): MergeWaveResult {
const startTime = Date.now();
const tmuxPrefix = config.orchestrator.tmux_prefix;
const targetBranch = config.orchestrator.integration_branch;
const targetBranch = baseBranch;
const laneResults: MergeLaneResult[] = [];

// Build lane outcome lookup for merge eligibility checks.
Expand Down
15 changes: 15 additions & 0 deletions extensions/taskplane/persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,15 @@ export function validatePersistedState(data: unknown): PersistedBatchState {
}
}

// ── Optional string fields (backward-compatible) ─────────────
// baseBranch was added after schema v1; default to empty string if missing
if (obj.baseBranch !== undefined && typeof obj.baseBranch !== "string") {
throw new StateFileError(
"STATE_SCHEMA_INVALID",
`Invalid "baseBranch" field (expected string, got ${typeof obj.baseBranch})`,
);
}

// ── Phase enum validation ────────────────────────────────────
if (!VALID_BATCH_PHASES.has(obj.phase as string)) {
throw new StateFileError(
Expand Down Expand Up @@ -520,6 +529,11 @@ export function validatePersistedState(data: unknown): PersistedBatchState {
}
}

// Default baseBranch for backward compatibility with older state files
if (!obj.baseBranch) {
(obj as any).baseBranch = "";
}

return obj as unknown as PersistedBatchState;
}

Expand Down Expand Up @@ -612,6 +626,7 @@ export function serializeBatchState(
schemaVersion: BATCH_STATE_SCHEMA_VERSION,
phase: state.phase,
batchId: state.batchId,
baseBranch: state.baseBranch,
startedAt: state.startedAt,
updatedAt: now,
endedAt: state.endedAt,
Expand Down
12 changes: 8 additions & 4 deletions extensions/taskplane/resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,7 @@ export async function resumeOrchBatch(
// ── 6. Reconstruct runtime state ─────────────────────────────
batchState.phase = "executing";
batchState.batchId = persistedState.batchId;
batchState.baseBranch = persistedState.baseBranch || "";
batchState.startedAt = persistedState.startedAt;
batchState.pauseSignal = { paused: false };
batchState.totalWaves = persistedState.totalWaves;
Expand Down Expand Up @@ -684,6 +685,7 @@ export async function resumeOrchBatch(
orchConfig,
repoRoot,
batchState.batchId,
batchState.baseBranch,
);

if (reExecMergeResult.status === "succeeded") {
Expand All @@ -693,7 +695,7 @@ export async function resumeOrchBatch(
);

// Clean up merged branches
const targetBranch = orchConfig.orchestrator.integration_branch;
const targetBranch = batchState.baseBranch;
for (const lr of reExecMergeResult.laneResults) {
if (lr.result?.status === "SUCCESS" || lr.result?.status === "CONFLICT_RESOLVED") {
deleteBranchBestEffort(lr.sourceBranch, repoRoot);
Expand Down Expand Up @@ -809,6 +811,7 @@ export async function resumeOrchBatch(
batchState.batchId,
batchState.pauseSignal,
depGraph,
batchState.baseBranch,
handleResumeMonitorUpdate,
(lanes) => {
latestAllocatedLanes = lanes;
Expand Down Expand Up @@ -918,6 +921,7 @@ export async function resumeOrchBatch(
orchConfig,
repoRoot,
batchState.batchId,
batchState.baseBranch,
);
batchState.mergeResults.push(mergeResult);

Expand Down Expand Up @@ -1018,7 +1022,7 @@ export async function resumeOrchBatch(

// Post-merge: reset worktrees for next wave
if (mergeResult && mergeResult.status === "succeeded") {
const targetBranch = orchConfig.orchestrator.integration_branch;
const targetBranch = batchState.baseBranch;
for (const lr of mergeResult.laneResults) {
if (lr.result?.status === "SUCCESS" || lr.result?.status === "CONFLICT_RESOLVED") {
const ancestorCheck = runGit(["merge-base", "--is-ancestor", lr.sourceBranch, targetBranch], repoRoot);
Expand All @@ -1033,7 +1037,7 @@ export async function resumeOrchBatch(
const wtPrefix = orchConfig.orchestrator.worktree_prefix;
const existingWorktrees = listWorktrees(wtPrefix, repoRoot);
if (existingWorktrees.length > 0) {
const targetBranch = orchConfig.orchestrator.integration_branch;
const targetBranch = batchState.baseBranch;
for (const wt of existingWorktrees) {
const resetResult = safeResetWorktree(wt, targetBranch, repoRoot);
if (!resetResult.success) {
Expand All @@ -1047,7 +1051,7 @@ export async function resumeOrchBatch(
// ── 11. Cleanup and terminal state ───────────────────────────
if (!preserveWorktreesForResume) {
const wtPrefix = orchConfig.orchestrator.worktree_prefix;
const targetBranch = orchConfig.orchestrator.integration_branch;
const targetBranch = batchState.baseBranch;
removeAllWorktrees(wtPrefix, repoRoot, targetBranch);
}

Expand Down
7 changes: 5 additions & 2 deletions extensions/taskplane/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ export interface OrchestratorConfig {
max_lanes: number;
worktree_location: "sibling" | "subdirectory";
worktree_prefix: string;
integration_branch: string;
batch_id_format: "timestamp" | "sequential";
spawn_mode: "tmux" | "subprocess";
tmux_prefix: string;
Expand Down Expand Up @@ -136,7 +135,6 @@ export const DEFAULT_ORCHESTRATOR_CONFIG: OrchestratorConfig = {
max_lanes: 3,
worktree_location: "subdirectory",
worktree_prefix: "taskplane-wt",
integration_branch: "main",
batch_id_format: "timestamp",
spawn_mode: "subprocess",
tmux_prefix: "orch",
Expand Down Expand Up @@ -790,6 +788,8 @@ export interface OrchBatchRuntimeState {
phase: OrchBatchPhase;
/** Unique batch identifier (timestamp format, e.g., "20260308T214300") */
batchId: string;
/** Branch that was active when /orch started — used as base for worktrees and merge target */
baseBranch: string;
/** Shared pause signal — set by /orch-pause, read by executeLane/executeWave */
pauseSignal: { paused: boolean };
/** All wave results in order (grows as waves complete) */
Expand Down Expand Up @@ -866,6 +866,7 @@ export function freshOrchBatchState(): OrchBatchRuntimeState {
return {
phase: "idle",
batchId: "",
baseBranch: "",
pauseSignal: { paused: false },
waveResults: [],
currentWaveIndex: -1,
Expand Down Expand Up @@ -1208,6 +1209,8 @@ export interface PersistedBatchState {
phase: OrchBatchPhase;
/** Unique batch identifier (timestamp format) */
batchId: string;
/** Branch that was active when /orch started — used as base for worktrees and merge target */
baseBranch: string;
/** Epoch ms when batch started */
startedAt: number;
/** Epoch ms when state was last written */
Expand Down
Loading