taskplane 0.2.6 → 0.2.8

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.
@@ -76,11 +76,59 @@ function resolveTaskFolder(task, state) {
76
76
  const laneNum = task.laneNumber;
77
77
  const lane = (state?.lanes || []).find((l) => l.laneNumber === laneNum);
78
78
  if (!lane || !lane.worktreePath) return task.taskFolder;
79
+
80
+ // In workspace mode, the worktree is inside a specific repo, not the workspace root.
81
+ // The task folder path needs to be made relative to the repo root (parent of the worktree),
82
+ // not the workspace root. Detect this by finding the repo root from the worktree path.
79
83
  const taskFolderAbs = path.resolve(task.taskFolder);
84
+ const worktreeAbs = path.resolve(lane.worktreePath);
85
+
86
+ // Try to find the repo root: walk up from the worktree path looking for which
87
+ // ancestor is a prefix of the task folder. The worktree is at <repoRoot>/.worktrees/<name>
88
+ // or a sibling, so the repo root is typically 2 levels up from a subdirectory worktree.
89
+ // Heuristic: find the longest common ancestor between taskFolder and worktree's repo root.
80
90
  const repoRootAbs = path.resolve(REPO_ROOT);
81
- const rel = path.relative(repoRootAbs, taskFolderAbs);
91
+
92
+ // First try: relative to workspace root (works in repo mode where workspace = repo)
93
+ let rel = path.relative(repoRootAbs, taskFolderAbs);
82
94
  if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return task.taskFolder;
83
- return path.join(lane.worktreePath, rel);
95
+
96
+ // Check if joining with worktree produces a valid path
97
+ const candidate = path.join(worktreeAbs, rel);
98
+ try {
99
+ if (fs.existsSync(candidate)) return candidate;
100
+ } catch { /* fall through */ }
101
+
102
+ // Second try: the worktree is inside a repo subdirectory of the workspace root.
103
+ // Strip the repo prefix from the task folder path to get the repo-relative path.
104
+ // e.g., taskFolder = "workspace/platform-docs/task-mgmt/DOC-001/"
105
+ // worktree = "workspace/platform-docs/.worktrees/wt-1/"
106
+ // repo-relative = "task-mgmt/DOC-001/"
107
+ // Find the repo by checking which workspace repo path is a prefix of the task folder.
108
+ const repoRoots = [];
109
+ try {
110
+ const stateMode = state.mode;
111
+ if (stateMode === "workspace" && state.repos) {
112
+ for (const r of state.repos) repoRoots.push(path.resolve(r.path));
113
+ }
114
+ } catch { /* no repo info in state */ }
115
+
116
+ // Also try inferring repo root from worktree path pattern:
117
+ // .worktrees/<name> → parent is repo root; sibling worktrees → shared parent
118
+ const worktreeParent = path.dirname(worktreeAbs);
119
+ const worktreeGrandparent = path.dirname(worktreeParent);
120
+ for (const possibleRepoRoot of [worktreeGrandparent, ...repoRoots]) {
121
+ const repoRel = path.relative(possibleRepoRoot, taskFolderAbs);
122
+ if (repoRel && !repoRel.startsWith("..") && !path.isAbsolute(repoRel)) {
123
+ const repoCandidate = path.join(worktreeAbs, repoRel);
124
+ try {
125
+ if (fs.existsSync(repoCandidate)) return repoCandidate;
126
+ } catch { continue; }
127
+ }
128
+ }
129
+
130
+ // Fallback: return original task folder (might work if not in worktree)
131
+ return task.taskFolder;
84
132
  }
85
133
 
86
134
  function parseStatusMd(taskFolder) {
@@ -48,6 +48,9 @@ export async function executeOrchBatch(
48
48
  workspaceRoot?: string,
49
49
  ): Promise<void> {
50
50
  const repoRoot = cwd;
51
+ // State files (.pi/batch-state.json, lane-state, etc.) belong in the workspace root,
52
+ // which is where .pi/ config lives. In repo mode, workspaceRoot === repoRoot.
53
+ const stateRoot = workspaceRoot ?? cwd;
51
54
 
52
55
  // ── Phase 1: Planning ────────────────────────────────────────
53
56
  batchState.phase = "planning";
@@ -187,7 +190,7 @@ export async function executeOrchBatch(
187
190
  batchState.phase = "executing";
188
191
 
189
192
  // ── TS-009: Persist state on batch start (after wave computation) ──
190
- persistRuntimeState("batch-start", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, repoRoot);
193
+ persistRuntimeState("batch-start", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot);
191
194
 
192
195
  for (let waveIdx = 0; waveIdx < rawWaves.length; waveIdx++) {
193
196
  // Check pause signal before starting each wave
@@ -196,14 +199,14 @@ export async function executeOrchBatch(
196
199
  execLog("batch", batchState.batchId, `batch paused before wave ${waveIdx + 1}`);
197
200
  onNotify(`⏸️ Batch paused before wave ${waveIdx + 1}. Resume not yet implemented (TS-009).`, "warning");
198
201
  // ── TS-009: Persist state on pause ──
199
- persistRuntimeState("pause-before-wave", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, repoRoot);
202
+ persistRuntimeState("pause-before-wave", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot);
200
203
  break;
201
204
  }
202
205
 
203
206
  batchState.currentWaveIndex = waveIdx;
204
207
 
205
208
  // ── TS-009: Persist state on wave index change ──
206
- persistRuntimeState("wave-index-change", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, repoRoot);
209
+ persistRuntimeState("wave-index-change", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot);
207
210
 
208
211
  // Filter wave tasks against blockedTaskIds
209
212
  let waveTasks = rawWaves[waveIdx].filter(
@@ -234,7 +237,7 @@ export async function executeOrchBatch(
234
237
  const handleWaveMonitorUpdate: MonitorUpdateCallback = (monitorState) => {
235
238
  const changed = syncTaskOutcomesFromMonitor(monitorState, allTaskOutcomes);
236
239
  if (changed) {
237
- persistRuntimeState("task-transition", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, repoRoot);
240
+ persistRuntimeState("task-transition", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot);
238
241
  }
239
242
  onMonitorUpdate?.(monitorState);
240
243
  };
@@ -255,7 +258,7 @@ export async function executeOrchBatch(
255
258
  latestAllocatedLanes = lanes;
256
259
  batchState.currentLanes = lanes;
257
260
  if (seedPendingOutcomesForAllocatedLanes(lanes, allTaskOutcomes)) {
258
- persistRuntimeState("wave-lanes-allocated", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, repoRoot);
261
+ persistRuntimeState("wave-lanes-allocated", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot);
259
262
  }
260
263
  },
261
264
  workspaceConfig,
@@ -283,7 +286,7 @@ export async function executeOrchBatch(
283
286
  }
284
287
 
285
288
  // ── TS-009: Persist state after wave execution ──
286
- persistRuntimeState("wave-execution-complete", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, repoRoot);
289
+ persistRuntimeState("wave-execution-complete", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot);
287
290
 
288
291
  const elapsedSec = Math.round((waveResult.endedAt - waveResult.startedAt) / 1000);
289
292
  onNotify(
@@ -302,14 +305,14 @@ export async function executeOrchBatch(
302
305
  if (waveResult.policyApplied === "stop-all") {
303
306
  batchState.phase = "stopped";
304
307
  // ── TS-009: Persist state on stop-all ──
305
- persistRuntimeState("stop-all", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, repoRoot);
308
+ persistRuntimeState("stop-all", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot);
306
309
  onNotify(ORCH_MESSAGES.orchBatchStopped(batchState.batchId, "stop-all"), "error");
307
310
  break;
308
311
  }
309
312
  if (waveResult.policyApplied === "stop-wave") {
310
313
  batchState.phase = "stopped";
311
314
  // ── TS-009: Persist state on stop-wave ──
312
- persistRuntimeState("stop-wave", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, repoRoot);
315
+ persistRuntimeState("stop-wave", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot);
313
316
  onNotify(ORCH_MESSAGES.orchBatchStopped(batchState.batchId, "stop-wave"), "error");
314
317
  break;
315
318
  }
@@ -347,7 +350,7 @@ export async function executeOrchBatch(
347
350
  if (mergeableLaneCount > 0) {
348
351
  batchState.phase = "merging";
349
352
  // ── TS-009: Persist state on executing→merging transition ──
350
- persistRuntimeState("merge-start", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, repoRoot);
353
+ persistRuntimeState("merge-start", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot);
351
354
  onNotify(ORCH_MESSAGES.orchMergeStart(waveIdx + 1, mergeableLaneCount), "info");
352
355
 
353
356
  mergeResult = mergeWaveByRepo(
@@ -359,12 +362,13 @@ export async function executeOrchBatch(
359
362
  batchState.batchId,
360
363
  batchState.baseBranch,
361
364
  workspaceConfig,
365
+ stateRoot,
362
366
  );
363
367
  allMergeResults.push(mergeResult);
364
368
  batchState.mergeResults.push(mergeResult);
365
369
 
366
370
  // Persist state after merge so dashboard shows wave merge results
367
- persistRuntimeState("merge-complete", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, repoRoot);
371
+ persistRuntimeState("merge-complete", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot);
368
372
 
369
373
  // Emit per-lane merge notifications
370
374
  for (const lr of mergeResult.laneResults) {
@@ -424,7 +428,7 @@ export async function executeOrchBatch(
424
428
  // Restore phase to executing (may be overridden below by failure handling)
425
429
  batchState.phase = "executing";
426
430
  // ── TS-009: Persist state after merge (merging→executing) ──
427
- persistRuntimeState("merge-complete", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, repoRoot);
431
+ persistRuntimeState("merge-complete", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot);
428
432
  } else if (mixedOutcomeLanes.length > 0) {
429
433
  const mixedIds = mixedOutcomeLanes.map(l => `lane-${l.laneNumber}`).join(", ");
430
434
  mergeResult = {
@@ -460,7 +464,7 @@ export async function executeOrchBatch(
460
464
 
461
465
  batchState.phase = policyResult.targetPhase;
462
466
  batchState.errors.push(policyResult.errorMessage);
463
- persistRuntimeState(policyResult.persistTrigger, batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, repoRoot);
467
+ persistRuntimeState(policyResult.persistTrigger, batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot);
464
468
  onNotify(policyResult.notifyMessage, policyResult.notifyLevel);
465
469
  // DO NOT cleanup/reset worktrees — preserve state for debugging/resume
466
470
  preserveWorktreesForResume = true;
@@ -516,7 +520,7 @@ export async function executeOrchBatch(
516
520
  // ── Save batch history (before cleanup deletes sidecar files) ────
517
521
  try {
518
522
  // Read token data from sidecar files while they still exist
519
- const piDir = join(repoRoot, ".pi");
523
+ const piDir = join(stateRoot, ".pi");
520
524
  const laneTokens = new Map<string, TokenCounts>();
521
525
  try {
522
526
  const files = readdirSync(piDir).filter(f => f.startsWith("lane-state-") && f.endsWith(".json"));
@@ -625,7 +629,7 @@ export async function executeOrchBatch(
625
629
  waves: waveSummaries,
626
630
  };
627
631
 
628
- saveBatchHistory(repoRoot, summary);
632
+ saveBatchHistory(stateRoot, summary);
629
633
  } catch (err) {
630
634
  execLog("batch", batchState.batchId, `failed to save batch history: ${err}`);
631
635
  }
@@ -650,7 +654,7 @@ export async function executeOrchBatch(
650
654
  }
651
655
 
652
656
  // Clean up sidecar files (lane state, worker conversation, merge artifacts)
653
- const piDir = join(repoRoot, ".pi");
657
+ const piDir = join(stateRoot, ".pi");
654
658
  try {
655
659
  const sidecarFiles = readdirSync(piDir).filter(
656
660
  f => f.startsWith("lane-state-") ||
@@ -745,7 +749,7 @@ export async function executeOrchBatch(
745
749
  }
746
750
 
747
751
  // ── TS-009: Persist terminal state ──
748
- persistRuntimeState("batch-terminal", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, repoRoot);
752
+ persistRuntimeState("batch-terminal", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot);
749
753
 
750
754
  if (batchState.phase === "paused" || batchState.phase === "stopped") {
751
755
  execLog("batch", batchState.batchId, "batch ended in non-terminal execution state; completion banner suppressed", {
@@ -767,7 +771,7 @@ export async function executeOrchBatch(
767
771
  // ── TS-009: Delete state file on clean completion (no failures) ──
768
772
  if (batchState.phase === "completed") {
769
773
  try {
770
- deleteBatchState(repoRoot);
774
+ deleteBatchState(stateRoot);
771
775
  execLog("state", batchState.batchId, "state file deleted on clean completion");
772
776
  } catch (err: unknown) {
773
777
  const msg = err instanceof Error ? err.message : String(err);
@@ -134,7 +134,7 @@ export function buildLaneEnvVars(
134
134
  TASK_AUTOSTART: relativePath,
135
135
  TASK_RUNNER_SPAWN_MODE: "subprocess",
136
136
  TASK_RUNNER_TMUX_PREFIX: lane.tmuxSessionName,
137
- ORCH_SIDECAR_DIR: join(repoRoot, ".pi"),
137
+ ORCH_SIDECAR_DIR: join(workspaceRoot || repoRoot, ".pi"),
138
138
  NODE_PATH: nodePath,
139
139
  // Pi's TUI (ink/react) hangs silently with TERM=tmux-256color (tmux default).
140
140
  // Force xterm-256color so pi can render and start execution.
@@ -269,6 +269,7 @@ export function spawnMergeAgent(
269
269
  mergeWorkDir: string,
270
270
  mergeRequestPath: string,
271
271
  config: OrchestratorConfig,
272
+ stateRoot?: string,
272
273
  ): void {
273
274
  execLog("merge", sessionName, "preparing to spawn merge agent", {
274
275
  mergeWorkDir,
@@ -303,7 +304,7 @@ export function spawnMergeAgent(
303
304
  "pi --no-session",
304
305
  modelArgs,
305
306
  toolsArgs,
306
- `--append-system-prompt ${shellQuote(join(repoRoot, ".pi", "agents", "task-merger.md"))}`,
307
+ `--append-system-prompt ${shellQuote(join(stateRoot ?? repoRoot, ".pi", "agents", "task-merger.md"))}`,
307
308
  `@${shellQuote(mergeRequestPath)}`,
308
309
  ].filter(Boolean).join(" ");
309
310
 
@@ -507,6 +508,7 @@ export function mergeWave(
507
508
  repoRoot: string,
508
509
  batchId: string,
509
510
  baseBranch: string,
511
+ stateRoot?: string,
510
512
  ): MergeWaveResult {
511
513
  const startTime = Date.now();
512
514
  const tmuxPrefix = config.orchestrator.tmux_prefix;
@@ -613,9 +615,10 @@ export function mergeWave(
613
615
  const laneStart = Date.now();
614
616
  const sessionName = `${tmuxPrefix}-${opId}-merge-${lane.laneNumber}`;
615
617
  const resultFileName = `merge-result-w${waveIndex}-lane${lane.laneNumber}-${opId}-${batchId}.json`;
616
- const resultFilePath = join(repoRoot, ".pi", resultFileName);
618
+ const piDir = stateRoot ?? repoRoot;
619
+ const resultFilePath = join(piDir, ".pi", resultFileName);
617
620
  const requestFileName = `merge-request-w${waveIndex}-lane${lane.laneNumber}-${opId}-${batchId}.txt`;
618
- const requestFilePath = join(repoRoot, ".pi", requestFileName);
621
+ const requestFilePath = join(piDir, ".pi", requestFileName);
619
622
 
620
623
  execLog("merge", sessionName, `starting merge for lane ${lane.laneNumber}`, {
621
624
  sourceBranch: lane.branch,
@@ -645,7 +648,7 @@ export function mergeWave(
645
648
  writeFileSync(requestFilePath, mergeRequestContent, "utf-8");
646
649
 
647
650
  // Spawn merge agent in the isolated merge worktree
648
- spawnMergeAgent(sessionName, repoRoot, mergeWorkDir, requestFilePath, config);
651
+ spawnMergeAgent(sessionName, repoRoot, mergeWorkDir, requestFilePath, config, stateRoot);
649
652
 
650
653
  // Wait for result
651
654
  const mergeResult = waitForMergeResult(resultFilePath, sessionName);
@@ -890,6 +893,7 @@ export function mergeWaveByRepo(
890
893
  batchId: string,
891
894
  baseBranch: string,
892
895
  workspaceConfig?: WorkspaceConfig | null,
896
+ stateRoot?: string,
893
897
  ): MergeWaveResult {
894
898
  const startTime = Date.now();
895
899
 
@@ -942,6 +946,7 @@ export function mergeWaveByRepo(
942
946
  repoRoot,
943
947
  batchId,
944
948
  baseBranch,
949
+ stateRoot,
945
950
  );
946
951
  // Attach empty repoResults for consistent shape
947
952
  return { ...result, repoResults: [] };
@@ -985,6 +990,7 @@ export function mergeWaveByRepo(
985
990
  groupRepoRoot,
986
991
  batchId,
987
992
  groupBaseBranch,
993
+ stateRoot,
988
994
  );
989
995
 
990
996
  // Accumulate lane results
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskplane",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "AI agent orchestration for pi — parallel task execution with checkpoint discipline",
5
5
  "keywords": [
6
6
  "pi-package",