pi-crew 0.9.28 → 0.9.29

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.
@@ -551,6 +551,100 @@ export function overlaySeedPaths(repoRoot: string, worktreePath: string, seedPat
551
551
  }
552
552
  }
553
553
 
554
+ /**
555
+ * Snapshot uncommitted work in a reused worktree to a recovery artifact BEFORE
556
+ * discarding it, so the data is never silently lost. Captures tracked changes
557
+ * (git diff HEAD) and inlines untracked file contents so the work can be
558
+ * recovered via `git apply` / manual restore. Best-effort: a snapshot failure
559
+ * only logs (it must not block the clean-slate reuse flow).
560
+ */
561
+ function snapshotDirtyWorktree(manifest: TeamRunManifest, task: TeamTaskState, worktreePath: string, dirtyStatus: string): void {
562
+ try {
563
+ const parts: string[] = [
564
+ `# Worktree recovery snapshot`,
565
+ `runId: ${manifest.runId} taskId: ${task.id} path: ${worktreePath}`,
566
+ `capturedAt: ${new Date().toISOString()}`,
567
+ "",
568
+ ];
569
+ let trackedDiff = "";
570
+ try {
571
+ trackedDiff = git(worktreePath, ["diff", "HEAD"]);
572
+ } catch {
573
+ trackedDiff = "";
574
+ }
575
+ if (trackedDiff.trim()) {
576
+ parts.push("## Tracked changes (`git diff HEAD`)", "```diff", trackedDiff, "```", "");
577
+ }
578
+ for (const line of dirtyStatus.split("\n")) {
579
+ if (!line.startsWith("?? ")) continue;
580
+ // Strip the "?? " prefix and surrounding git quotes.
581
+ const rel = line.slice(3).replace(/^"|"$/g, "");
582
+ if (!rel) continue;
583
+ try {
584
+ const abs = path.join(worktreePath, rel);
585
+ if (!fs.existsSync(abs) || fs.statSync(abs).isDirectory()) continue;
586
+ const content = fs.readFileSync(abs, "utf-8");
587
+ parts.push(`## Untracked file: ${rel}`, "```", content, "```", "");
588
+ } catch {
589
+ /* skip unreadable/unstat-able entry */
590
+ }
591
+ }
592
+ writeArtifact(manifest.artifactsRoot, {
593
+ kind: "diff",
594
+ relativePath: `worktree-recovery/${task.id}-${Date.now()}.md`,
595
+ content: parts.join("\n"),
596
+ producer: "worktree-manager.snapshotDirtyWorktree",
597
+ retention: "run",
598
+ });
599
+ } catch (err) {
600
+ logInternalError(
601
+ "worktree.recovery.snapshotFailed",
602
+ err instanceof Error ? err : new Error(String(err)),
603
+ `runId=${manifest.runId}, taskId=${task.id}`,
604
+ );
605
+ }
606
+ }
607
+
608
+ /**
609
+ * Best-effort removal of a just-created worktree + its branch. Used when a
610
+ * post-creation step (setup hook, node_modules link, seed overlay) fails, so
611
+ * the run does not leak an orphaned worktree dir + branch that would block the
612
+ * next run with an "already checked out" error.
613
+ */
614
+ function cleanupCreatedWorktree(repoRoot: string, worktreePath: string, branch: string): void {
615
+ try {
616
+ git(repoRoot, ["worktree", "remove", "--force", worktreePath]);
617
+ } catch {
618
+ try {
619
+ if (fs.existsSync(worktreePath)) fs.rmSync(worktreePath, { recursive: true, force: true });
620
+ } catch {
621
+ /* best-effort */
622
+ }
623
+ }
624
+ try {
625
+ git(repoRoot, ["branch", "-D", branch]);
626
+ } catch {
627
+ /* best-effort */
628
+ }
629
+ }
630
+
631
+ async function cleanupCreatedWorktreeAsync(repoRoot: string, worktreePath: string, branch: string): Promise<void> {
632
+ try {
633
+ await gitAsync(repoRoot, ["worktree", "remove", "--force", worktreePath]);
634
+ } catch {
635
+ try {
636
+ if (fs.existsSync(worktreePath)) fs.rmSync(worktreePath, { recursive: true, force: true });
637
+ } catch {
638
+ /* best-effort */
639
+ }
640
+ }
641
+ try {
642
+ await gitAsync(repoRoot, ["branch", "-D", branch]);
643
+ } catch {
644
+ /* best-effort */
645
+ }
646
+ }
647
+
554
648
  export function prepareTaskWorkspace(manifest: TeamRunManifest, task: TeamTaskState, stepSeedPaths?: string[]): PreparedTaskWorkspace {
555
649
  if (manifest.workspaceMode !== "worktree") return { cwd: task.cwd };
556
650
  const repoRoot = findGitRoot(manifest.cwd);
@@ -626,10 +720,12 @@ export function prepareTaskWorkspace(manifest: TeamRunManifest, task: TeamTaskSt
626
720
  // Check for uncommitted changes from previous run before reusing
627
721
  const dirtyStatus = git(worktreePath, ["status", "--porcelain"]);
628
722
  if (dirtyStatus.trim()) {
629
- // Discard uncommitted changes to ensure clean slate for new task
723
+ // Snapshot uncommitted work to a recovery artifact BEFORE discarding, so the
724
+ // previous run's changes are never silently destroyed on reuse.
725
+ snapshotDirtyWorktree(manifest, task, worktreePath, dirtyStatus);
630
726
  logInternalError(
631
727
  "worktree.reused.dirty",
632
- new Error(`Discarding uncommitted changes in reused worktree at ${worktreePath}`),
728
+ new Error(`Discarding uncommitted changes in reused worktree at ${worktreePath} (snapshot saved to artifacts)`),
633
729
  `runId=${manifest.runId}, taskId=${task.id}, dirtyStatus=${dirtyStatus.trim()}`,
634
730
  );
635
731
  git(worktreePath, ["checkout", "--", "."]);
@@ -679,23 +775,30 @@ export function prepareTaskWorkspace(manifest: TeamRunManifest, task: TeamTaskSt
679
775
  }
680
776
  throw error;
681
777
  }
682
- const syntheticPaths = runSetupHook(manifest, task, repoRoot, worktreePath, branch);
683
- const nodeModulesLinked =
684
- loadedConfig.config.worktree?.linkNodeModules === true ? linkNodeModulesIfPresent(repoRoot, worktreePath) : false;
685
- // Overlay seed paths from config + step-level seedPaths
686
- const globalSeedPaths = loadedConfig.config.worktree?.seedPaths ?? [];
687
- const merged = normalizeSeedPaths([...globalSeedPaths, ...(stepSeedPaths ?? [])], repoRoot);
688
- if (merged.length > 0) {
689
- overlaySeedPaths(repoRoot, worktreePath, merged);
778
+ try {
779
+ const syntheticPaths = runSetupHook(manifest, task, repoRoot, worktreePath, branch);
780
+ const nodeModulesLinked =
781
+ loadedConfig.config.worktree?.linkNodeModules === true ? linkNodeModulesIfPresent(repoRoot, worktreePath) : false;
782
+ // Overlay seed paths from config + step-level seedPaths
783
+ const globalSeedPaths = loadedConfig.config.worktree?.seedPaths ?? [];
784
+ const merged = normalizeSeedPaths([...globalSeedPaths, ...(stepSeedPaths ?? [])], repoRoot);
785
+ if (merged.length > 0) {
786
+ overlaySeedPaths(repoRoot, worktreePath, merged);
787
+ }
788
+ return {
789
+ cwd: worktreePath,
790
+ worktreePath,
791
+ branch,
792
+ reused: false,
793
+ nodeModulesLinked,
794
+ syntheticPaths,
795
+ };
796
+ } catch (setupError) {
797
+ // Hook/link/seed failure AFTER worktree+branch creation: clean up to avoid
798
+ // an orphaned worktree dir + branch that would block the next run.
799
+ cleanupCreatedWorktree(repoRoot, worktreePath, branch);
800
+ throw setupError;
690
801
  }
691
- return {
692
- cwd: worktreePath,
693
- worktreePath,
694
- branch,
695
- reused: false,
696
- nodeModulesLinked,
697
- syntheticPaths,
698
- };
699
802
  }
700
803
 
701
804
  /** Async version of prepareTaskWorkspace — yields the event loop during git operations. */
@@ -766,9 +869,10 @@ export async function prepareTaskWorkspaceAsync(
766
869
  }
767
870
  const dirtyStatus = await gitAsync(worktreePath, ["status", "--porcelain"]);
768
871
  if (dirtyStatus.trim()) {
872
+ snapshotDirtyWorktree(manifest, task, worktreePath, dirtyStatus);
769
873
  logInternalError(
770
874
  "worktree.reused.dirty",
771
- new Error(`Discarding uncommitted changes in reused worktree at ${worktreePath}`),
875
+ new Error(`Discarding uncommitted changes in reused worktree at ${worktreePath} (snapshot saved to artifacts)`),
772
876
  `runId=${manifest.runId}, taskId=${task.id}, dirtyStatus=${dirtyStatus.trim()}`,
773
877
  );
774
878
  await gitAsync(worktreePath, ["checkout", "--", "."]);
@@ -818,22 +922,29 @@ export async function prepareTaskWorkspaceAsync(
818
922
  }
819
923
  throw error;
820
924
  }
821
- const syntheticPaths = runSetupHook(manifest, task, repoRoot, worktreePath, branch);
822
- const nodeModulesLinked =
823
- loadedConfig.config.worktree?.linkNodeModules === true ? linkNodeModulesIfPresent(repoRoot, worktreePath) : false;
824
- const globalSeedPaths = loadedConfig.config.worktree?.seedPaths ?? [];
825
- const merged = normalizeSeedPaths([...globalSeedPaths, ...(stepSeedPaths ?? [])], repoRoot);
826
- if (merged.length > 0) {
827
- overlaySeedPaths(repoRoot, worktreePath, merged);
925
+ try {
926
+ const syntheticPaths = runSetupHook(manifest, task, repoRoot, worktreePath, branch);
927
+ const nodeModulesLinked =
928
+ loadedConfig.config.worktree?.linkNodeModules === true ? linkNodeModulesIfPresent(repoRoot, worktreePath) : false;
929
+ const globalSeedPaths = loadedConfig.config.worktree?.seedPaths ?? [];
930
+ const merged = normalizeSeedPaths([...globalSeedPaths, ...(stepSeedPaths ?? [])], repoRoot);
931
+ if (merged.length > 0) {
932
+ overlaySeedPaths(repoRoot, worktreePath, merged);
933
+ }
934
+ return {
935
+ cwd: worktreePath,
936
+ worktreePath,
937
+ branch,
938
+ reused: false,
939
+ nodeModulesLinked,
940
+ syntheticPaths,
941
+ };
942
+ } catch (setupError) {
943
+ // Hook/link/seed failure AFTER worktree+branch creation: clean up to avoid
944
+ // an orphaned worktree dir + branch that would block the next run.
945
+ await cleanupCreatedWorktreeAsync(repoRoot, worktreePath, branch);
946
+ throw setupError;
828
947
  }
829
- return {
830
- cwd: worktreePath,
831
- worktreePath,
832
- branch,
833
- reused: false,
834
- nodeModulesLinked,
835
- syntheticPaths,
836
- };
837
948
  }
838
949
 
839
950
  export function captureWorktreeDiffStat(worktreePath: string): WorktreeDiffStat {