pi-crew 0.9.27 → 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.
Files changed (35) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/build-meta.json +164 -115
  3. package/dist/index.mjs +986 -555
  4. package/dist/index.mjs.map +4 -4
  5. package/docs/REVIEW-FINDINGS-2026-07-CORE.md +139 -0
  6. package/docs/REVIEW-FINDINGS-2026-07.md +125 -0
  7. package/docs/bugs/bug-quota-display-truncation.md +223 -0
  8. package/docs/stories/README.md +3 -1
  9. package/docs/stories/US-DEPS-major-upgrade.md +62 -0
  10. package/package.json +4 -4
  11. package/src/agents/agent-config.ts +2 -0
  12. package/src/agents/discover-agents.ts +4 -0
  13. package/src/config/config.ts +4 -0
  14. package/src/extension/crew-vibes/config.ts +1 -1
  15. package/src/extension/crew-vibes/figures.ts +1 -1
  16. package/src/extension/crew-vibes/footer.ts +292 -0
  17. package/src/extension/crew-vibes/index.ts +74 -70
  18. package/src/extension/crew-vibes/provider-usage.ts +119 -53
  19. package/src/extension/team-tool.ts +11 -0
  20. package/src/prompt/prompt-runtime.ts +65 -0
  21. package/src/runtime/child-pi.ts +58 -43
  22. package/src/runtime/cross-extension-rpc.ts +1 -1
  23. package/src/runtime/pi-args.ts +2 -0
  24. package/src/runtime/post-exit-stdio-guard.ts +22 -4
  25. package/src/runtime/stale-reconciler.ts +9 -4
  26. package/src/runtime/task-runner.ts +1 -0
  27. package/src/runtime/team-runner.ts +11 -16
  28. package/src/state/atomic-write.ts +46 -19
  29. package/src/state/event-log.ts +77 -24
  30. package/src/state/mailbox.ts +6 -3
  31. package/src/ui/pi-ui-compat.ts +11 -0
  32. package/src/utils/conflict-detect.ts +9 -3
  33. package/src/utils/paths.ts +7 -1
  34. package/src/worktree/cleanup.ts +19 -0
  35. package/src/worktree/worktree-manager.ts +145 -34
@@ -260,32 +260,43 @@ function renameWithLinkSync(tempPath: string, filePath: string, retries = 8): vo
260
260
  let lastError: unknown;
261
261
  for (let attempt = 0; attempt <= retries; attempt++) {
262
262
  try {
263
- // Windows: hard links fail with ENOENT when short-name and long-name
264
- // path aliases are mixed (e.g. RUNNER~1 vs runneradmin). Use
265
- // renameSync which handles this correctly via MoveFileEx.
266
263
  if (process.platform === "win32") {
267
- try {
268
- fs.unlinkSync(filePath);
269
- } catch {
270
- /* destination may not exist */
271
- }
264
+ // B9: MoveFileEx (Node's renameSync on Windows) replaces the destination atomically
265
+ // with MOVEFILE_REPLACE_EXISTING, so no prior unlink is needed. Unlinking first
266
+ // opened a window where concurrent readers saw ENOENT between the unlink and
267
+ // the rename. Try rename directly; only fall back to unlink-then-rename for
268
+ // edge cases (read-only dest / type mismatch / short-long-name alias).
272
269
  try {
273
270
  fs.renameSync(tempPath, filePath);
274
271
  return;
275
272
  } catch (renameError) {
276
273
  lastError = renameError;
277
- if (!isRetryableLinkError(renameError) || attempt === retries) break;
274
+ if (isRetryableLinkError(renameError) && attempt !== retries) {
275
+ // retryable — loop again after sleep
276
+ } else {
277
+ try {
278
+ fs.unlinkSync(filePath);
279
+ } catch {
280
+ /* destination may not exist */
281
+ }
282
+ try {
283
+ fs.renameSync(tempPath, filePath);
284
+ return;
285
+ } catch (renameError2) {
286
+ lastError = renameError2;
287
+ if (!isRetryableLinkError(renameError2) || attempt === retries) break;
288
+ }
289
+ }
278
290
  }
279
291
  } else {
280
- // First try to unlink any existing file at destination (hard links don't follow symlinks)
281
292
  try {
282
293
  fs.unlinkSync(filePath);
283
294
  } catch {
284
295
  /* destination may not exist */
285
296
  }
286
- // Create hard link - does NOT follow symlinks at filePath
297
+ // Create hard link does NOT follow symlinks at filePath
287
298
  fs.linkSync(tempPath, filePath);
288
- // Successfully linked - now unlink the temp file
299
+ // Successfully linked now unlink the temp file
289
300
  fs.unlinkSync(tempPath);
290
301
  return;
291
302
  }
@@ -305,17 +316,32 @@ async function renameWithLinkAsync(tempPath: string, filePath: string, retries =
305
316
  for (let attempt = 0; attempt <= retries; attempt++) {
306
317
  try {
307
318
  if (process.platform === "win32") {
308
- try {
309
- await fs.promises.unlink(filePath);
310
- } catch {
311
- /* destination may not exist */
312
- }
319
+ // B9: MoveFileEx (Node's rename on Windows) replaces the destination atomically
320
+ // with MOVEFILE_REPLACE_EXISTING, so no prior unlink is needed. Unlinking first
321
+ // opened a window where concurrent readers saw ENOENT between the unlink and
322
+ // the rename. Try rename directly; only fall back to unlink-then-rename for
323
+ // edge cases (read-only dest / type mismatch / short-long-name alias).
313
324
  try {
314
325
  await fs.promises.rename(tempPath, filePath);
315
326
  return;
316
327
  } catch (renameError) {
317
328
  lastError = renameError;
318
- if (!isRetryableLinkError(renameError) || attempt === retries) break;
329
+ if (isRetryableLinkError(renameError) && attempt !== retries) {
330
+ // retryable — loop again after sleep
331
+ } else {
332
+ try {
333
+ await fs.promises.unlink(filePath);
334
+ } catch {
335
+ /* destination may not exist */
336
+ }
337
+ try {
338
+ await fs.promises.rename(tempPath, filePath);
339
+ return;
340
+ } catch (renameError2) {
341
+ lastError = renameError2;
342
+ if (!isRetryableLinkError(renameError2) || attempt === retries) break;
343
+ }
344
+ }
319
345
  }
320
346
  } else {
321
347
  try {
@@ -323,7 +349,9 @@ async function renameWithLinkAsync(tempPath: string, filePath: string, retries =
323
349
  } catch {
324
350
  /* destination may not exist */
325
351
  }
352
+ // Create hard link — does NOT follow symlinks at filePath
326
353
  await fs.promises.link(tempPath, filePath);
354
+ // Successfully linked — now unlink the temp file
327
355
  await fs.promises.unlink(tempPath);
328
356
  return;
329
357
  }
@@ -337,7 +365,6 @@ async function renameWithLinkAsync(tempPath: string, filePath: string, retries =
337
365
  }
338
366
  throw lastError;
339
367
  }
340
-
341
368
  export function renameWithRetry(
342
369
  tempPath: string,
343
370
  filePath: string,
@@ -304,6 +304,37 @@ function persistSequence(eventsPath: string, seq: number): void {
304
304
  }
305
305
  }
306
306
 
307
+ // B7: single in-process monotonic sequence counter per eventsPath. The three
308
+ // append paths — sync appendEvent (withEventLogLockSync file lock), buffered
309
+ // flush (asyncLocks promise chain), and direct appendEventAsync (asyncQueues
310
+ // promise chain) — use DIFFERENT locks, so the old read-sidecar / compute /
311
+ // persist-sidecar sequence logic in nextSequence() raced ACROSS paths and
312
+ // produced duplicate sequence numbers (observed live: distinct events sharing
313
+ // a seq; no data loss — only the counter collided). A single in-process counter
314
+ // makes assignment atomic (JS is single-threaded); persistSequence() keeps the
315
+ // sidecar durable for crash recovery across restarts.
316
+ const seqCounters = new Map<string, number>();
317
+
318
+ /** Atomically reserve the next sequence number for `eventsPath`. */
319
+ function reserveSequence(eventsPath: string): number {
320
+ let last = seqCounters.get(eventsPath);
321
+ if (last === undefined) {
322
+ // Seed once from the authoritative source (sidecar / cache / file scan).
323
+ // nextSequence() returns the NEXT seq to assign, so the last assigned is one less.
324
+ last = nextSequence(eventsPath) - 1;
325
+ }
326
+ const next = last + 1;
327
+ seqCounters.set(eventsPath, next);
328
+ return next;
329
+ }
330
+
331
+ /** Keep the in-process counter monotonic w.r.t. an explicitly-provided seq
332
+ * (e.g. baseMetadata.seq) so a later auto-assigned seq never collides with it. */
333
+ function advanceSequenceCounter(eventsPath: string, seq: number): void {
334
+ const last = seqCounters.get(eventsPath);
335
+ if (last === undefined || seq > last) seqCounters.set(eventsPath, seq);
336
+ }
337
+
307
338
  export function computeEventFingerprint(event: Pick<TeamEvent, "type" | "runId" | "taskId" | "data">): string {
308
339
  return createHash("sha256")
309
340
  .update(
@@ -390,13 +421,22 @@ async function withEventLogLockAsync(eventsPath: string, fn: () => Promise<void>
390
421
  try {
391
422
  await next;
392
423
  } finally {
393
- asyncLocks.delete(queueKey);
424
+ // Compare-and-delete: only remove our entry if it still points at our
425
+ // promise. With 3+ overlapping callers, an earlier caller's finally would
426
+ // otherwise delete a later caller's promise, letting the next caller start
427
+ // immediately (in parallel) -> broken mutual exclusion, duplicate seqs.
428
+ if (asyncLocks.get(queueKey) === next) {
429
+ asyncLocks.delete(queueKey);
430
+ }
394
431
  }
395
432
  }
396
433
 
397
434
  /** Reset event log mode (for testing only). */
398
435
  export function resetEventLogMode(): void {
399
436
  asyncQueues.clear();
437
+ // B7: clear in-process sequence counters alongside async state so tests
438
+ // don't leak seq state between runs.
439
+ seqCounters.clear();
400
440
  }
401
441
 
402
442
  /**
@@ -440,8 +480,9 @@ export async function appendEventAsync(eventsPath: string, event: AppendTeamEven
440
480
  let seq: number;
441
481
  if (baseMetadata?.seq !== undefined) {
442
482
  seq = baseMetadata.seq;
483
+ advanceSequenceCounter(eventsPath, seq);
443
484
  } else {
444
- seq = nextSequence(eventsPath);
485
+ seq = reserveSequence(eventsPath);
445
486
  // NOTE: We do NOT call persistSequence here. It will be called AFTER
446
487
  // successful appendFile below to ensure sidecar is only updated when
447
488
  // the event is actually written.
@@ -588,27 +629,31 @@ export async function appendEventAsync(eventsPath: string, event: AppendTeamEven
588
629
  }
589
630
  return fullEvent;
590
631
  });
591
- asyncQueues.set(
592
- queueKey,
593
- next.then(
594
- () => {
632
+ const tail = next.then(
633
+ () => {
634
+ // Compare-and-delete: only remove our entry if it still points at our
635
+ // tail promise. An older caller deleting unconditionally would wipe a
636
+ // newer caller's promise, letting the next caller bypass serialization
637
+ // -> duplicate seqs / interleaved appends.
638
+ if (asyncQueues.get(queueKey) === tail) {
595
639
  asyncQueues.delete(queueKey);
596
- },
597
- (error) => {
598
- // FIX: Wrap error handler in try-catch to ensure asyncQueues.delete
599
- // always runs, even if logging itself throws.
600
- try {
601
- logInternalError("event-log.async-queue", error, eventsPath);
602
- } catch {
603
- // logging failed — ensure queue is still cleaned up
604
- }
605
- // FIX: Reset queue to a resolved state instead of deleting it.
606
- // This prevents cascading failures where a single transient error
607
- // (e.g., ENOSPC) causes all subsequent events on the same path to fail.
608
- asyncQueues.set(queueKey, Promise.resolve());
609
- },
610
- ),
640
+ }
641
+ },
642
+ (error) => {
643
+ // FIX: Wrap error handler in try-catch to ensure asyncQueues.delete
644
+ // always runs, even if logging itself throws.
645
+ try {
646
+ logInternalError("event-log.async-queue", error, eventsPath);
647
+ } catch {
648
+ // logging failed — ensure queue is still cleaned up
649
+ }
650
+ // FIX: Reset queue to a resolved state instead of deleting it.
651
+ // This prevents cascading failures where a single transient error
652
+ // (e.g., ENOSPC) causes all subsequent events on the same path to fail.
653
+ asyncQueues.set(queueKey, Promise.resolve());
654
+ },
611
655
  );
656
+ asyncQueues.set(queueKey, tail);
612
657
  return next;
613
658
  }
614
659
 
@@ -658,7 +703,8 @@ async function appendEventBatchInsideLock(eventsPath: string, queue: BufferedApp
658
703
  // in between — every call would see the same file state and return the same
659
704
  // seq, breaking the "unique monotonic seq" contract. The cache update +
660
705
  // persistSequence at the end refreshes the sidecar to the last assigned seq.
661
- const startingSeq = queue[0]?.event.metadata?.seq ?? nextSequence(eventsPath);
706
+ // B7: use reserveSequence for atomic seq assignment across all paths.
707
+ const startingSeq = queue[0]?.event.metadata?.seq ?? reserveSequence(eventsPath);
662
708
  let nextSeq = startingSeq;
663
709
  const finalized: { item: BufferedAppend; line: string; fullEvent: TeamEvent }[] = [];
664
710
  let lastSeq = 0;
@@ -693,6 +739,8 @@ async function appendEventBatchInsideLock(eventsPath: string, queue: BufferedApp
693
739
  finalized.push({ item, line: `${JSON.stringify(redactSecrets(fullEvent))}\n`, fullEvent });
694
740
  lastSeq = seq;
695
741
  }
742
+ // B7: advance counter past the entire batch so next reserveSequence returns the correct value.
743
+ advanceSequenceCounter(eventsPath, lastSeq);
696
744
 
697
745
  // Phase 2: single appendFileSync + single fsync + single persistSequence.
698
746
  // Before this fix, each event in the batch triggered its own fsync, which
@@ -745,8 +793,12 @@ async function appendEventBatchInsideLock(eventsPath: string, queue: BufferedApp
745
793
  function appendEventInsideLock(eventsPath: string, event: AppendTeamEvent): TeamEvent {
746
794
  fs.mkdirSync(path.dirname(eventsPath), { recursive: true });
747
795
  const baseMetadata = event.metadata;
796
+ // B7: use reserveSequence for atomic seq assignment across all paths.
797
+ const explicitSeq = baseMetadata?.seq;
798
+ const seq = explicitSeq ?? reserveSequence(eventsPath);
799
+ if (explicitSeq !== undefined) advanceSequenceCounter(eventsPath, seq);
748
800
  let metadata: TeamEventMetadata = {
749
- seq: baseMetadata?.seq ?? nextSequence(eventsPath),
801
+ seq,
750
802
  provenance: baseMetadata?.provenance ?? "team_runner",
751
803
  ...(baseMetadata?.parentEventId ? { parentEventId: baseMetadata.parentEventId } : {}),
752
804
  ...(baseMetadata?.attemptId ? { attemptId: baseMetadata.attemptId } : {}),
@@ -813,7 +865,8 @@ function appendEventInsideLock(eventsPath: string, event: AppendTeamEvent): Team
813
865
  } catch (error) {
814
866
  logInternalError("event-log.size-check", error, `eventsPath=${eventsPath}`);
815
867
  }
816
- const seq = fullEvent.metadata?.seq ?? 0;
868
+ // seq is already computed above via reserveSequence — reuse it for persist/cache.
869
+ // const seq declaration removed (B7: seq is now computed before metadata object).
817
870
  if (!skippedDueToSize) {
818
871
  fs.appendFileSync(eventsPath, `${JSON.stringify(redactSecrets(fullEvent))}\n`, "utf-8");
819
872
  // F3a: skip data fsync for non-terminal events. We still call `persistSequence`
@@ -463,16 +463,19 @@ export function appendMailboxMessage(
463
463
  replyContent: message.replyContent,
464
464
  };
465
465
  // H2 fix: wrap append in cross-process lock to prevent interleaving on Windows.
466
+ // B8: rotation (rename + recreate) also runs INSIDE the lock so it is serialized
467
+ // with appends — otherwise a concurrent append between rename and recreate can
468
+ // be truncated to an empty file.
466
469
  withEventLogLockSync(mailboxFile(manifest, complete.direction, complete.taskId), () => {
467
470
  fs.appendFileSync(
468
471
  mailboxFile(manifest, complete.direction, complete.taskId),
469
472
  `${JSON.stringify(redactSecrets(complete))}\n`,
470
473
  "utf-8",
471
474
  );
475
+ // 3.3 — rotate mailbox file if it has grown past 10 MB. Cheap stat check;
476
+ // rotates at most once per append.
477
+ rotateMailboxFileIfNeeded(mailboxFile(manifest, complete.direction, complete.taskId));
472
478
  });
473
- // 3.3 — rotate mailbox file if it has grown past 10 MB. Cheap stat
474
- // check; rotates at most once per append.
475
- rotateMailboxFileIfNeeded(mailboxFile(manifest, complete.direction, complete.taskId));
476
479
  // BUGFIX (Round 12 C3): the delivery.json read-modify-write below was
477
480
  // UNLOCKED, so concurrent appendMailboxMessage calls could interleave and
478
481
  // clobber each other's delivery entries (lost-update race). FIX: wrap the
@@ -46,6 +46,17 @@ export function setExtensionWidget(
46
46
  ctx.ui.setWidget(key, content as never, widgetOptions as WidgetOptions);
47
47
  }
48
48
 
49
+ type FooterFactory = (tui: unknown, theme: unknown, footerData: unknown) => unknown;
50
+
51
+ /** Install a custom footer component, or pass `undefined` to restore pi's built-in footer.
52
+ * No-op when the host UI predates the `setFooter` API. */
53
+ export function setFooter(ctx: UiContext | undefined, factory: FooterFactory | undefined): void {
54
+ if (!ctx) return;
55
+ const record = maybeRecord(ctx.ui);
56
+ const fn = record?.setFooter;
57
+ if (typeof fn === "function") fn.call(ctx.ui, factory as never);
58
+ }
59
+
49
60
  export function showCustom<T>(ctx: UiContext, factory: CustomFactory<T>, options?: CustomOptions): Promise<T> {
50
61
  const custom = ctx.ui.custom as unknown as GenericCustom;
51
62
  return custom<T>(factory, options);
@@ -68,7 +68,11 @@ export function scanConflictLines(lines: readonly string[], firstLineNumber: num
68
68
  } | null = null;
69
69
 
70
70
  for (let i = 0; i < lines.length; i++) {
71
- const line = lines[i];
71
+ // Strip a trailing CR so CRLF files (Windows) match markers/separators.
72
+ // The raw line (with CR) is never needed here; content is collected from
73
+ // the normalized line so storage stays consistent across LF/CRLF inputs.
74
+ const rawLine = lines[i];
75
+ const line = rawLine.charCodeAt(rawLine.length - 1) === 13 /* \r */ ? rawLine.slice(0, -1) : rawLine;
72
76
  const ln = firstLineNumber + i;
73
77
 
74
78
  const oursLabel = matchMarker(line, OURS_PREFIX);
@@ -339,7 +343,9 @@ export function parseConflictUri(raw: string): ParsedConflictUri | null {
339
343
  * in the file that shift line numbers don't break resolution.
340
344
  */
341
345
  export function spliceConflict(originalText: string, entry: ConflictEntry, replacement: string): string {
342
- const lines = originalText.split("\n");
346
+ // Preserve the original line ending (CRLF or LF) for a byte-accurate round-trip.
347
+ const eol = originalText.includes("\r\n") ? "\r\n" : "\n";
348
+ const lines = originalText.split(/\r?\n/);
343
349
  const expected = buildRecordedRegion(entry);
344
350
  const match = locateRegion(lines, expected, entry.startLine - 1);
345
351
  if (!match) {
@@ -351,7 +357,7 @@ export function spliceConflict(originalText: string, entry: ConflictEntry, repla
351
357
  const trimmed = normalizeTrailingNewline(replacement);
352
358
  const replacementLines = trimmed.split("\n");
353
359
  const next = [...lines.slice(0, match.startIdx), ...replacementLines, ...lines.slice(match.endIdx + 1)];
354
- return next.join("\n");
360
+ return next.join(eol);
355
361
  }
356
362
 
357
363
  /** Reconstruct the recorded marker block as it should appear in the file. */
@@ -39,7 +39,13 @@ export function packageRoot(): string {
39
39
  }
40
40
 
41
41
  export function userPiRoot(): string {
42
- const home = process.env.PI_TEAMS_HOME?.trim() || os.homedir();
42
+ // F4: a misconfigured PI_TEAMS_HOME can be the literal string "undefined"
43
+ // (e.g. PI_TEAMS_HOME=$UNSET_VAR in a shell), which would build paths like
44
+ // "undefined/.pi/agent" relative to cwd and silently create a junk "undefined/"
45
+ // tree. Treat the literal "undefined" (and empty) as unset and fall back to
46
+ // os.homedir().
47
+ const rawHome = process.env.PI_TEAMS_HOME?.trim();
48
+ const home = rawHome && rawHome !== "undefined" ? rawHome : os.homedir();
43
49
  const resolved = path.join(home, ".pi", "agent");
44
50
 
45
51
  // Reject symlinks to prevent confusion attacks where PI_TEAMS_HOME points to
@@ -131,6 +131,25 @@ export function cleanupRunWorktrees(
131
131
  const branchName = `pi-crew/${manifest.runId}/${sanitizeBranchPart(entry.name)}`;
132
132
  const safeBranchName = sanitizeBranchPart(entry.name);
133
133
  if (dirty) {
134
+ // C9: preserve dirty worktrees unless explicitly forced. Previously the
135
+ // dirty branch ALWAYS ran 'git add -A' + commit + remove, staging every
136
+ // untracked file (incl. potential secrets/build artifacts) into a
137
+ // recovery branch without consent. force=true keeps the old behavior.
138
+ if (!options.force) {
139
+ const safePreserveName = sanitizeFilename(entry.name);
140
+ const artifact = writeArtifact(manifest.artifactsRoot, {
141
+ kind: "diff",
142
+ relativePath: `cleanup/${safePreserveName}.diff`,
143
+ content: captureDiff(worktreePath),
144
+ producer: "worktree-cleanup",
145
+ });
146
+ result.artifactPaths.push(artifact.path);
147
+ result.preserved.push({
148
+ path: worktreePath,
149
+ reason: "dirty worktree preserved \u2014 pass force=true to auto-commit and remove",
150
+ });
151
+ continue;
152
+ }
134
153
  // Issue 1 fix: check signal before git operations
135
154
  if (options.signal?.aborted) break;
136
155
  // Commit changes to a branch instead of just preserving the worktree
@@ -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 {