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.
- package/CHANGELOG.md +15 -0
- package/dist/build-meta.json +164 -115
- package/dist/index.mjs +986 -555
- package/dist/index.mjs.map +4 -4
- package/docs/REVIEW-FINDINGS-2026-07-CORE.md +139 -0
- package/docs/REVIEW-FINDINGS-2026-07.md +125 -0
- package/docs/bugs/bug-quota-display-truncation.md +223 -0
- package/docs/stories/README.md +3 -1
- package/docs/stories/US-DEPS-major-upgrade.md +62 -0
- package/package.json +4 -4
- package/src/agents/agent-config.ts +2 -0
- package/src/agents/discover-agents.ts +4 -0
- package/src/config/config.ts +4 -0
- package/src/extension/crew-vibes/config.ts +1 -1
- package/src/extension/crew-vibes/figures.ts +1 -1
- package/src/extension/crew-vibes/footer.ts +292 -0
- package/src/extension/crew-vibes/index.ts +74 -70
- package/src/extension/crew-vibes/provider-usage.ts +119 -53
- package/src/extension/team-tool.ts +11 -0
- package/src/prompt/prompt-runtime.ts +65 -0
- package/src/runtime/child-pi.ts +58 -43
- package/src/runtime/cross-extension-rpc.ts +1 -1
- package/src/runtime/pi-args.ts +2 -0
- package/src/runtime/post-exit-stdio-guard.ts +22 -4
- package/src/runtime/stale-reconciler.ts +9 -4
- package/src/runtime/task-runner.ts +1 -0
- package/src/runtime/team-runner.ts +11 -16
- package/src/state/atomic-write.ts +46 -19
- package/src/state/event-log.ts +77 -24
- package/src/state/mailbox.ts +6 -3
- package/src/ui/pi-ui-compat.ts +11 -0
- package/src/utils/conflict-detect.ts +9 -3
- package/src/utils/paths.ts +7 -1
- package/src/worktree/cleanup.ts +19 -0
- 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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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 (
|
|
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
|
|
297
|
+
// Create hard link — does NOT follow symlinks at filePath
|
|
287
298
|
fs.linkSync(tempPath, filePath);
|
|
288
|
-
// Successfully linked
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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 (
|
|
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,
|
package/src/state/event-log.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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`
|
package/src/state/mailbox.ts
CHANGED
|
@@ -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
|
package/src/ui/pi-ui-compat.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
360
|
+
return next.join(eol);
|
|
355
361
|
}
|
|
356
362
|
|
|
357
363
|
/** Reconstruct the recorded marker block as it should appear in the file. */
|
package/src/utils/paths.ts
CHANGED
|
@@ -39,7 +39,13 @@ export function packageRoot(): string {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
export function userPiRoot(): string {
|
|
42
|
-
|
|
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
|
package/src/worktree/cleanup.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
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 {
|