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.
- package/dist/build-meta.json +158 -91
- package/dist/index.mjs +893 -507
- 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/config/config.ts +4 -0
- package/src/extension/crew-vibes/config.ts +1 -1
- package/src/extension/crew-vibes/footer.ts +292 -0
- package/src/extension/crew-vibes/index.ts +67 -52
- package/src/prompt/prompt-runtime.ts +1 -1
- package/src/runtime/child-pi.ts +52 -43
- package/src/runtime/cross-extension-rpc.ts +1 -1
- package/src/runtime/post-exit-stdio-guard.ts +22 -4
- package/src/runtime/stale-reconciler.ts +9 -4
- 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
|
@@ -9,6 +9,9 @@ export interface ChildWithPipedStdio {
|
|
|
9
9
|
stdout: ChildProcess["stdout"];
|
|
10
10
|
stderr: ChildProcess["stderr"];
|
|
11
11
|
on: ChildProcess["on"];
|
|
12
|
+
/** Set by Node after the child exits. Used to detect a post-exit attach. */
|
|
13
|
+
exitCode?: number | null;
|
|
14
|
+
signalCode?: NodeJS.Signals | null;
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
export interface ChildWithKill {
|
|
@@ -72,13 +75,28 @@ export function attachPostExitStdioGuard(child: ChildWithPipedStdio, options: Po
|
|
|
72
75
|
stderrEnded = true;
|
|
73
76
|
if (stdoutEnded && stderrEnded) clearTimers();
|
|
74
77
|
});
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
armIdleTimer();
|
|
78
|
+
|
|
79
|
+
const armHardTimer = (): void => {
|
|
78
80
|
if (hardTimer) return;
|
|
79
81
|
hardTimer = setTimeout(destroyUnendedStdio, hardMs);
|
|
80
82
|
hardTimer.unref();
|
|
81
|
-
}
|
|
83
|
+
};
|
|
84
|
+
const onExit = (): void => {
|
|
85
|
+
exited = true;
|
|
86
|
+
armIdleTimer();
|
|
87
|
+
armHardTimer();
|
|
88
|
+
};
|
|
89
|
+
// The guard is normally attached from INSIDE the child's 'exit' handler
|
|
90
|
+
// (child-pi.ts), i.e. AFTER the child has already exited. A listener
|
|
91
|
+
// registered with child.on('exit') during the in-flight 'exit' dispatch
|
|
92
|
+
// will NOT fire (Node clones the listener array before iterating), so
|
|
93
|
+
// relying on it alone makes this guard a no-op and can hang the run if a
|
|
94
|
+
// descendant process holds the stdio pipes open. When a prior exit is
|
|
95
|
+
// detectable (exitCode/signalCode already set by Node), arm immediately.
|
|
96
|
+
if (child.exitCode != null || child.signalCode != null) {
|
|
97
|
+
onExit();
|
|
98
|
+
}
|
|
99
|
+
child.on("exit", onExit);
|
|
82
100
|
child.on("close", clearTimers);
|
|
83
101
|
child.on("error", clearTimers);
|
|
84
102
|
|
|
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { errors } from "../errors.ts";
|
|
5
|
+
import { atomicWriteJson } from "../state/atomic-write.ts";
|
|
5
6
|
import { saveRunManifest } from "../state/state-store.ts";
|
|
6
7
|
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
7
8
|
import { recordFromTask, upsertCrewAgent } from "./crew-agent-records.ts";
|
|
@@ -67,8 +68,12 @@ function checkResultFile(manifest: TeamRunManifest, tasks: TeamTaskState[]): { f
|
|
|
67
68
|
);
|
|
68
69
|
if (allTerminal) {
|
|
69
70
|
// All tasks are terminal but manifest status was not updated — repair it.
|
|
71
|
+
// Derive the run status from task outcomes instead of blindly marking
|
|
72
|
+
// "completed": a run where every task failed/cancelled is NOT a success.
|
|
73
|
+
const hasFailed = tasks.some((t) => t.status === "failed");
|
|
74
|
+
const onlyCancelledOrSkipped = tasks.every((t) => t.status === "cancelled" || t.status === "skipped");
|
|
75
|
+
manifest.status = hasFailed ? "failed" : onlyCancelledOrSkipped ? "cancelled" : "completed";
|
|
70
76
|
// Persist manifest status change immediately to make checkResultFile self-contained.
|
|
71
|
-
manifest.status = "completed";
|
|
72
77
|
saveRunManifest(manifest);
|
|
73
78
|
// Sync agent records even when tasks are already terminal
|
|
74
79
|
// (e.g., a previous reconcile fixed tasks but crashed before updating agents)
|
|
@@ -492,8 +497,8 @@ export function reconcileOrphanedTempWorkspaces(
|
|
|
492
497
|
const tasks: TeamTaskState[] = JSON.parse(fs.readFileSync(tasksPath, "utf-8"));
|
|
493
498
|
const result = reconcileStaleRun(manifest, tasks, now);
|
|
494
499
|
if (result.repaired && result.repairedTasks) {
|
|
495
|
-
// Persist repaired tasks
|
|
496
|
-
|
|
500
|
+
// Persist repaired tasks (atomic — temp+rename to survive mid-write crash)
|
|
501
|
+
atomicWriteJson(tasksPath, result.repairedTasks);
|
|
497
502
|
// Update manifest status
|
|
498
503
|
const updated = {
|
|
499
504
|
...manifest,
|
|
@@ -501,7 +506,7 @@ export function reconcileOrphanedTempWorkspaces(
|
|
|
501
506
|
updatedAt: new Date(now).toISOString(),
|
|
502
507
|
summary: `Stale run reconciled: ${result.detail}`,
|
|
503
508
|
};
|
|
504
|
-
|
|
509
|
+
atomicWriteJson(manifestPath, updated);
|
|
505
510
|
// Update agent records
|
|
506
511
|
for (const task of result.repairedTasks) {
|
|
507
512
|
try {
|
|
@@ -757,22 +757,17 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
|
|
|
757
757
|
stopTeamHeartbeat();
|
|
758
758
|
// P1: Catch unhandled errors — ensure manifest/tasks/agents are terminal so they don't stay "running" forever.
|
|
759
759
|
const message = error instanceof Error ? error.message : String(error);
|
|
760
|
-
//
|
|
761
|
-
//
|
|
762
|
-
//
|
|
763
|
-
//
|
|
764
|
-
//
|
|
765
|
-
//
|
|
766
|
-
//
|
|
767
|
-
//
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
// The stale-data concern is mitigated by the dispatcher having re-read disk
|
|
772
|
-
// state under lock at line ~1364 for each iteration; the manifest+task
|
|
773
|
-
// objects in the calling scope are already post-merge.
|
|
774
|
-
const freshManifest = manifest;
|
|
775
|
-
const freshTasks = refreshTaskGraphQueues(input.tasks);
|
|
760
|
+
// Re-read the latest persisted state from disk instead of trusting
|
|
761
|
+
// input.tasks (the ORIGINAL start snapshot, still all "queued" — it is never
|
|
762
|
+
// mutated by executeTeamRunCore). A late failure during closeout would
|
|
763
|
+
// otherwise map every task to "failed", overwriting tasks that already
|
|
764
|
+
// completed during the run. loadRunManifestById is the established
|
|
765
|
+
// fresh-read pattern in this file (see ~line 1269); it is best-effort with
|
|
766
|
+
// no lock, consistent with the lock-drop decision below. If the disk read
|
|
767
|
+
// fails, fall back to input.tasks so the run is still marked terminal.
|
|
768
|
+
const fresh = loadRunManifestById(manifest.cwd, manifest.runId);
|
|
769
|
+
const freshManifest = fresh?.manifest ?? manifest;
|
|
770
|
+
const freshTasks = refreshTaskGraphQueues(fresh?.tasks ?? input.tasks);
|
|
776
771
|
const failedAt = new Date().toISOString();
|
|
777
772
|
const tasks = freshTasks.map((task) =>
|
|
778
773
|
task.status === "running" || task.status === "queued" || task.status === "waiting"
|
|
@@ -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
|