taskplane 0.5.3 → 0.5.5

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.
@@ -2,8 +2,8 @@
2
2
  * Main batch execution engine
3
3
  * @module orch/engine
4
4
  */
5
- import { readFileSync, readdirSync, unlinkSync } from "fs";
6
- import { join } from "path";
5
+ import { existsSync, readFileSync, readdirSync, unlinkSync } from "fs";
6
+ import { dirname, join, resolve } from "path";
7
7
 
8
8
  import { formatDiscoveryResults, runDiscovery } from "./discovery.ts";
9
9
  import { execLog, executeWave, tmuxKillSession } from "./execution.ts";
@@ -339,6 +339,16 @@ export async function executeOrchBatch(
339
339
  }
340
340
  }
341
341
 
342
+ // ── Workspace mode: commit task artifacts to task-area repos ─
343
+ // In workspace mode, workers write .DONE and STATUS.md to the
344
+ // canonical task folder (e.g., shared-libs/task-management/...) via
345
+ // absolute paths, not to the lane worktree. These changes land as
346
+ // uncommitted modifications in the task-area repo's working tree.
347
+ // Commit them before the merge step so they appear in the orch branch.
348
+ if (workspaceConfig && waveResult.succeededTaskIds.length > 0) {
349
+ commitWorkspaceTaskArtifacts(discoveryRef, workspaceRoot ?? repoRoot, waveIdx + 1, batchState.batchId);
350
+ }
351
+
342
352
  // ── Wave Merge ───────────────────────────────────────────
343
353
  // Only merge if there are succeeded tasks in this wave
344
354
  let mergeResult: MergeWaveResult | null = null;
@@ -847,5 +857,78 @@ export async function executeOrchBatch(
847
857
  }
848
858
 
849
859
 
860
+ // ── Workspace Task Artifact Commit ───────────────────────────────────
861
+
862
+ /**
863
+ * In workspace mode, commit task artifacts (.DONE, STATUS.md) that workers
864
+ * wrote to the canonical task folder in the task-area repo.
865
+ *
866
+ * Workers write to absolute paths (e.g., shared-libs/task-management/.../TP-002/.DONE)
867
+ * which land as uncommitted changes in the task-area repo's working tree.
868
+ * This function finds all task-area repos with dirty task files and commits them
869
+ * so they appear in the lane branches and merge correctly.
870
+ *
871
+ * Best-effort: failures are logged but don't block the batch.
872
+ */
873
+ function commitWorkspaceTaskArtifacts(
874
+ discovery: DiscoveryResult | null,
875
+ workspaceRoot: string,
876
+ waveIndex: number,
877
+ batchId: string,
878
+ ): void {
879
+ if (!discovery) return;
880
+
881
+ // Collect unique repo roots that contain task folders
882
+ const repoRootsWithTasks = new Set<string>();
883
+ for (const [, task] of discovery.pending) {
884
+ const taskFolder = resolve(task.taskFolder);
885
+ // Walk up to find the git repo root for this task folder
886
+ const gitResult = runGit(["rev-parse", "--show-toplevel"], dirname(taskFolder));
887
+ if (gitResult.ok) {
888
+ repoRootsWithTasks.add(gitResult.stdout.trim().replace(/\\/g, "/"));
889
+ }
890
+ }
891
+
892
+ for (const taskRepoRoot of repoRootsWithTasks) {
893
+ // Check for uncommitted changes
894
+ const statusResult = runGit(["status", "--porcelain", "--", "task-management/"], taskRepoRoot);
895
+ if (!statusResult.ok) {
896
+ // Try without path filter (task area might have different name)
897
+ const statusAll = runGit(["status", "--porcelain"], taskRepoRoot);
898
+ if (!statusAll.ok || !statusAll.stdout.trim()) continue;
899
+ }
900
+ if (statusResult.ok && !statusResult.stdout.trim()) continue;
901
+
902
+ // Stage task artifacts (only .DONE and STATUS.md files)
903
+ const lines = (statusResult.stdout || "").split("\n").filter(l => l.trim());
904
+ let hasTaskArtifacts = false;
905
+ for (const line of lines) {
906
+ const file = line.slice(3).trim();
907
+ if (file.endsWith(".DONE") || file.endsWith("STATUS.md")) {
908
+ const addResult = runGit(["add", file], taskRepoRoot);
909
+ if (addResult.ok) hasTaskArtifacts = true;
910
+ }
911
+ }
912
+
913
+ if (!hasTaskArtifacts) continue;
914
+
915
+ // Commit
916
+ const commitResult = runGit(
917
+ ["commit", "-m", `checkpoint: wave ${waveIndex} task artifacts (.DONE, STATUS.md)`],
918
+ taskRepoRoot,
919
+ );
920
+ if (commitResult.ok) {
921
+ execLog("batch", batchId, `committed workspace task artifacts`, {
922
+ repoRoot: taskRepoRoot,
923
+ wave: waveIndex,
924
+ });
925
+ } else if (!commitResult.stderr.includes("nothing to commit")) {
926
+ execLog("batch", batchId, `workspace task artifact commit failed (non-fatal): ${commitResult.stderr.slice(0, 200)}`, {
927
+ repoRoot: taskRepoRoot,
928
+ });
929
+ }
930
+ }
931
+ }
932
+
850
933
  // ── Dashboard Widget (Step 6) ────────────────────────────────────────
851
934
 
@@ -413,21 +413,26 @@ export function resolveCanonicalTaskPaths(
413
413
  taskFolder: string,
414
414
  worktreePath: string,
415
415
  repoRoot: string,
416
+ isWorkspaceMode?: boolean,
416
417
  ): ResolvedTaskPaths {
417
418
  const repoRootNorm = resolve(repoRoot).replace(/\\/g, "/");
418
419
  const folderNorm = resolve(taskFolder).replace(/\\/g, "/");
419
420
 
420
421
  let resolvedFolder: string;
421
422
 
422
- if (folderNorm.startsWith(repoRootNorm + "/")) {
423
- // Case 1: Task folder is inside the repo root.
423
+ if (isWorkspaceMode) {
424
+ // Workspace mode: task folder may live in a different repo than
425
+ // the lane's worktree. Always use the absolute canonical path —
426
+ // .DONE and STATUS.md are written by workers to the original
427
+ // task folder (via absolute TASK_AUTOSTART path), not to the worktree.
428
+ resolvedFolder = resolve(taskFolder);
429
+ } else if (folderNorm.startsWith(repoRootNorm + "/")) {
430
+ // Repo mode: task folder is inside the repo root.
424
431
  // Translate to equivalent path in the worktree.
425
432
  const relativePath = folderNorm.slice(repoRootNorm.length + 1);
426
433
  resolvedFolder = join(worktreePath, relativePath);
427
434
  } else {
428
- // Case 2: Task folder is outside the repo root (workspace mode).
429
- // Use the absolute path directly — task state lives in the
430
- // canonical task folder, not mirrored in any worktree.
435
+ // Fallback: use absolute path directly.
431
436
  resolvedFolder = resolve(taskFolder);
432
437
  }
433
438
 
@@ -484,8 +489,9 @@ export function resolveTaskDonePath(
484
489
  taskFolder: string,
485
490
  worktreePath: string,
486
491
  repoRoot: string,
492
+ isWorkspaceMode?: boolean,
487
493
  ): string {
488
- return resolveCanonicalTaskPaths(taskFolder, worktreePath, repoRoot).donePath;
494
+ return resolveCanonicalTaskPaths(taskFolder, worktreePath, repoRoot, isWorkspaceMode).donePath;
489
495
  }
490
496
 
491
497
  /**
@@ -613,10 +619,11 @@ export async function pollUntilTaskComplete(
613
619
  config: OrchestratorConfig,
614
620
  repoRoot: string,
615
621
  pauseSignal: { paused: boolean },
622
+ isWorkspaceMode?: boolean,
616
623
  ): Promise<{ status: LaneTaskStatus; exitReason: string; doneFileFound: boolean }> {
617
624
  const sessionName = lane.tmuxSessionName;
618
625
  const laneId = lane.laneId;
619
- const resolved = resolveCanonicalTaskPaths(task.task.taskFolder, lane.worktreePath, repoRoot);
626
+ const resolved = resolveCanonicalTaskPaths(task.task.taskFolder, lane.worktreePath, repoRoot, isWorkspaceMode);
620
627
  const donePath = resolved.donePath;
621
628
  const statusPath = resolved.statusPath;
622
629
  const laneLogPath = resolveLaneLogPath(lane, task);
@@ -832,6 +839,7 @@ export async function executeLane(
832
839
  repoRoot: string,
833
840
  pauseSignal: { paused: boolean },
834
841
  workspaceRoot?: string,
842
+ isWorkspaceMode?: boolean,
835
843
  ): Promise<LaneExecutionResult> {
836
844
  const laneId = lane.laneId;
837
845
  const laneStartTime = Date.now();
@@ -877,6 +885,7 @@ export async function executeLane(
877
885
  config,
878
886
  repoRoot,
879
887
  pauseSignal,
888
+ isWorkspaceMode,
880
889
  );
881
890
 
882
891
  taskOutcome = {
@@ -1003,9 +1012,10 @@ export function parseWorktreeStatusMd(
1003
1012
  taskFolder: string,
1004
1013
  worktreePath: string,
1005
1014
  repoRoot: string,
1015
+ isWorkspaceMode?: boolean,
1006
1016
  ): { parsed: ParsedWorktreeStatus | null; error: string | null } {
1007
1017
  // Use canonical resolver for consistent path translation
1008
- const resolved = resolveCanonicalTaskPaths(taskFolder, worktreePath, repoRoot);
1018
+ const resolved = resolveCanonicalTaskPaths(taskFolder, worktreePath, repoRoot, isWorkspaceMode);
1009
1019
  const statusPath = resolved.statusPath;
1010
1020
 
1011
1021
  if (!existsSync(statusPath)) {
@@ -1323,6 +1333,7 @@ export async function monitorLanes(
1323
1333
  pauseSignal: { paused: boolean },
1324
1334
  waveNumber: number = 1,
1325
1335
  onUpdate?: MonitorUpdateCallback,
1336
+ isWorkspaceMode?: boolean,
1326
1337
  ): Promise<MonitorState> {
1327
1338
  const pollIntervalMs = (config.monitoring.poll_interval || 5) * 1000;
1328
1339
  const stallTimeoutMs = (config.failure.stall_timeout || 30) * 60_000;
@@ -1412,8 +1423,8 @@ export async function monitorLanes(
1412
1423
  currentTaskId = task.taskId;
1413
1424
 
1414
1425
  const tracker = getOrCreateTracker(task.taskId, now);
1415
- const donePath = resolveTaskDonePath(task.task.taskFolder, lane.worktreePath, repoRoot);
1416
- const statusResult = parseWorktreeStatusMd(task.task.taskFolder, lane.worktreePath, repoRoot);
1426
+ const donePath = resolveTaskDonePath(task.task.taskFolder, lane.worktreePath, repoRoot, isWorkspaceMode);
1427
+ const statusResult = parseWorktreeStatusMd(task.task.taskFolder, lane.worktreePath, repoRoot, isWorkspaceMode);
1417
1428
 
1418
1429
  const snapshot = resolveTaskMonitorState(
1419
1430
  task.taskId,
@@ -1819,8 +1830,9 @@ export async function executeWave(
1819
1830
  // In workspace mode, pass the workspace root so lane sessions can find .pi/ config.
1820
1831
  // configPath is .pi/taskplane-workspace.yaml → parent of parent is workspace root.
1821
1832
  const wsRoot = workspaceConfig ? dirname(dirname(workspaceConfig.configPath)) : undefined;
1833
+ const isWsMode = !!workspaceConfig;
1822
1834
  const lanePromises = lanes.map(lane =>
1823
- executeLane(lane, config, repoRoot, wavePauseSignal, wsRoot),
1835
+ executeLane(lane, config, repoRoot, wavePauseSignal, wsRoot, isWsMode),
1824
1836
  );
1825
1837
 
1826
1838
  // Start monitoring as a sibling async loop
@@ -1832,6 +1844,7 @@ export async function executeWave(
1832
1844
  wavePauseSignal,
1833
1845
  waveIndex,
1834
1846
  onMonitorUpdate,
1847
+ isWsMode,
1835
1848
  );
1836
1849
 
1837
1850
  // ── Stage 4: Wait for all lanes + apply policy ───────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskplane",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
4
4
  "description": "AI agent orchestration for pi — parallel task execution with checkpoint discipline",
5
5
  "keywords": [
6
6
  "pi-package",