Status: Draft — Pending Review
Created: 2026-03-08
Related: task-runner.yaml (.pi/task-runner.yaml) (Historical note: task-runner.ts was removed in v0.26.0)
The current task-runner extension (/task) executes a single task serially.
When a batch of tasks is staged — for example, 12 tasks across time-off,
performance, and notifications — they must be launched one at a time by a human
or run end-to-end in sequence. A batch of 12 medium tasks at ~2 hours each takes
~24 hours of wall-clock time.
The old Ralph Wiggum orchestrator (ralph-orchestrator.ps1) added dependency-
aware wave execution with optional parallelism, but ran parallel agents in the
same working directory. This caused file-writing conflicts: two agents
editing the same Go file, running go build simultaneously, or committing to
the same branch at the same time.
- Parallel execution of independent tasks to reduce wall-clock time
- Zero file conflicts between parallel agents — complete filesystem isolation
- Preserve the task-runner model — STATUS.md as persistent memory, checkpoint discipline, fresh-context worker iterations, cross-model reviews
- Orchestrator as supervisor — the orchestrator manages lifecycle, never does implementation or merge work itself; it delegates to specialized agents
- Deterministic merge — changes from parallel lanes merge cleanly into the integration branch with full traceability
┌─────────────────────────────────────────────────────────────────────┐
│ ORCHESTRATOR EXTENSION │
│ (runs in user's interactive Pi session) │
│ │
│ Responsibilities: │
│ • Parse PROMPT.md files, build dependency DAG │
│ • Compute execution waves via topological sort │
│ • Allocate lanes (worktrees) for each wave │
│ • Spawn TMUX sessions for each lane │
│ • Monitor progress via STATUS.md polling │
│ • Spawn TMUX sessions for merge agents after wave completion │
│ • Render high-level dashboard │
│ • Handle failures, pauses, and user intervention │
│ │
│ Does NOT: implement features, review code, resolve merge conflicts │
└──────┬──────────┬──────────┬──────────┬─────────────────────────────┘
│ │ │ │
│ TMUX: orch-lane-1 │ TMUX: orch-lane-2
│ ┌─────▼─────┐ │ ┌─────▼─────┐
│ │ LANE 1 │ │ │ LANE 2 │ ...up to maxLanes
│ │ │ │ │ │
│ │ Worktree: │ │ │ Worktree: │
│ │ cm-wt-1/ │ │ │ cm-wt-2/ │
│ │ │ │ │ │
│ │ Branch: │ │ │ Branch: │
│ │ lane-1-* │ │ │ lane-2-* │
│ │ │ │ │ │
│ │ ┌───────┐ │ │ │ ┌───────┐ │
│ │ │ pi │ │ │ │ │ pi │ │ Each Pi session has
│ │ │task- │ │ │ │ │task- │ │ full TUI dashboard,
│ │ │runner │ │ │ │ │runner │ │ attachable via TMUX
│ │ └──┬──┬─┘ │ │ │ └──┬──┬─┘ │
│ │ │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ TMUX:│ │ │ │ TMUX:│ │
│ │ orch-lane │ │ │ orch-lane │ Workers/reviewers
│ │ -1-worker │ │ │ -2-worker │ also in TMUX for
│ │ orch-lane │ │ │ orch-lane │ drill-down visibility
│ │ -1-review │ │ │ -2-review │
│ └───────────┘ │ └───────────┘
│ │
.worktrees/merge-workspace/ (isolated merge worktree)
┌──────────────────────────────────────┐
│ MERGE WORKTREE (_merge-temp branch) │ Created before merge phase,
│ │ removed after fast-forward.
│ TMUX: orch-merge-1 (cwd = here) │
│ ┌────────────────────────────────┐ │ Merge agents run sequentially
│ │ MERGE AGENT 1 → lane-1 │ │ inside this worktree. User's
│ └────────────────────────────────┘ │ main repo is untouched.
│ TMUX: orch-merge-2 (cwd = here) │
│ ┌────────────────────────────────┐ │
│ │ MERGE AGENT 2 → lane-2 │ │
│ └────────────────────────────────┘ │
└──────────────────────────────────────┘
│
▼ (after all lanes merged)
git merge --ff-only _merge-temp → develop
The system provides two complementary observability channels:
Web Dashboard (http://localhost:8099) — The primary monitoring interface.
Shows all lanes, tasks, worker stats, and merge status in a browser. Includes
a conversation viewer that renders the live AI worker stream (tool calls, text
output, token usage). No external dependencies — vanilla HTML/CSS/JS with
xterm.js from CDN. Server uses SSE for live push updates.
TMUX drill-down — For deep inspection of the runner TUI or merge agents:
Level 0: Web dashboard at http://localhost:8099
→ Lanes, tasks, worker stats (tools, elapsed, context %)
→ "View" button shows full AI conversation stream
Level 1: tmux attach -t orch-lane-1
→ task-runner TUI dashboard: step cards, progress bars,
worker iteration count, context %, current tool
Level 1: tmux attach -t orch-merge-1
→ Merge agent Pi session: running git merge, resolving conflicts
Workers run in subprocess/JSON mode (not TMUX) when orchestrated, so there is
no orch-lane-N-worker session to attach to. Instead, the full worker
conversation is captured via JSON stream and viewable in the web dashboard.
All TMUX sessions are detachable — attaching to observe has no effect on
execution. Detach with Ctrl+B, d (standard TMUX).
| Component | Type | Location | New/Existing |
|---|---|---|---|
task-orchestrator.ts |
Pi extension | extensions/ |
New |
task-orchestrator.yaml |
Config | .pi/ |
New |
task-merger.md |
Agent definition | .pi/agents/ |
New |
orch-dashboard-web/ |
Web dashboard | extensions/ |
New — server.cjs + public/ |
task-runner.ts |
Pi extension | extensions/ |
Existing — modified (subprocess mode, sidecar files) |
task-worker.md |
Agent definition | .pi/agents/ |
Existing — unchanged |
task-reviewer.md |
Agent definition | .pi/agents/ |
Existing — unchanged |
task-runner.yaml |
Config | .pi/ |
Existing — minor addition (spawn_mode) |
creating-pi-tasks |
Skill | .agents/skills/ |
Existing — minor addition (## File Scope) |
A lane is a serial execution track backed by its own git worktree. Each lane:
- Has its own filesystem (worktree) — full checkout of tracked files
- Has its own git branch — created from
developat wave start - Runs one task at a time serially (using the existing task-runner)
- Persists across tasks within the same wave (if a lane is assigned multiple serial tasks in the same wave)
Lanes are pooled and reused across waves. After a wave completes and merges,
the lane's worktree is updated to the new develop HEAD for the next wave.
The number of active lanes is capped by max_lanes in configuration.
A wave is a set of tasks whose dependencies are all satisfied. Within a wave, all tasks are independent and can run in parallel. Waves execute sequentially — wave N+1 does not start until wave N is fully merged.
Wave 1: [TO-014, OB-005, PS-007] ← 0 dependencies each
Wave 2: [TO-015, OB-006] ← depends on wave 1 tasks
Wave 3: [TO-016] ← depends on TO-015
A merge agent is a short-lived Pi subprocess (using the task-merger.md
agent definition) that handles merging a lane's branch into the integration
branch. The orchestrator spawns one merge agent per lane after wave completion.
Merge agents are responsible for:
- Executing the git merge
- Detecting and classifying conflicts
- Auto-resolving trivial conflicts (different files, different sections)
- Reporting unresolvable conflicts back to the orchestrator
- Verifying the merge result compiles/builds
The orchestrator never runs git merge itself. It delegates to merge agents and reacts to their reported outcomes.
Dependencies are extracted from PROMPT.md files. The orchestrator supports two modes:
- Agent-analyzed — An agent reads all PROMPT.md files and produces a
dependencies.jsonmapping (same as the old ralph-orchestrator) - Declared in PROMPT.md — Tasks declare dependencies in a structured
## Dependenciessection (preferred for new tasks)
Dependency references support two formats:
- Unqualified:
TO-014 - Area-qualified:
time-off/TO-014
If an unqualified task ID matches multiple tasks across areas, dependency
resolution fails with DEP_AMBIGUOUS and the task must be rewritten using the
area-qualified form.
## Dependencies
- **Task:** TO-014 (PTO policy engine must exist before testing accruals)
- **Task:** employee-management/EM-003 (employee batch endpoint needed for name hydration)Tasks can optionally declare which files/directories they touch:
## File Scope
- services/time-service/**
- docs/api/time-service-api.md
- web/src/pages/time-off/**The orchestrator uses file scope for lane assignment optimization: tasks with overlapping file scopes are assigned to the same lane (serial execution) even if they have no declared dependency. This prevents merge conflicts proactively.
File scope is advisory — the orchestrator does not enforce it. If a task modifies files outside its declared scope, the merge agent handles any resulting conflicts.
A git worktree creates an additional checkout of the repository at a different
filesystem location. All worktrees share the same .git database (objects,
pack files, refs). Only the working tree files are duplicated.
C:\dev askplane\ ← Main worktree (orchestrator runs here)
.git\ ← Full git database (~26 MB, shared)
.worktrees\ ← Worktree base directory (gitignored)
taskplane-wt-1\ ← Lane 1 worktree
.git ← Tiny pointer file (~50 bytes)
services\, docs\, web\, ... ← Own copy of working tree (~30 MB)
taskplane-wt-2\ ← Lane 2 worktree
.git ← Tiny pointer file (~50 bytes)
services\, docs\, web\, ... ← Own copy of working tree (~30 MB)
services\, docs\, web\, ... ← Working tree (~30 MB)
Note: The default
worktree_locationis"subdirectory", which places worktrees inside.worktrees/(gitignored). The"sibling"mode places them alongside the repo (e.g.,../taskplane-wt-1/) for environments where nested worktrees are problematic.
| Item | Size | Notes |
|---|---|---|
| Tracked files | ~30 MB | Full checkout of ~2,875 files |
.git pointer |
~50 bytes | Points back to main .git dir |
| Git database | 0 (shared) | Not duplicated |
node_modules |
0 or ~200 MB | Only if npm install is run |
| Go module cache | 0 (shared) | GOMODCACHE is user-level, shared by default |
| Total (code only) | ~30 MB | Trivial for modern disks |
CREATE (at wave start):
git worktree add .worktrees/taskplane-wt-1 -b task/lane-1-{batchId} develop
USE (during wave execution):
# Task-runner subprocess runs inside the worktree directory
cd .worktrees/taskplane-wt-1
pi -e extensions/task-runner.ts ...
MERGE (after wave completion — done by merge agent):
# Back in main worktree
cd C:\dev askplane
git checkout develop
git merge task/lane-1-{batchId} --no-ff
UPDATE (for next wave, if lane is reused):
cd .worktrees/taskplane-wt-1
git rebase develop
# or: git reset --hard develop && git checkout -b task/lane-1-{nextBatchId}
REMOVE (at batch end):
git worktree remove .worktrees/taskplane-wt-1
git branch -d task/lane-1-{batchId}
Branch protection: During cleanup,
removeAllWorktrees()andremoveWorktree()check for unmerged commits before deleting branches. If a branch has commits not reachable from the integration branch, it is preserved assaved/<branch-name>instead of being deleted. This prevents accidental loss of work from incomplete merges or failed lanes.
task/lane-{N}-{batchId}
Where:
N = lane number (1, 2, 3, ...)
batchId = ISO date-time stamp: 20260308T111750
Examples:
task/lane-1-20260308T111750
task/lane-2-20260308T111750
- A branch can only be checked out in one worktree at a time. Each lane gets
its own branch. The
developbranch stays checked out in the main worktree. - Worktrees share refs. A commit made in any worktree is visible to all
worktrees immediately (they share the same
.gitdatabase). - Worktrees share the reflog. This is fine — we don't rely on reflog.
Input: Arguments from /orch command (area names, folder paths, or "all")
Output: List of pending tasks with parsed metadata
1. Resolve arguments to tasks:
The /orch command accepts four types of arguments, which can be mixed:
a. "all" — expands to every area in task-runner.yaml:
/orch all
→ Iterates every key in task_areas, collects all paths
b. Area names — looked up in task-runner.yaml → task_areas:
/orch time-off performance-management
→ task-runner.yaml has:
task_areas:
time-off:
path: "docs/task-management/domains/time-off/tasks"
performance-management:
path: "docs/task-management/domains/performance-management/tasks"
→ Resolved to directory scan of each path
c. Directory paths — scanned for task folders (used as-is if path exists
and is a directory):
/orch docs/task-management/domains/time-off/tasks
→ Scanned for subdirectories containing PROMPT.md
d. Single PROMPT.md file — adds exactly one task:
/orch docs/task-management/domains/time-off/tasks/TO-014-accrual-engine/PROMPT.md
→ Adds this single task directly (no directory scan)
→ The orchestrator still creates a worktree and lane for it
→ Useful for re-running a specific task with full worktree isolation,
or when you only have one task but want the merge-back workflow
Resolution logic (pseudocode):
const taskFolders = [];
const areaScanPaths = [];
for each arg:
if arg === "all":
areaScanPaths.push(...Object.values(task_areas).map(a => a.path))
else if task_areas[arg] exists:
areaScanPaths.push(task_areas[arg].path)
else if arg ends with "PROMPT.md" and file exists:
// Single task — add directly, no scan needed
taskFolders.push(dirname(arg))
else if arg is an existing directory:
areaScanPaths.push(arg)
else:
error("Unknown area, path, or file: {arg}")
// Deduplicate scan paths
areaScanPaths = unique(areaScanPaths)
2. Scan each area path for pending tasks:
a. List immediate subdirectories ONLY — do NOT recurse
b. Skip directories named "archive" (completed tasks live here)
c. For each remaining subdirectory:
- Skip if no PROMPT.md exists
- Skip if .DONE file exists (already complete)
- Extract task ID from folder name (e.g., "TO-014" from "TO-014-accrual-engine")
- Parse PROMPT.md: task name, dependencies, review level, size, file scope
- Add to taskFolders list
Note: archive/ is NOT scanned for PROMPT.md files. It is only checked
for .DONE markers to build the completed-task-ID set (step 3).
3. Build completed task ID set (for dependency resolution):
a. For each area path, check archive/ subdirectory:
- List folders containing .DONE files
- Extract task IDs from folder names
b. Also include any task folders from step 2 that had .DONE files
c. This set is used ONLY to satisfy dependency references —
completed tasks are never re-executed
4. Build task registry: { taskId → ParsedTask }
- Pending tasks from step 2 (will be executed)
- Completed task IDs from step 3 (dependency resolution only)
- Error if duplicate task IDs found across areas
- If a single PROMPT.md was passed (step 1d), the registry may contain
just one task — this is valid. It becomes a single-task wave.
Input: Task registry
Output: Ordered list of waves, each containing independent task IDs
1. Build dependency graph:
a. If dependencies.json exists and is fresh → load it
b. Else → extract from PROMPT.md ## Dependencies sections
c. Option: spawn analysis agent for complex/ambiguous deps
2. Validate graph:
a. Check for circular dependencies (DFS cycle detection)
b. Check for missing dependency targets:
- If a task depends on a task ID not in the registry (neither pending
nor completed), the dependency is "unresolved"
- This happens when a task in one area depends on a task in another
area that wasn't included in the /orch arguments
(e.g., /orch time-off, but TO-015 depends on EM-003 from employee-management)
- Resolution: scan ALL task_areas for the missing task ID
(check for .DONE in its folder or archive)
- If found and complete → add to completed set, dependency satisfied
- If found and NOT complete → error with actionable message:
"TO-015 depends on EM-003 which is pending in 'employee-management'.
Include that area: /orch time-off employee-management"
- If not found in any area → error:
"TO-015 depends on EM-003 which does not exist in any task area"
c. Check for ambiguous dependency targets:
- For unqualified refs (e.g., `EM-003`), if multiple matching tasks are
found across areas, raise `DEP_AMBIGUOUS`
- Action required: use area-qualified dependency refs (e.g.,
`employee-management/EM-003`)
d. Warn on external dependencies (non-task deps like "All services running")
3. Topological sort into waves:
a. Wave 1: all tasks with 0 unmet dependencies
b. Wave 2: tasks whose deps are all in wave 1 or completed
c. ... repeat until all tasks scheduled
4. Apply file scope affinity:
a. Within each wave, group tasks with overlapping file scopes
b. Tasks in the same affinity group → same lane (serial)
Input: Current wave's task list, max_lanes config
Output: Lane assignments, worktrees created
1. Determine lane count: min(tasks in wave, max_lanes)
2. Assign tasks to lanes:
a. Serial-affinity tasks (overlapping file scope) → same lane
b. Remaining tasks → round-robin across lanes
c. Balance by estimated duration (task size: S=1, M=2, L=4)
3. For each lane:
a. If worktree exists from previous wave:
- Reset branch to current develop HEAD
b. If new lane:
- git worktree add .worktrees/{prefix}-{N} -b task/lane-{N}-{batchId} develop
4. Pre-warm dependencies (if configured):
a. Check lane's tasks for file scope touching web/ → npm ci
b. Go module cache is shared by default — no action needed
c. Run pre-warm commands in parallel across lanes
Input: Lane assignments with worktrees ready
Output: Completed task branches with STATUS.md checkpoints
For each lane (in parallel):
For each task assigned to this lane (sequentially):
1. Spawn TMUX session for this task in the lane's worktree:
Environment variables:
TASK_AUTOSTART = relative path from worktree root to this task's PROMPT.md
TASK_RUNNER_SPAWN_MODE = "tmux" (tells task-runner to use TMUX for workers)
TASK_RUNNER_TMUX_PREFIX = "orch-lane-{N}" (prefix for worker/reviewer sessions)
Command:
tmux new-session -d \
-s "orch-lane-{N}" \
-c ".worktrees/{prefix}-{N}" \
"TASK_AUTOSTART='path/to/PROMPT.md' \
TASK_RUNNER_SPAWN_MODE=tmux \
TASK_RUNNER_TMUX_PREFIX='orch-lane-{N}' \
pi --no-session -e extensions/task-runner.ts"
What happens inside the TMUX session:
a. Pi starts, loads task-runner.ts extension
b. task-runner's session_start handler reads TASK_AUTOSTART
c. task-runner resolves the path relative to cwd (the worktree root)
d. task-runner parses PROMPT.md, creates/reads STATUS.md
e. task-runner begins executing — identical to interactive /task behavior
f. task-runner spawns workers in TMUX sessions named
"{TASK_RUNNER_TMUX_PREFIX}-worker" (e.g., orch-lane-1-worker)
g. task-runner spawns reviewers in TMUX sessions named
"{TASK_RUNNER_TMUX_PREFIX}-reviewer" (e.g., orch-lane-1-reviewer)
h. When all steps complete, task-runner creates .DONE file and Pi exits
i. Pi exiting causes the TMUX session to close automatically
2. Monitor progress (orchestrator does this, NOT the lane):
a. Poll STATUS.md in the worktree every poll_interval seconds
Path: .worktrees/{prefix}-{N}/{task-folder}/STATUS.md
b. Parse step statuses and checkbox counts
c. Check TMUX session existence: tmux has-session -t orch-lane-{N}
Exit code 0 = alive, non-zero = session ended
d. Update orchestrator dashboard
e. Detect stalls: if STATUS.md mtime unchanged for stall_timeout → stall
3. Handle completion:
a. .DONE file appears in task folder → task succeeded
b. TMUX session exits AND no .DONE → task failed
c. STATUS.md stall detected → kill TMUX session, mark as stalled
4. Next task in lane:
a. If lane has more tasks assigned:
→ spawn a NEW TMUX session with the same name (orch-lane-{N})
but TASK_AUTOSTART pointing to the next task's PROMPT.md
→ same worktree, so prior task's commits are visible
b. If no more tasks → lane is complete, move to wave merge
Input: Completed lane branches
Output: Updated develop branch with all wave work merged
1. Wait for ALL lanes in the wave to complete (or fail)
2. Classify wave outcome:
a. All lanes succeeded → proceed to merge
b. Some lanes failed → see failure handling (§8)
c. All lanes failed → abort batch
3. Create an ISOLATED MERGE WORKTREE:
Merging in the user's main repo caused persistent failures — any
uncommitted file (user edits, IDE artifacts, orchestrator-generated
files) triggered the merge agent's dirty-worktree guard. The solution
is a dedicated merge worktree that is always clean by construction.
a. Create a temporary branch at the target branch HEAD:
git branch _merge-temp-{batchId} develop
b. Create a merge worktree:
git worktree add .worktrees/merge-workspace _merge-temp-{batchId}
This worktree is separate from lane worktrees and the main repo.
The user's working directory is completely untouched.
4. For each completed lane, spawn a MERGE AGENT (sequential):
Merge agents run SEQUENTIALLY (not in parallel) to avoid
race conditions. Each agent runs inside the merge worktree.
a. Spawn merge agent in a TMUX session with cwd = merge worktree:
tmux new-session -d -s orch-merge-{N} \
-c ".worktrees/merge-workspace" \
"pi --no-session --model {merger_model} \
--append-system-prompt .pi/agents/task-merger.md \
@merge-request.txt"
b. Merge request contains:
- Source branch: task/lane-{N}-{batchId}
- Target branch: develop (for metadata only — agent doesn't checkout)
- Tasks completed in this lane
- File scope of those tasks
- Verification commands to run after merge
- Instruction: "Branch is already checked out — do NOT checkout"
c. Merge agent executes (in the clean merge worktree):
- Verify current branch (sanity check)
- git merge task/lane-{N}-{batchId} --no-ff -m "merge: wave {W} lane {N} — {task IDs}"
- If conflict → classify and attempt resolution
- Run verification (go build, npm run type-check)
- Write result JSON to main repo's .pi/ directory (absolute path)
d. Orchestrator reads merge result:
- SUCCESS → continue to next lane's merge
- CONFLICT_RESOLVED → continue (agent resolved it)
- CONFLICT_UNRESOLVED → pause batch, notify user
- BUILD_FAILURE → pause batch, notify user
Note: Each successive lane merges into _merge-temp which already
contains all prior lanes' changes. Lane 2's merge sees lane 1's
work, enabling proper conflict detection.
5. Fast-forward develop to the merge result:
After all lanes merge successfully into _merge-temp:
a. In the main repo: git merge --ff-only _merge-temp-{batchId}
b. If fast-forward is blocked by user's dirty files:
- Stash user changes (git stash push --include-untracked)
- Fast-forward develop
- Restore user changes (git stash pop)
c. This is safe because the actual merge work (conflict resolution,
verification) already happened in the isolated worktree.
6. Clean up:
a. Remove merge worktree:
git worktree remove .worktrees/merge-workspace --force
b. Delete temporary branch:
git branch -D _merge-temp-{batchId}
c. Clean up merged lane branches:
git branch -d task/lane-{N}-{batchId}
d. Update lane worktrees for next wave:
cd .worktrees/{prefix}-{N}
git checkout -B task/lane-{N}-{nextBatchId} develop
On any failure:
- The merge worktree and temp branch are cleaned up
- The develop branch is untouched (no partial merge)
- Lane branches are preserved for manual inspection or retry
1. If more waves remain:
a. Re-evaluate: did any failed tasks block downstream waves?
b. Adjust remaining waves (remove tasks with failed dependencies)
c. Return to Phase 3 with next wave
2. If all waves complete:
a. Remove all worktrees:
git worktree remove .worktrees/{prefix}-{N} (for each lane)
b. Write batch summary to log
c. Update dashboard → "Batch Complete"
Without TMUX, lane subprocesses are invisible. The orchestrator can poll STATUS.md for progress numbers, but the user cannot see what a worker is actually doing — which file it's reading, which test is failing, what the reviewer is examining. TMUX solves this with zero-cost observability:
- Every subprocess runs in a named TMUX session
- Attach at any time to observe (
tmux attach -t name) - Detach without affecting execution (
Ctrl+B, d) - Sessions persist even if the user's terminal disconnects
- No performance overhead — TMUX is a terminal multiplexer, not a VM
All orchestrator TMUX sessions use the orch- prefix to avoid collisions
with user sessions.
| Session Name | Contains | Purpose |
|---|---|---|
orch-lane-{N} |
Pi + task-runner extension | Lane execution (task-runner TUI visible) |
orch-lane-{N}-worker |
Pi worker subprocess | Current worker iteration (raw tool calls) |
orch-lane-{N}-reviewer |
Pi reviewer subprocess | Code/plan review in progress |
orch-merge-{N} |
Pi merge agent | Branch merge + verification |
Where {N} is the lane number (1, 2, 3, ...).
Worker and reviewer sessions are ephemeral — created per-iteration and destroyed on completion. Lane sessions persist for the duration of the wave.
Interactive Pi sessions don't accept commands via CLI arguments — you type them
in the editor. To avoid fragile tmux send-keys timing, the task-runner
extension supports an environment variable trigger.
The problem: The orchestrator needs to tell a Pi session "run /task X"
at startup. But Pi commands are typed interactively — there's no pi --command
flag. Using tmux send-keys "/task X" Enter is fragile because you don't know
when Pi is ready to accept input.
The solution: The task-runner extension reads TASK_AUTOSTART from the
environment on startup and programmatically invokes the same code path as /task.
# Orchestrator sets TASK_AUTOSTART before launching the TMUX session
TASK_AUTOSTART="docs/task-management/domains/time-off/tasks/TO-014-accrual-engine/PROMPT.md" \
tmux new-session -d -s orch-lane-1 \
-c "/path/to/worktree" \
"pi --no-session -e extensions/task-runner.ts"Implementation in task-runner.ts — add to the existing session_start handler:
pi.on("session_start", async (_event, ctx) => {
// ... existing initialization (theme, footer, widget, config) ...
// Auto-start: orchestrator passes task path via environment variable
const autoStart = process.env.TASK_AUTOSTART;
if (autoStart) {
const fullPath = resolve(ctx.cwd, autoStart);
if (!existsSync(fullPath)) {
ctx.ui.notify(`TASK_AUTOSTART file not found: ${autoStart}`, "error");
return;
}
// Same logic as the /task command handler:
const content = readFileSync(fullPath, "utf-8");
state = freshState();
state.task = parsePromptMd(content, fullPath);
state.config = loadConfig(ctx.cwd);
state.phase = "running";
widgetCtx = ctx;
// Generate STATUS.md if missing
const statusPath = join(state.task.taskFolder, "STATUS.md");
if (!existsSync(statusPath)) {
writeFileSync(statusPath, generateStatusMd(state.task));
} else {
const existing = parseStatusMd(readFileSync(statusPath, "utf-8"));
state.reviewCounter = existing.reviewCounter;
state.totalIterations = existing.iteration;
for (const s of existing.steps) state.stepStatuses.set(s.number, s);
}
const reviewsDir = join(state.task.taskFolder, ".reviews");
if (!existsSync(reviewsDir)) mkdirSync(reviewsDir, { recursive: true });
updateWidgets();
ctx.ui.notify(`Auto-starting: ${state.task.taskId}`, "info");
// Fire execution (same as /task command)
executeTask(ctx).catch(err => {
state.phase = "error";
ctx.ui.notify(`Task error: ${err?.message || err}`, "error");
updateWidgets();
});
}
});Flow: TMUX starts → Pi initializes → extension loads → session_start
fires → reads TASK_AUTOSTART → parses PROMPT.md → begins execution. Zero
timing races. If the env var is not set (standalone /task usage), the existing
interactive behavior is unchanged.
When Pi exits: Task-runner calls executeTask(), which runs all steps, then
creates .DONE and Pi's process exits. Since the Pi process was the only thing
running in the TMUX session, the session closes automatically. The orchestrator
detects this via tmux has-session returning non-zero.
The existing spawnAgent() function in task-runner.ts gains a second mode.
The mode is determined by environment variable (set by the orchestrator) with
fallback to config file, with fallback to "subprocess" (current behavior).
Mode resolution order:
function getSpawnMode(config: TaskConfig): "subprocess" | "tmux" {
// 1. Environment variable (set by orchestrator when launching lane)
if (process.env.TASK_RUNNER_SPAWN_MODE === "tmux") return "tmux";
// 2. Config file (task-runner.yaml)
if (config.worker.spawn_mode === "tmux") return "tmux";
// 3. Default
return "subprocess";
}
function getTmuxPrefix(): string {
// Environment variable (set by orchestrator per-lane)
// e.g., "orch-lane-1" → worker session becomes "orch-lane-1-worker"
return process.env.TASK_RUNNER_TMUX_PREFIX || "task";
}Why env vars take priority: The orchestrator sets per-lane env vars
(TASK_RUNNER_SPAWN_MODE=tmux, TASK_RUNNER_TMUX_PREFIX=orch-lane-1) when
spawning TMUX sessions. This lets task-runner.yaml keep subprocess as the
default for standalone /task usage, while orchestrated lanes automatically
get TMUX mode without config file changes.
# .pi/task-runner.yaml — no change needed for orchestrated use
worker:
spawn_mode: "subprocess" # Default for standalone /task
# Orchestrator overrides this via TASK_RUNNER_SPAWN_MODE env varSubprocess mode (existing, default for standalone /task usage):
// Current behavior preserved exactly — pi -p --mode json, JSON event stream
const { promise, kill } = spawnAgent({
model, tools, thinking, systemPrompt, prompt,
onToolCall: (name, args) => { ... }, // Real-time tool tracking
onContextPct: (pct) => { ... }, // Context window monitoring
});TMUX mode (new, for orchestrated parallel execution):
function spawnAgentTmux(opts: {
sessionName: string; // e.g., "orch-lane-1-worker"
cwd: string; // worktree path
systemPrompt: string; // agent system prompt content
prompt: string; // user prompt content
model: string; // e.g., "anthropic/claude-sonnet-4-20250514"
tools: string; // e.g., "read,write,edit,bash,grep,find,ls"
thinking: string; // e.g., "off"
}): { promise: Promise<{ exitCode: number; elapsed: number }>; kill: () => void } {
// Write system prompt and user prompt to temp files
// (same approach as existing spawnAgent — avoids shell escaping issues)
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const sysTmpFile = join(tmpdir(), `pi-task-sys-${id}.txt`);
const promptTmpFile = join(tmpdir(), `pi-task-prompt-${id}.txt`);
writeFileSync(sysTmpFile, opts.systemPrompt);
writeFileSync(promptTmpFile, opts.prompt);
// Build the Pi command that will run inside the TMUX session
const piCmd = [
"pi", "--no-session", "--no-extensions", "--no-skills",
"--model", opts.model,
"--tools", opts.tools,
"--thinking", opts.thinking,
"--append-system-prompt", sysTmpFile,
`@${promptTmpFile}`,
].join(" ");
// Create the TMUX session
// -d = detached, -s = session name, -c = working directory
spawnSync("tmux", [
"new-session", "-d",
"-s", opts.sessionName,
"-c", opts.cwd,
piCmd,
], { shell: true });
// Return interface compatible with existing spawnAgent return type
const promise = pollUntilSessionEnds(opts.sessionName, sysTmpFile, promptTmpFile);
const kill = () => {
spawnSync("tmux", ["kill-session", "-t", opts.sessionName]);
// Clean up temp files
try { unlinkSync(sysTmpFile); } catch {}
try { unlinkSync(promptTmpFile); } catch {}
};
return { promise, kill };
}
async function pollUntilSessionEnds(
sessionName: string,
sysTmpFile: string,
promptTmpFile: string,
): Promise<{ exitCode: number; elapsed: number }> {
const start = Date.now();
while (true) {
// tmux has-session returns 0 if session exists, 1 if not
const result = spawnSync("tmux", ["has-session", "-t", sessionName]);
if (result.status !== 0) {
// Session no longer exists — Pi process exited, TMUX session closed
// Clean up temp files
try { unlinkSync(sysTmpFile); } catch {}
try { unlinkSync(promptTmpFile); } catch {}
return { exitCode: 0, elapsed: Date.now() - start };
}
await new Promise(resolve => setTimeout(resolve, 2000));
}
}How task-runner calls the right mode:
// In runWorker() and doReview(), replace the existing spawnAgent() call:
const spawnMode = getSpawnMode(config);
if (spawnMode === "tmux") {
const prefix = getTmuxPrefix(); // e.g., "orch-lane-1"
const { promise, kill } = spawnAgentTmux({
sessionName: `${prefix}-worker`, // → "orch-lane-1-worker"
cwd: ctx.cwd,
systemPrompt, prompt, model,
tools: config.worker.tools,
thinking: config.worker.thinking,
});
state.workerProc = { kill };
const result = await promise;
// ... handle result (same as existing post-spawnAgent logic)
} else {
// Existing subprocess spawnAgent() — unchanged
const { promise, kill } = spawnAgent({ ... });
// ...
}Key detail — session name reuse: Worker sessions are ephemeral. Each worker
iteration creates orch-lane-1-worker, it runs, Pi exits, the session closes.
The next iteration creates a new orch-lane-1-worker session. TMUX allows
reusing session names after the previous one exits. If a user is attached when
the session closes, they see the session end — they'd need to re-attach for the
next iteration.
In orchestrated mode, workers run in subprocess/JSON mode (not TMUX). This gives the orchestrator full telemetry while the web dashboard provides observability that replaces the need for TMUX attach.
The orchestrator sets TASK_RUNNER_SPAWN_MODE=subprocess per lane. The runner
spawns workers as headless subprocesses with pi -p --mode json, capturing
the JSON event stream on stdout.
Telemetry captured from JSON stream:
| Data Point | Available | Source |
|---|---|---|
| Task progress (checkboxes) | ✅ | STATUS.md polling |
| Worker completion | ✅ | Process exit code |
| Worker elapsed time | ✅ | Timer |
| Context window % | ✅ | message_end events with token usage |
| Current tool name | ✅ | tool_execution_start events |
| Tool call count | ✅ | tool_execution_start event counting |
| Full conversation | ✅ | All JSON events teed to JSONL file |
Sidecar files (written to main repo's .pi/ directory):
| File | Contents | Update Frequency |
|---|---|---|
.pi/lane-state-orch-lane-{N}.json |
Worker stats (tool count, elapsed, context %, last tool, status) | Every 1 second |
.pi/worker-conversation-orch-lane-{N}.jsonl |
Full JSON event stream from worker | Every event |
The ORCH_SIDECAR_DIR env var tells the runner where to write these files
(main repo's .pi/, not the worktree's). The web dashboard server reads them
for live display.
Context window management: In subprocess mode, task-runner tracks context %
via JSON message_end events and creates the wrap-up file at warn_percent
and kills the process at kill_percent.
Fresh context per iteration: Each worker iteration is a new Pi subprocess, so context accumulation resets every iteration.
Note: TMUX mode for workers is still supported for standalone
/taskusage and can be forced viaTASK_RUNNER_SPAWN_MODE=tmux. In TMUX mode, the JSON event stream is unavailable and the runner falls back to wall-clock timeouts instead of context % tracking.
The orchestrator does NOT attach to lane TMUX sessions. It monitors lanes via file-based polling:
Orchestrator monitoring loop (every poll_interval seconds):
For each active lane:
1. Read STATUS.md from the lane's worktree
→ Parse step status, checkbox counts, iteration number
2. Check for .DONE file in task folder
→ Task complete
3. tmux has-session -t orch-lane-{N}
→ Session alive? If not and no .DONE → task failed
4. Compare STATUS.md mtime to last check
→ No change for stall_timeout → stall detected
This is robust — no JSON parsing, no pipe management, no process handle tracking. The filesystem is the communication channel.
The merge agent keeps the orchestrator as a pure supervisor. It's a short-lived Pi session (in its own TMUX session) with a focused system prompt.
The merge agent runs in an isolated merge worktree, not the user's main working directory. This eliminates the dirty-worktree failure mode that previously caused merge failures when the user had uncommitted edits.
---
name: task-merger
description: Merges lane branches into develop with conflict resolution
tools: read,write,edit,bash,grep,find,ls
model: "" # inherits from orchestrator config
---
You are a merge agent running in an ISOLATED MERGE WORKTREE.
## Your Job
1. Read the merge request provided in your prompt
2. Execute the merge (branch is already checked out — do NOT switch branches)
3. Handle any conflicts
4. Verify the result
5. Write your outcome to the specified result file
## Merge Procedure
1. Verify current state (sanity check only):
git branch --show-current
git log --oneline -1
# The correct branch is already checked out. Do NOT switch branches.
2. Attempt merge:
git merge {source_branch} --no-ff -m "{merge_message}"
3. If merge succeeds (no conflicts):
→ Run verification commands
→ Write SUCCESS result
4. If merge has conflicts:
→ Run: git diff --name-only --diff-filter=U (list conflicted files)
→ Classify each conflict (see Conflict Classification)
→ Attempt resolution for auto-resolvable conflicts
→ If all resolved: git add . && git commit -m "merge: resolved conflicts..."
→ If any unresolvable: git merge --abort && write CONFLICT_UNRESOLVED resultKey differences from earlier design:
- No
git status --porcelaincheck — the worktree is clean by construction - No
git checkout develop— the correct branch (_merge-temp) is already checked out - Result file is written to an absolute path in the main repo's
.pi/directory - The agent never touches the user's working directory
Merge agents run one at a time, not in parallel. Rationale:
- The develop branch is a shared resource. Two concurrent merges to develop would race on the branch ref.
- Each merge must see the prior merge's result. Lane 2's merge might conflict with lane 1's changes — this is only detectable if lane 1 is already merged.
- Merge agents are fast. A typical merge takes 10-30 seconds. Even with verification (go build), under 2 minutes. For a 3-lane wave, total merge time is ~5 minutes — negligible compared to task execution time.
The orchestrator determines merge order using a heuristic:
- Lanes with fewer changed files merge first — smaller changes are less likely to conflict, establishing a clean base.
- Lanes with non-overlapping file scopes merge first — guaranteed clean.
- Last: lanes with the broadest changes — most likely to need conflict resolution, benefits from seeing all prior merges.
The merge worktree provides complete isolation between the merge process and the user's main working directory.
BEFORE MERGE PHASE:
1. git branch _merge-temp-{batchId} develop
→ Creates temp branch at current develop HEAD
2. git worktree add .worktrees/merge-workspace _merge-temp-{batchId}
→ Creates a clean checkout on the temp branch
DURING MERGE (sequential, per lane):
3. Merge agent spawns with cwd = .worktrees/merge-workspace
4. Agent merges lane branch into _merge-temp (current HEAD)
5. Agent runs verification commands in the worktree
6. Agent writes result JSON to main repo's .pi/ (absolute path)
7. Next lane's agent sees prior lanes' merges on _merge-temp
AFTER ALL LANES SUCCEED:
8. git merge --ff-only _merge-temp-{batchId} (in main repo)
→ Fast-forwards develop to include all lane merges
→ If blocked by dirty files: stash → ff → pop
9. git worktree remove .worktrees/merge-workspace --force
10. git branch -D _merge-temp-{batchId}
ON ANY FAILURE:
8. git worktree remove .worktrees/merge-workspace --force
9. git branch -D _merge-temp-{batchId}
→ develop is UNTOUCHED (no partial merge)
→ Lane branches preserved for retry
Why this is better than merging in the main repo:
| Problem | Old Approach | Worktree Approach |
|---|---|---|
| User has uncommitted edits | ❌ BUILD_FAILURE | ✅ Merge is isolated, user edits unaffected |
| Orchestrator generates files (dependencies.json) | ❌ BUILD_FAILURE | ✅ Generated files are in main repo, not worktree |
| IDE has files open | ❌ Potential lock conflict | ✅ Different directory entirely |
| Merge fails midway | ❌ develop has partial merge | ✅ develop untouched until all lanes succeed |
| User edits during merge | ❌ Race condition | ✅ Stash/pop only for fast-forward step |
When a task fails (worker can't make progress, tests fail permanently, blocker):
on_task_failure Config |
Behavior |
|---|---|
skip-dependents |
Mark task failed. Remove dependent tasks from future waves. Continue current wave's other lanes. |
stop-wave |
Mark task failed. Let other lanes in this wave finish. Don't start next wave. |
stop-all |
Kill all running lanes immediately. Abort batch. |
Default: skip-dependents — maximizes work done while respecting dependency
chains.
| Merge Result | Orchestrator Action |
|---|---|
SUCCESS |
Continue to next lane merge |
CONFLICT_RESOLVED |
Log resolution details, continue |
CONFLICT_UNRESOLVED |
Pause batch. Notify user. Wait for /orch-resume |
BUILD_FAILURE |
Pause batch. Notify user with error output. Wait for /orch-resume |
On pause, the orchestrator preserves all state: worktrees stay on disk, lane branches are intact, STATUS.md files reflect completed work. The user can:
- Manually resolve the conflict in the main worktree
- Resume with
/orch-resume - Or abort with
/orch-abort(worktrees cleaned up, branches preserved)
If a lane's STATUS.md hasn't changed for stall_timeout minutes (default: 30),
the orchestrator:
- Checks if the TMUX session is still alive (
tmux has-session -t orch-lane-{N}) - If alive but no progress → kill the session (
tmux kill-session -t orch-lane-{N}) - Also kill any child sessions (
orch-lane-{N}-worker,orch-lane-{N}-reviewer) - Mark the current task as stalled
- Treat as task failure (apply
on_task_failurepolicy)
The orchestrator provides two monitoring interfaces: a web dashboard for primary monitoring and TMUX attach for deep inspection.
The web dashboard is the primary monitoring interface, running as a Node.js HTTP server with SSE (Server-Sent Events) for live push updates.
Location: extensions/orch-dashboard-web/
| File | Purpose |
|---|---|
server.cjs |
HTTP server, SSE endpoints, file watching, tmux session detection |
public/index.html |
Single-page dashboard shell |
public/style.css |
Dark GitHub-style theme |
public/app.js |
EventSource client, DOM rendering, conversation viewer |
Start: node extensions/orch-dashboard-web/server.cjs
Features:
- Batch header: Wave progress, task counts, elapsed time, overall progress bar
- Lanes & Tasks panel: Integrated view showing each lane with its tasks, progress bars, step info, and live worker stats
- Worker stats (inline): Each running task shows: ⏱ elapsed, 🔧 tool count, 📊 context %, last tool call with path
- 👁 View button: Opens a conversation viewer panel showing the full AI worker stream — text output, tool calls with args, tool results, token usage per turn
- Merge agents table: Shows merge session status (ready for merge phase)
- Errors panel: Displays any merge failures with details
- TMUX commands: Click-to-copy
tmux attachcommands for drill-down
Data flow:
Task-runner (in worktree)
├── Writes STATUS.md to worktree (step progress, checkboxes)
├── Writes .pi/lane-state-orch-lane-{N}.json (worker stats, every 1s)
└── Writes .pi/worker-conversation-orch-lane-{N}.jsonl (full JSON stream)
│
Dashboard server (port 8099)
├── fs.watch on .pi/batch-state.json (immediate push on change)
├── Reads .pi/lane-state-*.json (worker stats)
├── Reads STATUS.md from worktrees (step progress)
├── tmux list-sessions (session liveness)
└── SSE push to browser every 2s (+ immediate on file change)
│
Browser (http://localhost:8099)
├── EventSource /api/stream (dashboard state)
└── Fetch /api/conversation/{prefix} (worker JSONL, polled every 2s)
Zero external npm dependencies. The server uses only Node.js built-ins
(http, fs, path, child_process). The frontend is vanilla HTML/CSS/JS.
Each lane's Pi session shows the task-runner TUI dashboard (visible via
tmux attach -t orch-lane-{N}). This provides per-step detail for a single
lane, including step cards, progress bars, and iteration tracking.
| Data | Source | Update Method |
|---|---|---|
| Batch state (waves, tasks, phases) | .pi/batch-state.json |
fs.watch + 2s poll |
| Lane task progress (steps, checkboxes) | STATUS.md in each worktree | Parsed server-side per SSE push |
| Worker stats (tools, elapsed, context %) | .pi/lane-state-*.json |
Read per SSE push |
| Worker conversation (full AI stream) | .pi/worker-conversation-*.jsonl |
Fetched on demand via REST |
| Lane alive/dead | tmux list-sessions |
Checked per SSE push |
| Merge status | Merge result files + TMUX session exit | On completion |
The orchestrator extension registers these Pi commands:
| Command | Description |
|---|---|
/orch <areas|paths|all> |
Start batch execution (see argument resolution below). |
/orch-plan <areas|paths|all> |
Show execution plan (waves, lane assignments) without executing. |
/orch-status |
Show current batch progress (refreshes dashboard). |
/orch-pause |
Pause after current tasks finish. Lanes stop picking up new tasks. |
/orch-resume |
Resume a paused batch. Also used after manual conflict resolution. |
/orch-abort |
Graceful abort. Signal workers to checkpoint, wait up to 60s, then force-kill stragglers. |
/orch-abort --hard |
Immediate abort. Kill all orch-* TMUX sessions instantly. No checkpoint. |
/orch-deps <areas|paths|all> [--refresh] |
Show or re-analyze dependency graph. |
/orch-sessions |
List all active orchestrator TMUX sessions with attach commands. |
Argument resolution (applies to /orch, /orch-plan, /orch-deps):
Arguments are resolved in order. Each argument is matched as:
all→ expands to every area intask-runner.yaml → task_areas- Area name → looked up in
task-runner.yaml → task_areas → {name}.path - PROMPT.md file → if arg ends with
PROMPT.mdand file exists, adds that single task - Directory path → scanned for task subfolders (skips
archive/subdirectory)
Multiple arguments are combined. Duplicate paths are deduplicated. A single PROMPT.md is valid — the orchestrator creates one lane, one wave, one task.
# Run all pending tasks across all areas defined in task-runner.yaml
/orch all
# Run tasks from specific areas (names from task-runner.yaml → task_areas)
/orch time-off performance-management
# → resolves to:
# docs/task-management/domains/time-off/tasks/
# docs/task-management/domains/performance-management/tasks/
# → scans each for subdirectories containing PROMPT.md (without .DONE)
# → skips archive/ subdirectories entirely
# → builds dependency graph across both areas
# → computes waves and begins execution
# Run tasks from a specific folder path (bypass area name lookup)
/orch docs/task-management/domains/time-off/tasks
# Mix area names and paths
/orch time-off docs/task-management/platform/notifications/tasks
# Run a single task by pointing directly to its PROMPT.md
/orch docs/task-management/domains/time-off/tasks/TO-014-accrual-engine/PROMPT.md
# → one task, one lane, one wave — still gets worktree isolation and merge-back
# Preview the execution plan without executing
/orch-plan time-off notifications
# → shows: tasks found, dependency graph, waves, lane assignments
# Resume after fixing a merge conflict
/orch-resume
# See all TMUX sessions (from a separate terminal window)
tmux list-sessions | grep orch-
# Attach to a lane to see its task-runner dashboard
tmux attach -t orch-lane-1
# Watch the current worker in real time
tmux attach -t orch-lane-1-worker
# Detach without stopping anything
# Press: Ctrl+B, then d
# ═══════════════════════════════════════════════════════════════════════
# Parallel Task Orchestrator Configuration
# ═══════════════════════════════════════════════════════════════════════
orchestrator:
# Maximum parallel lanes (worktrees). Each lane is ~30 MB disk.
# Recommended: 2-4 depending on CPU/memory available.
max_lanes: 3
# Where to create worktree directories.
# "sibling" = ../{prefix}-{N} (alongside main repo, e.g. ../taskplane-wt-1)
# "subdirectory" = .worktrees/{prefix}-{N} (inside repo, gitignored, e.g. .worktrees/taskplane-wt-1)
worktree_location: "subdirectory"
worktree_prefix: "taskplane-wt"
# Batch ID format. Used in branch names and logs.
# "timestamp" = 20260308T111750
# "sequential" = batch-001, batch-002, ...
batch_id_format: "timestamp"
# How to spawn lane and agent subprocesses.
# "tmux" = Each subprocess runs in a named TMUX session.
# User can attach to any session for live visibility.
# Requires: tmux installed and functional.
# "subprocess" = Headless subprocesses (no TMUX required).
# Parallel execution and worktrees still work.
# No drill-down observability (dashboard only).
spawn_mode: "tmux"
# Prefix for TMUX session names. All sessions are named:
# {tmux_prefix}-lane-{N}, {tmux_prefix}-lane-{N}-worker, etc.
# Use a unique prefix to avoid collisions with user TMUX sessions.
tmux_prefix: "orch"
# ── Dependency Analysis ────────────────────────────────────────────────
dependencies:
# How to determine task dependencies.
# "prompt" = parse ## Dependencies from PROMPT.md (fast, no agent needed)
# "agent" = spawn an agent to analyze all PROMPT.md files (thorough)
source: "prompt"
# Cache dependency analysis to dependencies.json per task area.
cache: true
# Force re-analysis even if cache exists.
# refresh: false # (use /orch-deps --refresh instead)
# ── Lane Assignment ────────────────────────────────────────────────────
assignment:
# Strategy for assigning tasks to lanes within a wave.
# "affinity-first" = group by file scope overlap, then round-robin
# "round-robin" = simple round-robin (ignores file scope)
# "load-balanced" = assign by estimated duration (task size)
strategy: "affinity-first"
# Task size weights for load-balanced assignment.
size_weights:
S: 1
M: 2
L: 4
# ── Pre-warming ────────────────────────────────────────────────────────
pre_warm:
# Run these commands in each worktree before starting tasks.
# Commands run relative to the worktree root.
# Use {lane} placeholder for lane number.
# Auto-detect which commands to run based on task file scope.
auto_detect: true
# Commands to run if auto-detect determines they're needed.
commands:
go_deps: "go mod download" # If task touches services/
npm_deps: "cd web && npm ci" # If task touches web/
# go_build: "go build ./..." # Optional: pre-compile
# Always run these regardless of file scope.
always: []
# ── Merge ──────────────────────────────────────────────────────────────
merge:
# Model for merge agents. Empty = inherit from parent Pi session.
model: ""
tools: "read,write,edit,bash,grep,find,ls"
# Verification commands run after each merge.
# All must pass or the merge is reverted.
verify:
- "go build ./..."
# - "cd web && npm run type-check" # Enable when frontend tasks are common
# Merge order heuristic.
# "fewest-files-first" = lanes with fewer changed files merge first
# "sequential" = merge in lane number order
order: "fewest-files-first"
# ── Failure Handling ───────────────────────────────────────────────────
failure:
# What to do when a task fails.
# "skip-dependents" = mark failed, remove downstream tasks, continue wave
# "stop-wave" = let current wave finish, don't start next
# "stop-all" = kill all lanes immediately
on_task_failure: "skip-dependents"
# What to do when a merge fails.
# "pause" = pause batch, wait for user intervention
# "abort" = abort entire batch
on_merge_failure: "pause"
# Minutes of no STATUS.md changes before declaring a lane stalled.
stall_timeout: 30
# Maximum minutes for a single worker iteration in TMUX mode.
# In subprocess mode, context % tracking handles this via kill_percent.
# In TMUX mode, there's no context % visibility, so wall-clock timeout
# is the safety net. Default 30 minutes per worker iteration.
max_worker_minutes: 30
# Seconds to wait for graceful shutdown on /orch-abort before force-killing.
abort_grace_period: 60
# ── Monitoring ─────────────────────────────────────────────────────────
monitoring:
# How often to poll STATUS.md files in worktrees (seconds).
poll_interval: 5
# Write batch progress to this file (for external monitoring).
# progress_file: "batch-progress.json"The orchestrator reads both task-orchestrator.yaml and task-runner.yaml:
task-runner.yaml→ task areas, reference docs, worker/reviewer configtask-orchestrator.yaml→ lane count, merge strategy, failure handling
This separation means the task-runner extension continues to work independently
for single-task execution (/task). The orchestrator is an optional layer on top.
Two optional sections are added to the PROMPT.md template for orchestrator awareness. Both are backward-compatible — the single-task runner ignores them.
## File Scope
Files and directories this task will modify. Used by the orchestrator for
parallel lane assignment (tasks with overlapping scope run in the same lane).
- services/time-service/**
- docs/api/time-service-api.md
- web/src/pages/time-off/**
- web/src/stores/timeoff.ts## Dependencies
- **Task:** TO-014 — PTO policy engine must exist
- **Task:** employee-management/EM-003 — Employee batch endpoint needed
- **None**Rules:
- Prefer unqualified
TASK-IDwhen globally unique - Use
area-name/TASK-IDwhen cross-area IDs may be ambiguous - If orchestrator reports
DEP_AMBIGUOUS, update dependency entries to the area-qualified form
The orchestrator has hard dependencies beyond Pi itself. This section documents what's required, how to verify it, and platform-specific installation guidance for packaging this system for other users.
| Dependency | Required By | Minimum Version | Purpose |
|---|---|---|---|
| Pi | Everything | Latest stable | Agent runtime, extension host |
| Git | Worktree management | 2.15+ | git worktree (added in 2.5), --no-ff merge |
| TMUX | Observability layer | 2.6+ | Session management, detach/attach |
| Bash-compatible shell | TMUX session commands | Any | TMUX spawns commands through a shell |
| Node.js | Pi + extensions | 20+ | Extension runtime (TypeScript via Pi) |
Optional dependencies (needed only for specific tasks, not the orchestrator):
| Dependency | Required When | Purpose |
|---|---|---|
| Go toolchain | Tasks touching Go services | go build, go test in verification |
| npm / Node.js | Tasks touching web/ |
npm ci, npm run type-check in verification |
| golangci-lint | Go tasks with lint step | Code quality verification |
The orchestrator runs a preflight check on /orch before any execution.
All hard dependencies must pass or the batch is aborted with actionable error
messages.
interface PreflightResult {
passed: boolean;
checks: {
name: string;
status: "pass" | "fail" | "warn";
version?: string;
message: string;
installHint?: string;
}[];
}
function runPreflight(): PreflightResult {
const checks = [];
// Git
const gitVersion = exec("git --version"); // "git version 2.43.0"
checks.push(checkMinVersion("git", gitVersion, "2.15"));
// Git worktree support
const worktreeTest = exec("git worktree list");
checks.push({ name: "git-worktree", status: worktreeTest.ok ? "pass" : "fail",
message: worktreeTest.ok ? "Worktree support available" : "Git worktree not available",
installHint: "Upgrade Git to 2.15+" });
// TMUX
const tmuxVersion = exec("tmux -V"); // "tmux 3.3a"
checks.push(checkMinVersion("tmux", tmuxVersion, "2.6"));
// TMUX server reachable (can we create sessions?)
const tmuxTest = exec("tmux new-session -d -s orch-preflight-test 'exit 0'");
if (tmuxTest.ok) exec("tmux kill-session -t orch-preflight-test");
checks.push({ name: "tmux-functional", status: tmuxTest.ok ? "pass" : "fail",
message: tmuxTest.ok ? "TMUX can create sessions" : "TMUX server not reachable" });
// Pi (already running, but check version)
const piVersion = exec("pi --version");
checks.push({ name: "pi", status: "pass", version: piVersion, message: "Pi available" });
return { passed: checks.every(c => c.status !== "fail"), checks };
}Preflight output example:
Preflight Check:
✅ git 2.43.0 Git available
✅ git-worktree Worktree support available
✅ tmux 3.3a TMUX available
✅ tmux-functional TMUX can create sessions
✅ pi 1.2.3 Pi available
⚠️ go — Not found (needed if tasks touch services/)
Install: https://go.dev/doc/install
All required checks passed. Starting batch...
All dependencies available via native package managers:
# Debian/Ubuntu
sudo apt install git tmux
# Fedora/RHEL
sudo dnf install git tmux
# Arch
sudo pacman -S git tmux
# Pi (via npm)
npm install -g @mariozechner/pi-coding-agentTMUX and Git are standard packages on all major Linux distributions. No special configuration needed.
# Homebrew (recommended)
brew install git tmux
# Pi
npm install -g @mariozechner/pi-coding-agentmacOS ships with Git, but the Homebrew version is typically newer. TMUX is not included by default — Homebrew install is required.
Windows requires a POSIX-compatible environment for TMUX. There are two options:
Option A: MSYS2 / Git Bash (recommended for this project)
Git for Windows ships with MSYS2, which provides a bash shell and can run Windows-native ports of Unix tools. TMUX is available as an MSYS2 package but is not included by default in Git for Windows.
# If using standalone MSYS2:
pacman -S tmux
# If using Git Bash only (no pacman):
# Download pre-built tmux.exe for MSYS2 from:
# https://packages.msys2.org/package/tmux
# Place tmux.exe and its dependencies (msys-event_core-*.dll) in
# a directory on your PATH (e.g., ~/bin or /usr/bin)Verify it works:
# In Git Bash:
tmux -V # Should print version
tmux new-session -d -s test "echo ok"
tmux has-session -t test && echo "TMUX works"
tmux kill-session -t testKey facts about Git Bash TMUX:
- Runs as a native Windows
.exe(PE32+), NOT through WSL - Uses the MSYS2 POSIX translation layer (
msys-2.0.dll) - Sees Windows paths as
/c/dev/...(standard MSYS2 path translation) - Shares filesystem with native Windows processes — no cross-boundary issues
- Pi, Git, and all other tools work normally within TMUX sessions
Option B: WSL (Windows Subsystem for Linux)
# Inside WSL:
sudo apt install tmux git
npm install -g @mariozechner/pi-coding-agentWSL works but introduces filesystem boundary considerations:
- Windows files accessed from WSL via
/mnt/c/have performance overhead - Git operations on
/mnt/c/are slower than on native Linux filesystem - Recommended: clone the repo inside WSL's filesystem (
~/projects/)
Option C: Without TMUX (fallback)
If TMUX is unavailable, the orchestrator can fall back to subprocess mode
(headless Pi processes, same as current task-runner behavior). Parallel execution
and worktree isolation still work — only the drill-down observability is lost.
# .pi/task-orchestrator.yaml
orchestrator:
spawn_mode: "subprocess" # Headless mode — no TMUX requiredFor distributing the orchestrator as a Pi package or standalone tool:
| Item | How |
|---|---|
| Extension code | extensions/task-orchestrator.ts in a Pi package |
| Agent definitions | .pi/agents/task-merger.md |
| Config template | .pi/task-orchestrator.yaml with documented defaults |
| Preflight check | Built into extension, runs on first /orch |
| Platform docs | README with per-platform install instructions |
| TMUX fallback | spawn_mode: subprocess for environments without TMUX |
| Version constraints | pi field in package.json if Pi supports engine constraints |
These apply regardless of TMUX source (MSYS2 or WSL):
Path length: Windows has a default 260-character path limit. Worktree paths
are well within limits (~97 chars for deepest Go files), but node_modules can
exceed 260. Mitigation:
git config core.longpaths trueFile locking: Windows locks open files more aggressively than Unix. If an
IDE has a file open in the main worktree, it won't affect other worktrees
(different directories). But if cleanup runs while an agent has a file open,
git worktree remove may fail. Mitigation: the orchestrator retries removal
with exponential backoff.
Antivirus / Defender: Windows Defender real-time scanning of new files can slow worktree creation. The ~2,875 file checkout may take 5-10 seconds instead of <1 second. This is a one-time cost per wave and acceptable. Users can add worktree directories to Defender exclusions for better performance.
Orchestrator TMUX: orch-lane-1 TMUX: orch-lane-2 Merge Worktree
│ │ │ │
│─── git worktree ───►│ │ │
│─── git worktree ───►│───────────────────►│ │
│ │ │ │
│─── tmux new ───────►│ │ │
│ (TASK_AUTOSTART) │ │ │
│─── tmux new ────────│───────────────────►│ │
│ (TASK_AUTOSTART) │ │ │
│ │ │ │
│ [poll STATUS.md + │ │ │
│ lane-state JSON + │ │ │
│ tmux has-session] │ │ │
│◄── STATUS.md ───────│ │ │
│◄── lane-state.json ─│ │ │
│◄── conversation.jsonl│ │ │
│ │ │ │
│ ... web dashboard at localhost:8099 shows live stats ... │
│ ... tasks execute in parallel ... │ │
│ │ │ │
│◄── .DONE ───────────│ │ │
│ (session exits) │ │ │
│ │ │ │
│ ... wait for lane 2 ... │ │
│ │ │ │
│◄── .DONE ───────────│────────────────────│ │
│ (session exits) │ │ │
│ │ │ │
│ [all lanes done — create isolated merge worktree] │
│ │ │ │
│─── git branch _merge-temp ──────────────────────────────────│
│─── git worktree add ────────────────────────────────────────►│
│ │ │ │
│─── tmux new (merge-1, cwd=merge-wt) ───────────────────────►│
│ agent merges lane-1 into _merge-temp │ │
│◄── result file (.pi/) ──────────────────────────────────────│
│ (session exits) │ │ │
│ │ │ │
│─── tmux new (merge-2, cwd=merge-wt) ───────────────────────►│
│ agent merges lane-2 into _merge-temp │ │
│◄── result file (.pi/) ──────────────────────────────────────│
│ (session exits) │ │ │
│ │ │ │
│─── git merge --ff-only _merge-temp (in main repo) │
│ develop now has all lane work │ │
│ │ │ │
│─── git worktree remove merge-workspace ─────────────────────X
│─── git branch -D _merge-temp │ │
│ │ │ │
│─── reset wt-1 ─────►│ │ │
│─── reset wt-2 ─────►│───────────────────►│ │
│ │ │ │
│ [next wave...] │ │ │
| Aspect | ralph-orchestrator.ps1 | Parallel Task Orchestrator |
|---|---|---|
| Execution engine | PowerShell jobs calling run-wiggum.ps1 | Pi extension spawning TMUX sessions |
| File isolation | ❌ None — same directory | ✅ Git worktrees per lane |
| Git conflicts | ❌ Agents commit to same branch | ✅ Separate branch per lane, sequential merge |
| Merge strategy | None — single branch | Merge agents with conflict resolution |
| Dependency analysis | Agent-analyzed, cached | PROMPT.md-declared + agent fallback |
| Dashboard | Console logging | Live Pi TUI widget (orchestrator level) |
| Observability | Log files only | TMUX drill-down: lane → worker → reviewer |
| Failure handling | Stop on first failure | Configurable: skip-dependents / stop-wave / stop-all |
| Stall detection | Timeout per task (max minutes) | STATUS.md change monitoring |
| Resume capability | None — restart from beginning | Full: /orch-resume continues from last state |
| Terminal disconnect | Kills everything | TMUX sessions survive (reconnect on resume) |
| Persistent memory | STATUS.md | STATUS.md (unchanged) |
| Worker model | Same for all | Per-task-runner config (unchanged) |
| Review model | N/A (ralph didn't review) | Cross-model review (unchanged, per task-runner) |
| Cross-platform | Windows only (PowerShell) | Any platform with TMUX + Git (fallback: subprocess) |
task-orchestrator.ts— Extension with/orch,/orch-plan,/orch-status,/orch-pause,/orch-abort,/orch-sessionstask-orchestrator.yaml— Configuration file with defaults- Preflight check — Verify git, tmux, pi dependencies on startup
- Worktree lifecycle — Create, update, remove (with retry/backoff on Windows)
- Wave computation — Dependency parsing from PROMPT.md, topological sort, cycle detection
- TMUX lane spawning — Create TMUX sessions per lane with
TASK_AUTOSTARTenv var - Progress monitoring — STATUS.md polling +
tmux has-sessionliveness checks - Dashboard widget — Multi-lane progress with TMUX session name labels
TASK_AUTOSTARTenv var — Auto-execute/taskon session start- Dual spawn mode —
spawnAgentTmux()alongside existingspawnAgentSubprocess() spawn_modeconfig — Read fromtask-runner.yamlorTASK_RUNNER_SPAWN_MODEenv var- TMUX session lifecycle — Create named sessions for workers/reviewers, poll for exit
- Kill via TMUX —
tmux kill-session -t nameas alternative to process kill
task-merger.md— Agent definition (system prompt, tools, result format)- Merge TMUX sessions — Each merge agent in
orch-merge-{N}session - Sequential merge orchestration — Wait for each merge before starting next
- Conflict detection & classification — Auto-resolve trivial, abort on real
- Post-merge verification — Run configurable build/type-check commands
- Merge result handling — Parse result files, route success/failure
.pi/batch-state.json— Write/update on every state change (§17.5)/orch-resume— Reconstruct from state file, detect orphan TMUX sessions- Orphan detection — Check for
orch-*sessions on/orchstart - Graceful abort —
/orch-abortwrites wrap-up files, waits, then force-kills (§17.7) - Hard abort —
/orch-abort --hardimmediate kill
- File scope parsing & affinity assignment — Lane conflict avoidance
- Pre-warming — Auto-detect and install dependencies per worktree
- Stall detection — STATUS.md mtime monitoring with configurable timeout
- Subprocess fallback —
spawn_mode: subprocessfor environments without TMUX scripts/setup-tmux.sh— TMUX installation helper for Windows/MSYS2 (§17.6)- Batch progress file — JSON output for external monitoring/CI integration
Resolved from initial open questions during design review.
Decision: Subdirectory mode (.worktrees/taskplane-wt-1, .worktrees/taskplane-wt-2).
Rationale (updated from initial "sibling" decision): The original sibling
approach (../taskplane-wt-*) placed worktrees outside the project root,
making them invisible to VS Code and other IDE file explorers. Moving to
.worktrees/ inside the repo (gitignored) keeps worktrees discoverable by the
IDE while avoiding git tracking. The .worktrees/ directory is added to
.gitignore.
Manual step for developers: because .vscode/ is gitignored in this repo,
VS Code explorer hiding is local-only. Add this to your local
.vscode/settings.json if you want worktrees hidden from Explorer:
{
"files.exclude": {
"**/.worktrees": true
}
}The "sibling" mode remains available via config for environments where
nested worktrees are problematic.
Naming invariant: Worktree basenames follow {prefix}-{N} (e.g.,
taskplane-wt-1), where prefix is from worktree_prefix config and
N is the lane number. No -wt- suffix is appended (avoids double-wt
like taskplane-wt-wt-1).
Decision: Let lanes share the default Go build cache (GOCACHE). Do NOT
isolate per lane.
Rationale: Go's build cache uses content-addressable storage — files are
keyed by the hash of their inputs, not by project path. Two concurrent
go build commands in different worktrees produce the same cache keys for the
same source files. This means:
- No corruption risk. Two lanes building the same package write identical cache entries. Go handles concurrent writes gracefully (atomic rename).
- Faster second builds. After Lane 1 compiles
identity-service, Lane 2 gets a cache hit on shared packages, cutting build time significantly. - Minor lock contention. Under high parallelism (4+ lanes building simultaneously), there may be brief filesystem lock waits. This is negligible compared to LLM API latency.
If profiling reveals build cache issues at higher lane counts, add a config
option to set GOCACHE per lane. Not needed for 3 lanes.
Decision: Default max_lanes: 3. Increase based on observed rate limit
behavior.
Rationale: 3 lanes means up to 3 concurrent workers + up to 3 concurrent reviewers = 6 simultaneous LLM API calls in the worst case (all lanes reviewing at once). Most providers handle this, but burst capacity varies. Start conservative and increase after confirming:
- No HTTP 429 (rate limit) responses from the LLM provider
- Local machine handles 6 concurrent Pi processes without memory pressure
- TMUX server handles 10+ sessions without issues
The max_lanes config makes this easy to adjust without code changes.
Decision: Each lane spawns its own reviewer subprocess (unchanged from single-task behavior).
Rationale: A shared reviewer would serialize all reviews across lanes, defeating parallelism. Since reviews are short-lived (30-120 seconds) and infrequent (once or twice per step), the overhead of separate reviewer processes is minimal. Each lane's task-runner manages its own reviewer lifecycle independently — no coordination needed.
Decision: Combine state file persistence with TMUX orphan detection for a resilient resume experience.
Implementation:
On /orch start:
1. Write .pi/batch-state.json with:
- Batch ID, start time
- Task registry (all tasks with their assignments)
- Wave plan (which tasks in each wave)
- Lane assignments (which lane → which worktree → which tasks)
- Current wave number
- Per-task status: pending | running | complete | failed
2. Update batch-state.json after every state change:
- Task completes → update status, increment wave progress
- Wave merges → record merge results
- Lane fails → record failure
On /orch-resume (or fresh /orch detecting existing state):
1. Read .pi/batch-state.json
2. Check for orphaned TMUX sessions: tmux list-sessions | grep orch-
a. If orphan sessions exist AND batch-state.json exists:
→ Notify user: "Found running batch. Resume? (Y/n)"
→ If yes: reconnect monitoring to existing sessions
→ If no: kill orphans, clean up, start fresh
b. If orphan sessions exist but NO batch-state.json:
→ Notify user: "Found orphan TMUX sessions from a previous batch."
→ Offer to kill them
c. If no orphans but batch-state.json exists:
→ Sessions finished while orchestrator was disconnected
→ Check .DONE files and STATUS.md to determine what completed
→ Resume from next incomplete task/wave
3. Reconstruct orchestrator state from batch-state.json
4. Skip completed tasks (via .DONE files + state file)
5. Resume execution from current wave
On batch complete or /orch-abort:
1. Delete .pi/batch-state.json (clean state)
What survives a terminal disconnect:
- TMUX sessions keep running (server-side processes)
- STATUS.md checkpoints are on disk
- .DONE files mark completed tasks
- batch-state.json preserves wave/lane assignments
- Git commits from workers are in the worktree branches
What's lost: The orchestrator's in-memory dashboard state and monitoring loop. On resume, the dashboard rebuilds from batch-state.json and STATUS.md polling.
Decision: Provide scripts/setup-tmux.sh (bash script for Git Bash / MSYS2)
that downloads and installs the MSYS2 TMUX package automatically. Also document
manual installation as fallback.
Implementation:
#!/bin/bash
# scripts/setup-tmux.sh — Install TMUX for Git Bash / MSYS2 on Windows
#
# Run from Git Bash: bash scripts/setup-tmux.sh
if command -v tmux &>/dev/null; then
echo "TMUX is already installed: $(tmux -V)"
exit 0
fi
# Detect environment
if [ -f /usr/bin/pacman ]; then
echo "MSYS2 detected — installing via pacman..."
pacman -S --noconfirm tmux
elif [ -f /usr/bin/msys-2.0.dll ] || [ -f /c/Program\ Files/Git/usr/bin/msys-2.0.dll ]; then
echo "Git Bash detected (no pacman). Downloading TMUX binary..."
# Download pre-built TMUX + libevent from MSYS2 package repo
# Install to ~/bin (should be on PATH in Git Bash)
mkdir -p ~/bin
echo "Please install MSYS2 (https://www.msys2.org/) and run: pacman -S tmux"
echo "Or download tmux.exe manually from https://packages.msys2.org/package/tmux"
exit 1
else
echo "Not running in MSYS2 or Git Bash. TMUX requires a POSIX environment."
echo "Options: Install MSYS2, use WSL, or set spawn_mode: subprocess"
exit 1
fi
echo "Verifying: $(tmux -V)"
echo "TMUX installed successfully."The preflight check (§13.2) detects missing TMUX and prints the setup command.
Users on Linux/macOS just apt install tmux or brew install tmux.
Decision: /orch-abort is graceful (checkpoint, then stop). /orch-abort --hard
kills immediately.
| Command | Behavior |
|---|---|
/orch-abort |
Signal each lane to stop after its current checkpoint. Workers see the wrap-up file, finish their current item, commit, then exit. TMUX sessions close on their own. Orchestrator waits up to 60 seconds, then force-kills any remaining sessions. Worktrees and branches preserved. |
/orch-abort --hard |
Immediately kill all orch-* TMUX sessions (tmux kill-session). No checkpoint opportunity. Worktrees and branches preserved (last git commit is the recovery point). |
Implementation in the extension:
pi.registerCommand("orch-abort", {
description: "Abort batch execution (--hard for immediate kill)",
handler: async (args, ctx) => {
const hard = args?.trim() === "--hard";
if (hard) {
// Kill all orch-* TMUX sessions immediately
const sessions = listOrchSessions();
for (const s of sessions) {
spawnSync("tmux", ["kill-session", "-t", s]);
}
ctx.ui.notify(`Hard abort: killed ${sessions.length} sessions`, "warning");
} else {
// Graceful: write wrap-up files for each active lane
for (const lane of activeLanes) {
const wrapUpPath = join(lane.worktree, lane.taskFolder, ".task-wrap-up");
const legacyWrapUpPath = join(lane.worktree, lane.taskFolder, ".wiggum-wrap-up");
writeFileSync(wrapUpPath, `Abort requested at ${new Date().toISOString()}`);
// Backward compatibility during migration window
writeFileSync(legacyWrapUpPath, `Abort requested at ${new Date().toISOString()}`);
}
ctx.ui.notify("Abort signal sent. Waiting for checkpoints (60s max)...", "info");
// Wait up to 60 seconds for sessions to exit gracefully
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
const remaining = listOrchSessions();
if (remaining.length === 0) break;
await sleep(2000);
}
// Force-kill any that didn't exit
const stragglers = listOrchSessions();
for (const s of stragglers) {
spawnSync("tmux", ["kill-session", "-t", s]);
}
if (stragglers.length > 0) {
ctx.ui.notify(`Force-killed ${stragglers.length} sessions after timeout`, "warning");
}
}
// Clean up state
state.phase = "aborted";
cleanupBatchState(); // Delete .pi/batch-state.json
// Worktrees and branches are preserved for manual inspection
updateWidgets();
},
});
function listOrchSessions(): string[] {
const result = spawnSync("tmux", ["list-sessions", "-F", "#{session_name}"]);
if (result.status !== 0) return [];
return result.stdout.toString().trim().split("\n")
.filter(name => name.startsWith(state.config.tmux_prefix + "-"));
}Note: Pi extension commands receive arguments as a single string (args).
The handler splits on whitespace or checks for known flags. --hard is simple
enough to parse with args?.trim() === "--hard".