taskplane 0.26.1 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,7 +13,7 @@ import { resolvePacketPaths, buildRuntimeAgentId } from "./types.ts";
13
13
  import { readRegistrySnapshot, readLaneSnapshot, isTerminalStatus, isProcessAlive, detectOrphans, markOrphansCrashed, buildRegistrySnapshot, writeRegistrySnapshot } from "./process-registry.ts";
14
14
  import { allocateLanes } from "./waves.ts";
15
15
  import { resolveOperatorId } from "./naming.ts";
16
- import { runGit } from "./git.ts";
16
+ import { runGit, runGitWithEnv } from "./git.ts";
17
17
  import { resolveTaskplanePackageFile, resolveTaskplaneAgentTemplate } from "./path-resolver.ts";
18
18
  import { resolvePointer, loadWorkspaceConfig } from "./workspace.ts";
19
19
 
@@ -1436,6 +1436,124 @@ export function ensureTaskFilesCommitted(
1436
1436
 
1437
1437
  if (foldersToStage.length === 0) return;
1438
1438
 
1439
+ // TP-169: When an orch branch is provided, commit task files directly on
1440
+ // the orch branch using a temporary git index file. This avoids polluting
1441
+ // the repo's current branch (e.g. main) with orchestrator-internal staging
1442
+ // commits, maintaining proper branch isolation in workspace mode.
1443
+ //
1444
+ // Approach:
1445
+ // 1. Read the orch branch's tree into a temporary index
1446
+ // 2. Add new/modified task files to the temporary index
1447
+ // 3. Write the combined tree
1448
+ // 4. Create a commit on the orch branch
1449
+ // 5. Update the orch branch ref
1450
+ // 6. Clean up the temporary index
1451
+ //
1452
+ // Fallback: if orch branch plumbing fails or orchBranch is not provided,
1453
+ // fall back to the legacy path of committing on HEAD.
1454
+ if (orchBranch) {
1455
+ const orchTipRes = runGit(["rev-parse", `refs/heads/${orchBranch}`], repoRoot);
1456
+ if (orchTipRes.ok) {
1457
+ const orchTip = orchTipRes.stdout.trim();
1458
+ const tmpIdx = join(repoRoot, ".git", `tmp-staging-idx-wave-${waveIndex}`);
1459
+
1460
+ try {
1461
+ // Read orch branch tree into temporary index
1462
+ const readTreeRes = runGitWithEnv(
1463
+ ["read-tree", orchTip],
1464
+ repoRoot,
1465
+ { GIT_INDEX_FILE: tmpIdx },
1466
+ );
1467
+ if (!readTreeRes.ok) {
1468
+ execLog("wave", `W${waveIndex}`, `orch branch staging: read-tree failed, falling back to HEAD commit`, {
1469
+ error: readTreeRes.stderr,
1470
+ });
1471
+ // Fall through to legacy path
1472
+ } else {
1473
+ // Add task files to temporary index
1474
+ let addFailed = false;
1475
+ for (const folder of foldersToStage) {
1476
+ const addRes = runGitWithEnv(
1477
+ ["add", "--", folder],
1478
+ repoRoot,
1479
+ { GIT_INDEX_FILE: tmpIdx },
1480
+ );
1481
+ if (!addRes.ok) {
1482
+ execLog("wave", `W${waveIndex}`, `orch branch staging: git add failed for ${folder}, falling back`, {
1483
+ error: addRes.stderr,
1484
+ });
1485
+ addFailed = true;
1486
+ break;
1487
+ }
1488
+ }
1489
+
1490
+ if (!addFailed) {
1491
+ // Write tree from temporary index
1492
+ const writeTreeRes = runGitWithEnv(
1493
+ ["write-tree"],
1494
+ repoRoot,
1495
+ { GIT_INDEX_FILE: tmpIdx },
1496
+ );
1497
+
1498
+ if (writeTreeRes.ok) {
1499
+ const tree = writeTreeRes.stdout.trim();
1500
+ const taskIds = foldersToStage.map(f => f.split("/").pop() || f).join(", ");
1501
+ const commitMsg = `chore: stage task files for orchestrator wave ${waveIndex} (${taskIds})`;
1502
+
1503
+ // Create commit directly on orch branch
1504
+ const commitTreeRes = runGit(
1505
+ ["commit-tree", tree, "-p", orchTip, "-m", commitMsg],
1506
+ repoRoot,
1507
+ );
1508
+
1509
+ if (commitTreeRes.ok) {
1510
+ const newCommit = commitTreeRes.stdout.trim();
1511
+ const refUpdateRes = runGit(
1512
+ ["update-ref", `refs/heads/${orchBranch}`, newCommit, orchTip],
1513
+ repoRoot,
1514
+ );
1515
+
1516
+ if (refUpdateRes.ok) {
1517
+ execLog("wave", `W${waveIndex}`, `committed ${foldersToStage.length} task folder(s) directly on orch branch`, {
1518
+ orchBranch,
1519
+ folders: foldersToStage,
1520
+ from: orchTip.slice(0, 8),
1521
+ to: newCommit.slice(0, 8),
1522
+ });
1523
+ // Clean up temp index and return — no need for legacy path
1524
+ try { unlinkSync(tmpIdx); } catch { /* best effort */ }
1525
+ return;
1526
+ }
1527
+ execLog("wave", `W${waveIndex}`, `orch branch staging: ref update failed, falling back`, {
1528
+ error: refUpdateRes.stderr,
1529
+ });
1530
+ } else {
1531
+ execLog("wave", `W${waveIndex}`, `orch branch staging: commit-tree failed, falling back`, {
1532
+ error: commitTreeRes.stderr,
1533
+ });
1534
+ }
1535
+ } else {
1536
+ execLog("wave", `W${waveIndex}`, `orch branch staging: write-tree failed, falling back`, {
1537
+ error: writeTreeRes.stderr,
1538
+ });
1539
+ }
1540
+ }
1541
+ }
1542
+ } catch (err: unknown) {
1543
+ execLog("wave", `W${waveIndex}`, `orch branch staging: unexpected error, falling back to HEAD commit`, {
1544
+ error: err instanceof Error ? err.message : String(err),
1545
+ });
1546
+ } finally {
1547
+ // Always clean up temp index
1548
+ try { unlinkSync(tmpIdx); } catch { /* best effort */ }
1549
+ }
1550
+ }
1551
+ }
1552
+
1553
+ // Legacy fallback: commit on HEAD and sync orch branch.
1554
+ // This path is used when orchBranch is not provided, or when the
1555
+ // plumbing-based approach above failed.
1556
+
1439
1557
  // Stage only the task folders
1440
1558
  for (const folder of foldersToStage) {
1441
1559
  const addResult = runGit(["add", "--", folder], repoRoot);
@@ -1472,15 +1590,6 @@ export function ensureTaskFilesCommitted(
1472
1590
  // Fast-forward (or merge) the orch branch to include the staging commit so
1473
1591
  // that worktrees—which branch from orchBranch—see the new task files and
1474
1592
  // workers can find their PROMPT.md / STATUS.md without an ENOENT crash.
1475
- //
1476
- // The orch branch was created from baseBranch before executeWave runs, so in
1477
- // wave 1 it is always an ancestor of HEAD and a plain fast-forward applies.
1478
- // In wave 2+ the orch branch may have advanced due to prior wave merges
1479
- // (commits from worker worktrees merged back). In that case we create a merge
1480
- // commit so the task files become visible without rewinding any wave history.
1481
- //
1482
- // Failure here is non-fatal: the commit already succeeded; if the ref update
1483
- // fails the subsequent worktree allocation will produce a clear error.
1484
1593
  if (orchBranch) {
1485
1594
  try {
1486
1595
  const headRes = runGit(["rev-parse", "HEAD"], repoRoot);
@@ -1490,16 +1599,12 @@ export function ensureTaskFilesCommitted(
1490
1599
  const newHead = headRes.stdout.trim();
1491
1600
  const orchTip = orchTipRes.stdout.trim();
1492
1601
 
1493
- // Check whether the orch branch tip is an ancestor of the new HEAD
1494
- // (i.e., a fast-forward is safe and sufficient).
1495
1602
  const ancestorCheck = runGit(
1496
1603
  ["merge-base", "--is-ancestor", orchTip, newHead],
1497
1604
  repoRoot,
1498
1605
  );
1499
1606
 
1500
1607
  if (ancestorCheck.ok) {
1501
- // FF case: orch branch is behind HEAD — move it forward.
1502
- // Expected-old-sha semantics guard against concurrent ref moves.
1503
1608
  const ffResult = runGit(
1504
1609
  ["update-ref", `refs/heads/${orchBranch}`, newHead, orchTip],
1505
1610
  repoRoot,
@@ -1517,29 +1622,16 @@ export function ensureTaskFilesCommitted(
1517
1622
  });
1518
1623
  }
1519
1624
  } else {
1520
- // Non-FF case: orch branch has advanced due to prior wave merges.
1521
- // Create a merge commit so the new task files become visible in
1522
- // worktrees without discarding any accumulated wave history.
1523
- // Requires git ≥ 2.38 for `merge-tree --write-tree`.
1524
1625
  const mergeTreeRes = runGit(
1525
1626
  ["merge-tree", "--write-tree", orchTip, newHead],
1526
1627
  repoRoot,
1527
1628
  );
1528
1629
  if (mergeTreeRes.ok) {
1529
- // First line of stdout is the merged tree SHA.
1530
- // git merge-tree --write-tree exits 0 on clean merge, non-zero on conflicts.
1531
- // Since it exited 0, the tree should be conflict-free, but validate
1532
- // the SHA looks like a valid 40-hex OID before using it.
1533
1630
  const mergedTree = mergeTreeRes.stdout.trim().split("\n")[0];
1534
- if (!/^[0-9a-f]{40}$/i.test(mergedTree)) {
1535
- execLog("wave", `W${waveIndex}`, `warning: merge-tree returned unexpected output (non-fatal)`, {
1536
- orchBranch,
1537
- output: mergedTree.slice(0, 60),
1538
- });
1539
- } else {
1540
- const mergeCommitMsg = `merge: include staged task files for wave ${waveIndex} into orch branch`;
1631
+ if (/^[0-9a-f]{40}$/i.test(mergedTree)) {
1632
+ const mergeMsg = `merge: include staged task files for wave ${waveIndex} into orch branch`;
1541
1633
  const commitTreeRes = runGit(
1542
- ["commit-tree", mergedTree, "-p", orchTip, "-p", newHead, "-m", mergeCommitMsg],
1634
+ ["commit-tree", mergedTree, "-p", orchTip, "-p", newHead, "-m", mergeMsg],
1543
1635
  repoRoot,
1544
1636
  );
1545
1637
  if (commitTreeRes.ok) {
@@ -1551,28 +1643,11 @@ export function ensureTaskFilesCommitted(
1551
1643
  if (refUpdateRes.ok) {
1552
1644
  execLog("wave", `W${waveIndex}`, `merged staging commit into orch branch (non-FF wave)`, {
1553
1645
  orchBranch,
1554
- orchTip: orchTip.slice(0, 8),
1555
- newHead: newHead.slice(0, 8),
1556
1646
  mergeCommit: mergeCommitSha.slice(0, 8),
1557
1647
  });
1558
- } else {
1559
- execLog("wave", `W${waveIndex}`, `warning: failed to update orch branch ref after merge-tree (non-fatal)`, {
1560
- orchBranch,
1561
- error: refUpdateRes.stderr,
1562
- });
1563
1648
  }
1564
- } else {
1565
- execLog("wave", `W${waveIndex}`, `warning: failed to create merge commit for orch branch (non-fatal)`, {
1566
- orchBranch,
1567
- error: commitTreeRes.stderr,
1568
- });
1569
1649
  }
1570
- } // end valid tree SHA
1571
- } else {
1572
- execLog("wave", `W${waveIndex}`, `warning: failed to compute merge-tree for orch branch (non-fatal; requires git ≥ 2.38)`, {
1573
- orchBranch,
1574
- error: mergeTreeRes.stderr,
1575
- });
1650
+ }
1576
1651
  }
1577
1652
  }
1578
1653
  }
@@ -2093,8 +2168,23 @@ export function buildExecutionUnit(
2093
2168
  repoRoot: string,
2094
2169
  isWorkspaceMode?: boolean,
2095
2170
  ): ExecutionUnit {
2171
+ // TP-169: Guard against missing taskFolder. This can happen when
2172
+ // reconstructAllocatedLanes creates task stubs from persisted state
2173
+ // where taskFolder enrichment failed (e.g., dynamically-expanded
2174
+ // segments whose persisted records had empty taskFolder).
2175
+ const taskFolder = task.task?.taskFolder;
2176
+ if (!taskFolder) {
2177
+ throw new ExecutionError(
2178
+ "EXEC_MISSING_TASK_FOLDER",
2179
+ `Cannot build execution unit for task ${task.taskId}: taskFolder is ${taskFolder === "" ? "empty" : "undefined"}. ` +
2180
+ `This typically means the task's persisted record was not enriched with discovery data. ` +
2181
+ `Re-run discovery or check that the task exists in the task area.`,
2182
+ "execution",
2183
+ task.taskId,
2184
+ );
2185
+ }
2096
2186
  const resolved = resolveCanonicalTaskPaths(
2097
- task.task.taskFolder,
2187
+ taskFolder,
2098
2188
  lane.worktreePath,
2099
2189
  repoRoot,
2100
2190
  isWorkspaceMode,