taskplane 0.30.4 → 0.30.6

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.
@@ -9,7 +9,8 @@ import { join, basename, resolve } from "path";
9
9
  import { execLog } from "./execution.ts";
10
10
  import { runGit } from "./git.ts";
11
11
  import { resolveOperatorId } from "./naming.ts";
12
- import { DEFAULT_ORCHESTRATOR_CONFIG, WorktreeError } from "./types.ts";
12
+ import { DEFAULT_ORCHESTRATOR_CONFIG, WorktreeError, runtimeRoot } from "./types.ts";
13
+ import { assessEngineLiveness } from "./engine-identity.ts";
13
14
  import type {
14
15
  AllocatedLane,
15
16
  BulkWorktreeError,
@@ -716,10 +717,48 @@ export function runWindowsCmdRd(absolutePath: string): {
716
717
  * @throws WorktreeError with WORKTREE_REMOVE_FAILED for terminal (non-retriable) errors
717
718
  * @throws WorktreeError with WORKTREE_BRANCH_DELETE_FAILED if branch cleanup fails
718
719
  */
720
+ /**
721
+ * #628: does this worktree have uncommitted changes? Returns the count, 0 for
722
+ * clean, or null when it CANNOT be assessed — e.g. the path is a corrupted or
723
+ * orphaned worktree whose git context resolves to the PARENT repo (running
724
+ * `git status` there would report the parent's state, a false positive).
725
+ * Callers treat null as "proceed with removal" so corruption-recovery paths
726
+ * keep working; only a confirmed-dirty, functioning worktree refuses.
727
+ */
728
+ function worktreeUncommittedCount(worktreePath: string): number | null {
729
+ const top = runGit(["rev-parse", "--show-toplevel"], worktreePath);
730
+ if (!top.ok) return null;
731
+ const norm = (p: string) => {
732
+ // realpathSync.native expands Windows 8.3 short names (HENRYL~1 → HenryLach)
733
+ // so git's long-form output compares equal to a short-form input path.
734
+ let r: string;
735
+ try {
736
+ r = realpathSync.native(p.trim());
737
+ } catch {
738
+ r = resolve(p.trim());
739
+ }
740
+ r = r.replace(/[\\/]+/g, "/");
741
+ return process.platform === "win32" ? r.toLowerCase() : r;
742
+ };
743
+ if (norm(top.stdout) !== norm(worktreePath)) return null; // not this dir's own repo context
744
+ const st = runGit(["status", "--porcelain"], worktreePath);
745
+ if (!st.ok) return null;
746
+ const t = st.stdout.trim();
747
+ return t.length === 0 ? 0 : t.split(/\r?\n/).length;
748
+ }
749
+
719
750
  export function removeWorktree(
720
751
  worktree: WorktreeInfo,
721
752
  repoRoot: string,
722
753
  targetBranch?: string,
754
+ options?: {
755
+ /**
756
+ * #628: permit removal even when the worktree has uncommitted changes.
757
+ * Only pass true when the caller has ALREADY preserved progress (commit,
758
+ * stash, or progress branch). Default false = refuse when dirty.
759
+ */
760
+ allowDirty?: boolean;
761
+ },
723
762
  ): RemoveWorktreeResult {
724
763
  const { path: worktreePath, branch } = worktree;
725
764
 
@@ -755,6 +794,33 @@ export function removeWorktree(
755
794
  };
756
795
  }
757
796
 
797
+ // ── #628: uncommitted-work guard ────────────────────────────
798
+ // Removal uses `git worktree remove --force`, which destroys uncommitted
799
+ // changes. In the reported incident a takeover path removed a held lane's
800
+ // worktree and the worker's uncommitted files were lost (recovered only via
801
+ // dangling objects). Safety invariant: NEVER remove a worktree with
802
+ // uncommitted changes unless the caller explicitly opts in after preserving
803
+ // progress. Refusal is non-fatal — callers already handle removed:false.
804
+ if (pathExists && !options?.allowDirty) {
805
+ const dirtyFileCount = worktreeUncommittedCount(worktreePath);
806
+ if (dirtyFileCount !== null && dirtyFileCount > 0) {
807
+ execLog(
808
+ "cleanup",
809
+ "worktree",
810
+ `REFUSED to remove worktree with ${dirtyFileCount} uncommitted change(s) — commit/stash or pass allowDirty after preserving progress (#628)`,
811
+ { path: worktreePath, branch },
812
+ );
813
+ return {
814
+ removed: false,
815
+ alreadyRemoved: false,
816
+ branchDeleted: false,
817
+ branchPreserved: true,
818
+ refusedDirty: true,
819
+ dirtyFileCount,
820
+ };
821
+ }
822
+ }
823
+
758
824
  // ── Attempt removal with retry/backoff ───────────────────────
759
825
  const RETRY_DELAYS_MS = [1000, 2000, 4000, 8000, 16000];
760
826
  const MAX_ATTEMPTS = RETRY_DELAYS_MS.length + 1; // first attempt + retries
@@ -2105,9 +2171,31 @@ export function forceCleanupWorktree(
2105
2171
  worktree: WorktreeInfo,
2106
2172
  repoRoot: string,
2107
2173
  batchId: string,
2174
+ options?: {
2175
+ /** #628: permit force-removal even with uncommitted changes. Only after preserving progress. */
2176
+ allowDirty?: boolean;
2177
+ },
2108
2178
  ): void {
2109
2179
  const { path: worktreePath, branch, laneNumber } = worktree;
2110
2180
 
2181
+ // ── #628: uncommitted-work guard (same invariant as removeWorktree) ───
2182
+ // This is the raw-rmSync last resort — without the guard it silently
2183
+ // destroys uncommitted worker files (e.g. batch-start cleanup of a prior
2184
+ // batch's held lane). "Force" here means stubborn-removal MECHANICS
2185
+ // (Windows reserved names), not overriding the data-safety invariant.
2186
+ if (existsSync(worktreePath) && !options?.allowDirty) {
2187
+ const dirtyFileCount = worktreeUncommittedCount(worktreePath);
2188
+ if (dirtyFileCount !== null && dirtyFileCount > 0) {
2189
+ execLog(
2190
+ "cleanup",
2191
+ `lane-${laneNumber}`,
2192
+ `REFUSED force-cleanup: worktree has ${dirtyFileCount} uncommitted change(s) — preserve progress first (#628)`,
2193
+ { path: worktreePath, branch, batchId },
2194
+ );
2195
+ return;
2196
+ }
2197
+ }
2198
+
2111
2199
  // Step 1: Force-remove the directory
2112
2200
  if (existsSync(worktreePath)) {
2113
2201
  try {
@@ -2615,6 +2703,33 @@ export interface StaleBranchCleanupResult {
2615
2703
  deletedSavedBranches: string[];
2616
2704
  /** Branches that failed to delete (best-effort) */
2617
2705
  failedDeletes: string[];
2706
+ /**
2707
+ * #631: branches of OTHER batches that were kept because that batch's engine
2708
+ * is alive, or its ownership is unknown (runtime dir present, no identity).
2709
+ */
2710
+ skippedOwnedBranches?: string[];
2711
+ }
2712
+
2713
+ /**
2714
+ * #631: may a lane branch belonging to ANOTHER batch be swept as an orphan?
2715
+ * The TP-051 operator-wide sweep is kept (orphans from finished batches do
2716
+ * accumulate), but never for a batch whose engine is alive, nor for one whose
2717
+ * ownership is unknown (a runtime dir exists with no engine identity — a
2718
+ * pre-#631 engine of unknown state). No runtime dir at all = no engine
2719
+ * evidence anywhere → a pure leftover → sweepable.
2720
+ */
2721
+ function otherBatchBranchSweepable(ownershipRoot: string, otherBatchId: string): boolean {
2722
+ const liveness = assessEngineLiveness(ownershipRoot, otherBatchId);
2723
+ if (liveness.status === "alive") return false;
2724
+ if (liveness.status === "none" && existsSync(runtimeRoot(ownershipRoot, otherBatchId)))
2725
+ return false;
2726
+ return true;
2727
+ }
2728
+
2729
+ /** `task/{opId}-lane-{N}-{batchId}` / `saved/task/…-{batchId}` → batchId (last dash segment). */
2730
+ function laneBranchBatchId(branch: string): string | null {
2731
+ const m = /-lane-\d+-([A-Za-z0-9._]+)$/.exec(branch);
2732
+ return m ? m[1] : null;
2618
2733
  }
2619
2734
 
2620
2735
  /**
@@ -2645,10 +2760,34 @@ export function deleteStaleBranches(
2645
2760
  repoRoot: string,
2646
2761
  opId: string,
2647
2762
  batchId: string,
2763
+ /**
2764
+ * #631: root under which `.pi/runtime/<batchId>/engine.json` lives (workspace
2765
+ * root in workspace mode). Defaults to repoRoot.
2766
+ */
2767
+ ownershipRoot: string = repoRoot,
2648
2768
  ): StaleBranchCleanupResult {
2649
2769
  const deletedTaskBranches: string[] = [];
2650
2770
  const deletedSavedBranches: string[] = [];
2651
2771
  const failedDeletes: string[] = [];
2772
+ const skippedOwnedBranches: string[] = [];
2773
+ // #631: a lane branch of another batch is only swept when that batch's engine
2774
+ // is verifiably gone and its ownership is not unknown.
2775
+ const guardOtherBatch = (branch: string): boolean => {
2776
+ const other = laneBranchBatchId(branch);
2777
+ if (!other || other === batchId) return true;
2778
+ if (otherBatchBranchSweepable(ownershipRoot, other)) return true;
2779
+ skippedOwnedBranches.push(branch);
2780
+ execLog(
2781
+ "cleanup",
2782
+ batchId,
2783
+ `kept lane branch of another batch (engine alive or ownership unknown, #631)`,
2784
+ {
2785
+ branch,
2786
+ otherBatchId: other,
2787
+ },
2788
+ );
2789
+ return false;
2790
+ };
2652
2791
 
2653
2792
  // 1. Delete task/{opId}-lane-* branches
2654
2793
  const taskBranchResult = runGit(["branch", "--list", `task/${opId}-lane-*`], repoRoot);
@@ -2659,6 +2798,7 @@ export function deleteStaleBranches(
2659
2798
  .filter(Boolean);
2660
2799
 
2661
2800
  for (const branch of branches) {
2801
+ if (!guardOtherBatch(branch)) continue;
2662
2802
  const deleted = deleteBranchBestEffort(branch, repoRoot);
2663
2803
  if (deleted) {
2664
2804
  deletedTaskBranches.push(branch);
@@ -2677,6 +2817,7 @@ export function deleteStaleBranches(
2677
2817
  .filter(Boolean);
2678
2818
 
2679
2819
  for (const branch of branches) {
2820
+ if (!guardOtherBatch(branch)) continue;
2680
2821
  const deleted = deleteBranchBestEffort(branch, repoRoot);
2681
2822
  if (deleted) {
2682
2823
  deletedSavedBranches.push(branch);
@@ -2721,5 +2862,5 @@ export function deleteStaleBranches(
2721
2862
  });
2722
2863
  }
2723
2864
 
2724
- return { deletedTaskBranches, deletedSavedBranches, failedDeletes };
2865
+ return { deletedTaskBranches, deletedSavedBranches, failedDeletes, skippedOwnedBranches };
2725
2866
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskplane",
3
- "version": "0.30.4",
3
+ "version": "0.30.6",
4
4
  "description": "AI agent orchestration for pi — parallel task execution with checkpoint discipline",
5
5
  "keywords": [
6
6
  "pi-package",