pi-crew 0.10.2 → 0.10.3
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 +249 -0
- package/dist/index.mjs +98 -307
- package/package.json +2 -1
- package/schema.json +11 -0
- package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +6 -2
- package/skills/real-test-pi-crew/SKILL.md +278 -79
- package/src/config/config-merge.ts +11 -1
- package/src/config/config-validation.ts +40 -1
- package/src/config/config.ts +28 -6
- package/src/config/defaults.ts +35 -10
- package/src/config/env-vars.ts +27 -2
- package/src/config/types.ts +36 -0
- package/src/extension/registration/lifecycle-handlers.ts +40 -9
- package/src/extension/registration/team-tool.ts +53 -5
- package/src/extension/team-tool/doctor.ts +364 -7
- package/src/extension/team-tool/handle-settings.ts +19 -0
- package/src/extension/team-tool/inspect.ts +10 -2
- package/src/extension/team-tool/status.ts +7 -0
- package/src/extension/team-tool.ts +35 -2
- package/src/hooks/registry.ts +59 -56
- package/src/prompt/inbox-poll.ts +90 -0
- package/src/prompt/message-tool.ts +166 -0
- package/src/prompt/prompt-runtime.ts +201 -18
- package/src/prompt/surface-worker.ts +720 -0
- package/src/prompt/worker-events-channel.ts +49 -3
- package/src/runtime/async-runner.ts +29 -1
- package/src/runtime/background-runner.ts +13 -7
- package/src/runtime/broker/broker-issuer.ts +27 -2
- package/src/runtime/broker/crew-broker-tokens.ts +56 -4
- package/src/runtime/broker/crew-broker.ts +261 -41
- package/src/runtime/child-pi/child-pi-spawn.ts +23 -9
- package/src/runtime/child-pi/child-pi-streams.ts +9 -1
- package/src/runtime/child-pi/child-pi.ts +353 -5
- package/src/runtime/crew-agent-records.ts +13 -1
- package/src/runtime/dispatch-batch.ts +12 -1
- package/src/runtime/event-log-tail-source.ts +374 -0
- package/src/runtime/finalize-run.ts +4 -0
- package/src/runtime/live-session/live-agent-manager.ts +34 -1
- package/src/runtime/live-session/live-control-realtime.ts +10 -0
- package/src/runtime/live-session/live-session-runtime.ts +47 -27
- package/src/runtime/manifest-cache.ts +128 -17
- package/src/runtime/model/pi-args.ts +54 -65
- package/src/runtime/output/sidechain-output.ts +61 -6
- package/src/runtime/process/proc-stat.ts +46 -0
- package/src/runtime/process/zombie-scanner.ts +32 -19
- package/src/runtime/spawn-policy.ts +27 -41
- package/src/runtime/surface/degrade.ts +776 -0
- package/src/runtime/surface/herdr-provider.ts +546 -0
- package/src/runtime/surface/launch-script.ts +172 -0
- package/src/runtime/surface/resolve-surface.ts +274 -0
- package/src/runtime/surface/surface-provider.ts +129 -0
- package/src/runtime/surface/surface-spawn.ts +475 -0
- package/src/runtime/surface/tmux-provider.ts +400 -0
- package/src/runtime/task-runner/child-executor.ts +47 -0
- package/src/runtime/task-runner/post-execution.ts +57 -2
- package/src/runtime/task-runner/prompt-builder.ts +1 -0
- package/src/runtime/task-runner/retrieval-orchestrator.ts +191 -56
- package/src/runtime/task-runner/state-helpers.ts +54 -30
- package/src/runtime/task-runner.ts +4 -2
- package/src/runtime/team-runner.ts +101 -0
- package/src/schema/config-schema.ts +24 -0
- package/src/state/atomic-write.ts +219 -40
- package/src/state/coordination/locks.ts +7 -5
- package/src/state/coordination/mailbox.ts +56 -10
- package/src/state/event-log/cursor.ts +413 -23
- package/src/state/event-log/event-log.ts +120 -113
- package/src/state/event-log/sequence-cache.ts +21 -3
- package/src/state/stores/state-store.ts +98 -6
- package/src/state/types.ts +51 -0
- package/src/ui/inline-panel/agent-pane.ts +3 -0
- package/src/ui/render-diff.ts +16 -8
- package/src/ui/run-dashboard.ts +87 -42
- package/src/ui/run-event-bus.ts +10 -1
- package/src/ui/run-snapshot-cache.ts +83 -35
- package/src/ui/transcript-cache.ts +101 -13
- package/src/ui/transcript-viewer.ts +92 -24
- package/src/ui/widget/index.ts +32 -8
- package/src/utils/visual.ts +43 -0
- package/src/worktree/worktree-manager.ts +65 -4
|
@@ -267,7 +267,7 @@ function isTargetNotSymlink(filePath: string): boolean {
|
|
|
267
267
|
* entry is correctly reusable for every file written into that dir (no
|
|
268
268
|
* cross-file cache poisoning).
|
|
269
269
|
*/
|
|
270
|
-
function isSymlinkSafeDirCached(filePath: string): boolean {
|
|
270
|
+
export function isSymlinkSafeDirCached(filePath: string): boolean {
|
|
271
271
|
const now = Date.now();
|
|
272
272
|
const dir = path.dirname(filePath);
|
|
273
273
|
// Always re-check the target file (uncached) before trusting the dir verdict.
|
|
@@ -549,6 +549,91 @@ function normalizeOptions(arg: unknown): { expectedHash?: string; durability: Wr
|
|
|
549
549
|
return { durability: "full", mode: undefined, compact: undefined };
|
|
550
550
|
}
|
|
551
551
|
|
|
552
|
+
// PERF (2026-08-24): every atomic write ran mkdirSync(recursive) on a parent
|
|
553
|
+
// that exists for the lifetime of a run. Memoize known-existing dirs; on the
|
|
554
|
+
// ENOENT retry at temp-open (memoized dir deleted underneath us) BOTH the dir
|
|
555
|
+
// memo AND the symlink-safety caches for that dir are dropped (forgetDir +
|
|
556
|
+
// invalidateSymlinkSafeCache) so a deleted-then-recreated tree re-runs the
|
|
557
|
+
// full symlink walk on the next write — without the invalidation the stale
|
|
558
|
+
// dir verdict would be trusted for up to the 10s symlinkSafeCache TTL.
|
|
559
|
+
const knownDirs = new Set<string>();
|
|
560
|
+
const KNOWN_DIRS_MAX = 512;
|
|
561
|
+
export function ensureDirSync(dirPath: string): void {
|
|
562
|
+
if (knownDirs.has(dirPath)) return;
|
|
563
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
564
|
+
if (knownDirs.size >= KNOWN_DIRS_MAX) {
|
|
565
|
+
const oldest = knownDirs.keys().next().value;
|
|
566
|
+
if (oldest !== undefined) knownDirs.delete(oldest);
|
|
567
|
+
}
|
|
568
|
+
knownDirs.add(dirPath);
|
|
569
|
+
}
|
|
570
|
+
function forgetDir(dirPath: string): void {
|
|
571
|
+
knownDirs.delete(dirPath);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// T8 (perf round 2, 2026-08-25): grouped parent-dir fsync for coalesced drains.
|
|
575
|
+
//
|
|
576
|
+
// A full-durability atomic write fsyncs (1) the data file and (2) the parent
|
|
577
|
+
// directory after the rename. When a coalesced DRAIN
|
|
578
|
+
// (`flushPendingAtomicWrites()` with no argument) serially flushes N pending
|
|
579
|
+
// files, the per-file parent-dir fsync is redundant: rename(2) is atomic and
|
|
580
|
+
// visible to readers the instant it happens (independent of the dir fsync),
|
|
581
|
+
// and ONE fsync of the parent dir after ALL renames makes every rename in
|
|
582
|
+
// that dir crash-durable in a single journal commit. Measured on this repo's
|
|
583
|
+
// state burst (4 files, one dir): 59.4ms serial per-file dir-fsync vs 22.9ms
|
|
584
|
+
// with one shared trailing dir-fsync (−61%), full durability preserved. Files
|
|
585
|
+
// in distinct dirs still group per-dir.
|
|
586
|
+
//
|
|
587
|
+
// Ordering (R16-B1): every rename completes BEFORE the trailing dir-fsync —
|
|
588
|
+
// guaranteed because the drain loop is serial and synchronous and the
|
|
589
|
+
// trailing fsync only runs after the loop.
|
|
590
|
+
//
|
|
591
|
+
// Scoping: `dirFsyncDeferralDepth` is raised ONLY around the global-drain
|
|
592
|
+
// loop, so the deferral never leaks to other callers — direct `atomicWriteJson`
|
|
593
|
+
// / `atomicWriteFile` calls, the terminal `skipCoalesce` path, scoped
|
|
594
|
+
// `flushPendingAtomicWrites(filePath)` flushes, and coalesce-timer
|
|
595
|
+
// `flushOnePendingAtomicWrite` callbacks all run at depth 0 and keep their
|
|
596
|
+
// immediate dir-fsync. `pendingDirFsyncs` is drained from the drain's
|
|
597
|
+
// `finally` so a mid-drain throw still fsyncs the dirs of files that were
|
|
598
|
+
// already renamed. Nested drains are impossible via the existing
|
|
599
|
+
// `flushInProgress` guard. There is no async drain counterpart today; the
|
|
600
|
+
// async path therefore NEVER defers (see its comment at the dir-fsync site).
|
|
601
|
+
const pendingDirFsyncs = new Set<string>();
|
|
602
|
+
let dirFsyncDeferralDepth = 0;
|
|
603
|
+
|
|
604
|
+
/** Immediate (ungrouped) parent-dir fsync — the pre-T8 inline behavior. */
|
|
605
|
+
function fsyncParentDirImmediate(filePath: string): void {
|
|
606
|
+
try {
|
|
607
|
+
const dirFd = fs.openSync(path.dirname(filePath), "r");
|
|
608
|
+
fs.fsyncSync(dirFd);
|
|
609
|
+
fs.closeSync(dirFd);
|
|
610
|
+
} catch {
|
|
611
|
+
/* best-effort — not all filesystems support directory fsync */
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** Flush the dirs accumulated by a grouped drain: ONE open+fsync+close per
|
|
616
|
+
* DISTINCT dir. Called from the drain's `finally` so it fires even when the
|
|
617
|
+
* serial loop threw mid-drain — files renamed before the throw must still
|
|
618
|
+
* become crash-durable. Best-effort per dir, mirroring the inline path.
|
|
619
|
+
* The set is snapshotted+cleared up front so a throwing dir cannot skip the
|
|
620
|
+
* remaining dirs or leak entries into the next drain. */
|
|
621
|
+
function fsyncPendingParentDirs(): void {
|
|
622
|
+
if (pendingDirFsyncs.size === 0) return;
|
|
623
|
+
const dirs = [...pendingDirFsyncs];
|
|
624
|
+
pendingDirFsyncs.clear();
|
|
625
|
+
if (process.platform === "win32") return;
|
|
626
|
+
for (const dir of dirs) {
|
|
627
|
+
try {
|
|
628
|
+
const dirFd = fs.openSync(dir, "r");
|
|
629
|
+
fs.fsyncSync(dirFd);
|
|
630
|
+
fs.closeSync(dirFd);
|
|
631
|
+
} catch {
|
|
632
|
+
/* best-effort — not all filesystems support directory fsync */
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
552
637
|
export function atomicWriteFile(filePath: string, content: string, options?: AtomicWriteOptions): void {
|
|
553
638
|
cancelPendingCoalescedWrite(filePath);
|
|
554
639
|
const { durability, mode } = normalizeOptions(options);
|
|
@@ -581,7 +666,9 @@ export function atomicWriteFile(filePath: string, content: string, options?: Ato
|
|
|
581
666
|
};
|
|
582
667
|
const dirPath = path.dirname(filePath);
|
|
583
668
|
try {
|
|
584
|
-
|
|
669
|
+
// PERF (2026-08-24): memoized — on a memo hit this cannot throw, so the
|
|
670
|
+
// EPERM fallback below only ever runs when mkdir actually executed.
|
|
671
|
+
ensureDirSync(dirPath);
|
|
585
672
|
} catch (error) {
|
|
586
673
|
if (process.platform === "win32" && (error as NodeJS.ErrnoException).code === "EPERM") {
|
|
587
674
|
// mkdir hit a short/long-name alias wall — retry with the canonical
|
|
@@ -604,7 +691,24 @@ export function atomicWriteFile(filePath: string, content: string, options?: Ato
|
|
|
604
691
|
// removes a leftover instead of being skipped by the stale `fd` guard.
|
|
605
692
|
let tempNeedsCleanup = false;
|
|
606
693
|
try {
|
|
607
|
-
|
|
694
|
+
// PERF (2026-08-24): the memoized parent may have been deleted after
|
|
695
|
+
// memoization — openSync then fails ENOENT. Forget the memo, re-create
|
|
696
|
+
// the dir, and retry the open exactly once; a second failure re-throws
|
|
697
|
+
// (through the outer finally, which has nothing to clean up yet since
|
|
698
|
+
// the temp never existed).
|
|
699
|
+
try {
|
|
700
|
+
fd = fs.openSync(tempPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW, mode ?? 0o600);
|
|
701
|
+
} catch (openError) {
|
|
702
|
+
if ((openError as NodeJS.ErrnoException).code !== "ENOENT") throw openError;
|
|
703
|
+
forgetDir(dirPath);
|
|
704
|
+
// Parity hardening: drop the symlink-safety verdict for this dir too —
|
|
705
|
+
// the memoized dir may have been deleted and RECREATED (possibly as a
|
|
706
|
+
// symlink or with symlinked ancestors), and the cached verdict would
|
|
707
|
+
// otherwise stay trusted for up to the 10s TTL.
|
|
708
|
+
invalidateSymlinkSafeCache(dirPath);
|
|
709
|
+
ensureDirSync(dirPath);
|
|
710
|
+
fd = fs.openSync(tempPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW, mode ?? 0o600);
|
|
711
|
+
}
|
|
608
712
|
tempNeedsCleanup = true; // ST-7: temp file now exists on disk
|
|
609
713
|
// Post-open verification: on Windows O_NOFOLLOW is 0, so verify FD is a regular file
|
|
610
714
|
const openedStat = fs.fstatSync(fd);
|
|
@@ -657,13 +761,16 @@ export function atomicWriteFile(filePath: string, content: string, options?: Ato
|
|
|
657
761
|
// where the Windows-specific rename path in renameWithLinkSync already
|
|
658
762
|
// uses MoveFileEx which is fully durable).
|
|
659
763
|
// F4: honor durability — best-effort also skips the parent-dir fsync.
|
|
764
|
+
// T8: inside a coalesced DRAIN (dirFsyncDeferralDepth > 0 — raised only
|
|
765
|
+
// by flushPendingAtomicWrites() with no argument) DEFER the dir fsync:
|
|
766
|
+
// accumulate the dirname in pendingDirFsyncs and let the drain issue
|
|
767
|
+
// ONE fsync per distinct dir after all renames. Every other caller
|
|
768
|
+
// runs at depth 0 and keeps this immediate dir-fsync unchanged.
|
|
660
769
|
if (durability === "full" && process.platform !== "win32") {
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
} catch {
|
|
666
|
-
/* best-effort — not all filesystems support directory fsync */
|
|
770
|
+
if (dirFsyncDeferralDepth > 0) {
|
|
771
|
+
pendingDirFsyncs.add(path.dirname(filePath));
|
|
772
|
+
} else {
|
|
773
|
+
fsyncParentDirImmediate(filePath);
|
|
667
774
|
}
|
|
668
775
|
}
|
|
669
776
|
} catch (renameError) {
|
|
@@ -731,16 +838,36 @@ export async function atomicWriteFileAsync(filePath: string, content: string, op
|
|
|
731
838
|
}
|
|
732
839
|
if (!isSymlinkSafeDirCached(filePath))
|
|
733
840
|
throw new Error(`Refusing to write: target is a symlink or inside untrusted directory: ${filePath}`);
|
|
734
|
-
|
|
841
|
+
// PERF (2026-08-24): shared dir memo — a sync mkdir on an existing dir is one
|
|
842
|
+
// cheap syscall; replacing `await fs.promises.mkdir` is fine because the
|
|
843
|
+
// memoized hit does not throw and does not yield the event loop.
|
|
844
|
+
ensureDirSync(path.dirname(filePath));
|
|
735
845
|
const tempPath = `${filePath}.${crypto.randomUUID()}.tmp`;
|
|
736
846
|
let fd: fs.promises.FileHandle | undefined;
|
|
737
847
|
try {
|
|
738
848
|
const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
849
|
+
// PERF (2026-08-24): same ENOENT retry as the sync path — the memoized
|
|
850
|
+
// dir may have been deleted between memoization and this open.
|
|
851
|
+
try {
|
|
852
|
+
fd = await fs.promises.open(
|
|
853
|
+
tempPath,
|
|
854
|
+
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW,
|
|
855
|
+
mode ?? 0o600,
|
|
856
|
+
);
|
|
857
|
+
} catch (openError) {
|
|
858
|
+
if ((openError as NodeJS.ErrnoException).code !== "ENOENT") throw openError;
|
|
859
|
+
forgetDir(path.dirname(filePath));
|
|
860
|
+
// Parity hardening (async twin of the sync path): drop the cached
|
|
861
|
+
// symlink-safety verdict alongside the dir memo so a recreated dir
|
|
862
|
+
// re-runs the walk on the next write.
|
|
863
|
+
invalidateSymlinkSafeCache(path.dirname(filePath));
|
|
864
|
+
ensureDirSync(path.dirname(filePath));
|
|
865
|
+
fd = await fs.promises.open(
|
|
866
|
+
tempPath,
|
|
867
|
+
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW,
|
|
868
|
+
mode ?? 0o600,
|
|
869
|
+
);
|
|
870
|
+
}
|
|
744
871
|
// Post-open verification: on Windows O_NOFOLLOW is 0, so verify FD is a regular file
|
|
745
872
|
const openedStat = await fd.stat();
|
|
746
873
|
if (!openedStat.isFile()) {
|
|
@@ -773,6 +900,14 @@ export async function atomicWriteFileAsync(filePath: string, content: string, op
|
|
|
773
900
|
// some Linux filesystems, so a crash between rename and journal flush
|
|
774
901
|
// could leave a stale directory entry. The sync path already does this;
|
|
775
902
|
// this brings the async path to durability parity.
|
|
903
|
+
// T8: the grouped dir-fsync (pendingDirFsyncs) deliberately does NOT
|
|
904
|
+
// apply here. There is no ASYNC coalesced drain — the drain loop in
|
|
905
|
+
// flushPendingAtomicWrites() is fully synchronous and calls only the
|
|
906
|
+
// sync atomicWriteFile, so an async write can never observe
|
|
907
|
+
// dirFsyncDeferralDepth > 0. Deferring here would add dirs to a set
|
|
908
|
+
// whose trailing flush has already run (worse than not fsyncing at
|
|
909
|
+
// all). If an async drain is ever added, it must collect distinct dirs
|
|
910
|
+
// and Promise.all their fsyncs at the end of the drain.
|
|
776
911
|
if (durability === "full" && process.platform !== "win32") {
|
|
777
912
|
try {
|
|
778
913
|
const dirFd = await fs.promises.open(path.dirname(filePath), "r");
|
|
@@ -832,7 +967,12 @@ export async function atomicWriteJsonAsync<T>(filePath: string, value: T, option
|
|
|
832
967
|
// Auto-flush hooks: process exit / SIGTERM / SIGINT, plus an exposed
|
|
833
968
|
// `flushPendingAtomicWrites()` for cleanupRuntime.
|
|
834
969
|
interface CoalescedAtomicWrite {
|
|
835
|
-
|
|
970
|
+
/** PERF (2026-08-24): the value is stringified at FLUSH time, not queue
|
|
971
|
+
* time (see atomicWriteJsonCoalesced). Flushed content reflects the
|
|
972
|
+
* object's state at flush time. */
|
|
973
|
+
value: unknown;
|
|
974
|
+
/** PERF-6: formatting captured at queue time, applied at flush time. */
|
|
975
|
+
compact: boolean;
|
|
836
976
|
timer: ReturnType<typeof setTimeout>;
|
|
837
977
|
coalesceMs: number;
|
|
838
978
|
retryCount: number;
|
|
@@ -880,10 +1020,16 @@ export function atomicWriteJsonCoalesced<T>(
|
|
|
880
1020
|
atomicWriteJson(filePath, value, options);
|
|
881
1021
|
return;
|
|
882
1022
|
}
|
|
883
|
-
// PERF-6: honor compact — normalize BEFORE
|
|
884
|
-
//
|
|
1023
|
+
// PERF-6: honor compact — normalize BEFORE queueing so the buffered entry
|
|
1024
|
+
// carries the caller's preferred formatting for the flush-time stringify.
|
|
885
1025
|
const normalized = normalizeOptions(options);
|
|
886
|
-
|
|
1026
|
+
// PERF (2026-08-24): stringify moved to FLUSH time. persistSingleTaskUpdate
|
|
1027
|
+
// re-saves every ~500ms per task while the coalesce window is 50ms — callers
|
|
1028
|
+
// that keep writing overwrite the pending entry before it ever flushes, so
|
|
1029
|
+
// eager stringify burned a full-array serialize per save for nothing.
|
|
1030
|
+
// NOTE (semantic): the flushed content reflects the object's state at flush
|
|
1031
|
+
// time. All current callers hand us a freshly built array and drop it; if a
|
|
1032
|
+
// future caller mutates after queueing, that mutation persists.
|
|
887
1033
|
const previous = pendingAtomicWrites.get(filePath);
|
|
888
1034
|
if (previous) clearTimeout(previous.timer);
|
|
889
1035
|
const timer = setTimeout(() => flushOnePendingAtomicWrite(filePath), coalesceMs);
|
|
@@ -891,7 +1037,8 @@ export function atomicWriteJsonCoalesced<T>(
|
|
|
891
1037
|
// Issue 2 fix: increment generation for each new entry
|
|
892
1038
|
const generation = ++writeGeneration;
|
|
893
1039
|
pendingAtomicWrites.set(filePath, {
|
|
894
|
-
|
|
1040
|
+
value,
|
|
1041
|
+
compact: normalized.compact === true,
|
|
895
1042
|
timer,
|
|
896
1043
|
coalesceMs,
|
|
897
1044
|
retryCount: 0,
|
|
@@ -909,7 +1056,11 @@ function flushOnePendingAtomicWrite(filePath: string): void {
|
|
|
909
1056
|
const savedGeneration = entry.generation;
|
|
910
1057
|
clearTimeout(entry.timer);
|
|
911
1058
|
try {
|
|
912
|
-
|
|
1059
|
+
// PERF (2026-08-24): stringify happens HERE (flush time), not at queue
|
|
1060
|
+
// time — see the note in atomicWriteJsonCoalesced. No defensive deep-copy
|
|
1061
|
+
// of entry.value: that would reintroduce the cost this change removes.
|
|
1062
|
+
const content = `${entry.compact ? JSON.stringify(entry.value) : JSON.stringify(entry.value, null, 2)}\n`;
|
|
1063
|
+
atomicWriteFile(filePath, content, { durability: entry.durability });
|
|
913
1064
|
// Issue 2 fix: Verify generation hasn't changed before deleting.
|
|
914
1065
|
// A concurrent write may have replaced entry with a newer one during the flush.
|
|
915
1066
|
// Only delete if generation matches (not a newer entry).
|
|
@@ -919,25 +1070,34 @@ function flushOnePendingAtomicWrite(filePath: string): void {
|
|
|
919
1070
|
}
|
|
920
1071
|
} catch (error) {
|
|
921
1072
|
logInternalError("atomic-write.coalesced-flush", error, filePath, "error");
|
|
922
|
-
//
|
|
923
|
-
//
|
|
924
|
-
// write
|
|
925
|
-
//
|
|
1073
|
+
// ISSUE (2026-08-26, dead-retry-path fix): atomicWriteFile's first
|
|
1074
|
+
// statement cancels the pending entry (cancelPendingCoalescedWrite),
|
|
1075
|
+
// so when the write throws, the map no longer holds this entry — the
|
|
1076
|
+
// previous retry logic looked the map up, found nothing, and silently
|
|
1077
|
+
// dropped the buffered write (no retry, no propagation). RE-ADD the
|
|
1078
|
+
// captured entry (unless a NEWER write re-queued during the flush —
|
|
1079
|
+
// its generation would differ) so the backoff retry below fires on
|
|
1080
|
+
// real data instead of a phantom lookup.
|
|
926
1081
|
const current = pendingAtomicWrites.get(filePath);
|
|
927
|
-
if (current
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
timer.unref();
|
|
939
|
-
current.timer = timer;
|
|
1082
|
+
if (current !== undefined && current.generation !== savedGeneration) {
|
|
1083
|
+
// A newer write arrived during the flush — leave it alone.
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
const entryToRetry = current ?? entry;
|
|
1087
|
+
entryToRetry.retryCount++;
|
|
1088
|
+
if (entryToRetry.retryCount >= MAX_FLUSH_RETRIES) {
|
|
1089
|
+
// Max retries exceeded - remove entry and propagate error to callers
|
|
1090
|
+
pendingAtomicWrites.delete(filePath);
|
|
1091
|
+
// Re-throw so callers can handle the persistent failure
|
|
1092
|
+
throw error;
|
|
940
1093
|
}
|
|
1094
|
+
// Exponential backoff: base delay * 2^(retryCount-1), capped at 30 seconds
|
|
1095
|
+
const backoffMs = Math.min(30000, entryToRetry.coalesceMs * 2 ** (entryToRetry.retryCount - 1));
|
|
1096
|
+
clearTimeout(entryToRetry.timer);
|
|
1097
|
+
const timer = setTimeout(() => flushOnePendingAtomicWrite(filePath), backoffMs);
|
|
1098
|
+
timer.unref();
|
|
1099
|
+
entryToRetry.timer = timer;
|
|
1100
|
+
pendingAtomicWrites.set(filePath, entryToRetry);
|
|
941
1101
|
}
|
|
942
1102
|
}
|
|
943
1103
|
|
|
@@ -975,18 +1135,37 @@ function cancelPendingCoalescedWrite(filePath: string): void {
|
|
|
975
1135
|
* Re-entrancy guard (`flushInProgress`) applies to both modes: a scoped flush
|
|
976
1136
|
* triggered while a global flush is running is a no-op (the global flush
|
|
977
1137
|
* already covers this file), and vice versa.
|
|
1138
|
+
*
|
|
1139
|
+
* T8: the GLOBAL drain (no argument) groups the parent-dir fsync — each
|
|
1140
|
+
* flushed file keeps its data fsync but defers the dir fsync, and after the
|
|
1141
|
+
* serial loop completes (all renames done → R16-B1 ordering) ONE fsync is
|
|
1142
|
+
* issued per distinct pending dir. The trailing fsync lives in the `finally`
|
|
1143
|
+
* so a mid-drain throw (a flush that exhausted MAX_FLUSH_RETRIES) still
|
|
1144
|
+
* makes the already-renamed files crash-durable. Scoped flushes and
|
|
1145
|
+
* coalesce-timer flushes keep the immediate per-file dir fsync.
|
|
978
1146
|
*/
|
|
979
1147
|
export function flushPendingAtomicWrites(filePath?: string): void {
|
|
980
1148
|
if (flushInProgress > 0) return;
|
|
981
1149
|
flushInProgress++;
|
|
1150
|
+
const drainAll = filePath === undefined;
|
|
1151
|
+
if (drainAll) dirFsyncDeferralDepth++;
|
|
982
1152
|
try {
|
|
983
|
-
if (
|
|
1153
|
+
if (drainAll) {
|
|
984
1154
|
for (const pending of [...pendingAtomicWrites.keys()]) flushOnePendingAtomicWrite(pending);
|
|
985
1155
|
} else if (pendingAtomicWrites.has(filePath)) {
|
|
986
1156
|
flushOnePendingAtomicWrite(filePath);
|
|
987
1157
|
}
|
|
988
1158
|
} finally {
|
|
989
|
-
|
|
1159
|
+
try {
|
|
1160
|
+
if (drainAll) {
|
|
1161
|
+
// Decrement BEFORE flushing the deferred dirs so nothing inside
|
|
1162
|
+
// the trailing fsync could observe a deferral scope.
|
|
1163
|
+
dirFsyncDeferralDepth--;
|
|
1164
|
+
fsyncPendingParentDirs();
|
|
1165
|
+
}
|
|
1166
|
+
} finally {
|
|
1167
|
+
flushInProgress--;
|
|
1168
|
+
}
|
|
990
1169
|
}
|
|
991
1170
|
}
|
|
992
1171
|
|
|
@@ -5,7 +5,7 @@ import * as path from "node:path";
|
|
|
5
5
|
import { DEFAULT_LOCKS } from "../../config/defaults.ts";
|
|
6
6
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
7
7
|
import { sleepSync } from "../../utils/sleep.ts";
|
|
8
|
-
import { isSymlinkSafePath } from "../atomic-write.ts";
|
|
8
|
+
import { ensureDirSync, isSymlinkSafeDirCached, isSymlinkSafePath } from "../atomic-write.ts";
|
|
9
9
|
import type { TeamRunManifest } from "../types.ts";
|
|
10
10
|
|
|
11
11
|
export interface RunLockOptions {
|
|
@@ -415,10 +415,12 @@ export function withFileLockSync<T>(filePath: string, fn: () => T, options: RunL
|
|
|
415
415
|
if (fileLockHeldByUs.get(lockFile)) {
|
|
416
416
|
return fn();
|
|
417
417
|
}
|
|
418
|
-
//
|
|
419
|
-
//
|
|
420
|
-
|
|
421
|
-
|
|
418
|
+
// PERF (2026-08-24): pre-loop check uses the 10s-TTL cached verdict (same
|
|
419
|
+
// tradeoff atomic writes already make). The RETRY loop below keeps the
|
|
420
|
+
// UNCACHED re-validation — TOCTOU rigor is preserved exactly where a race
|
|
421
|
+
// is actually being retried.
|
|
422
|
+
if (!isSymlinkSafeDirCached(path.dirname(lockFile))) throw new Error("Refusing: parent of lock directory is a symlink");
|
|
423
|
+
ensureDirSync(path.dirname(lockFile));
|
|
422
424
|
// Round 26 (BUG 2): REMOVED the pre-acquisition target-file-existence check.
|
|
423
425
|
// It was racy — between statSync(target) and acquire, a concurrent process
|
|
424
426
|
// could acquire the lock to CREATE the target, and we'd delete its active
|
|
@@ -9,7 +9,7 @@ import { withFileLockAsync, withFileLockSync } from "./locks.ts";
|
|
|
9
9
|
|
|
10
10
|
export type MailboxDirection = "inbox" | "outbox";
|
|
11
11
|
export type MailboxMessageStatus = "queued" | "delivered" | "acknowledged";
|
|
12
|
-
export type MailboxMessageKind = "message" | "steer" | "follow-up" | "response" | "group_join";
|
|
12
|
+
export type MailboxMessageKind = "message" | "notify" | "steer" | "follow-up" | "response" | "group_join";
|
|
13
13
|
export type MailboxMessagePriority = "urgent" | "normal" | "low";
|
|
14
14
|
export type MailboxDeliveryMode = "interrupt" | "next_turn";
|
|
15
15
|
|
|
@@ -257,7 +257,14 @@ function isStatus(value: unknown): value is MailboxMessageStatus {
|
|
|
257
257
|
}
|
|
258
258
|
|
|
259
259
|
function isKind(value: unknown): value is MailboxMessageKind {
|
|
260
|
-
return
|
|
260
|
+
return (
|
|
261
|
+
value === "message" ||
|
|
262
|
+
value === "notify" ||
|
|
263
|
+
value === "steer" ||
|
|
264
|
+
value === "follow-up" ||
|
|
265
|
+
value === "response" ||
|
|
266
|
+
value === "group_join"
|
|
267
|
+
);
|
|
261
268
|
}
|
|
262
269
|
|
|
263
270
|
function isPriority(value: unknown): value is MailboxMessagePriority {
|
|
@@ -309,8 +316,9 @@ function parseMailboxMessage(raw: unknown, expectedDirection: MailboxDirection):
|
|
|
309
316
|
};
|
|
310
317
|
}
|
|
311
318
|
|
|
312
|
-
|
|
313
|
-
|
|
319
|
+
/** Raw read+parse of one mailbox JSONL file (extracted verbatim from the old
|
|
320
|
+
* readMailboxFile body). Callers go through cachedMailboxRead instead. */
|
|
321
|
+
function parseMailboxFile(filePath: string, direction: MailboxDirection): MailboxMessage[] {
|
|
314
322
|
const messages: MailboxMessage[] = [];
|
|
315
323
|
const raw = fs.readFileSync(filePath, "utf-8");
|
|
316
324
|
for (const line of raw.split(/\r?\n/).filter(Boolean)) {
|
|
@@ -324,18 +332,50 @@ function readMailboxFile(filePath: string, direction: MailboxDirection): Mailbox
|
|
|
324
332
|
return messages;
|
|
325
333
|
}
|
|
326
334
|
|
|
335
|
+
// PERF (2026-08-24): parked workers poll all mailboxes every 500ms and used to
|
|
336
|
+
// read+parse every file each tick. Parse results are now memoized per file by
|
|
337
|
+
// (mtime, size); append/rotate change mtime so invalidation is automatic.
|
|
338
|
+
// Entries hold the parsed array; readers get a shallow copy (array of refs) —
|
|
339
|
+
// 100x cheaper than re-reading, and callers never mutate message objects.
|
|
340
|
+
const mailboxParseCache = new Map<string, { mtimeMs: number; size: number; messages: MailboxMessage[] }>();
|
|
341
|
+
const MAILBOX_PARSE_CACHE_MAX = 128;
|
|
342
|
+
function cachedMailboxRead(filePath: string, direction: MailboxDirection): MailboxMessage[] {
|
|
343
|
+
let stat: fs.Stats;
|
|
344
|
+
try {
|
|
345
|
+
stat = fs.statSync(filePath);
|
|
346
|
+
} catch {
|
|
347
|
+
// Missing (or vanished, e.g. pruned archive) → drop any stale entry.
|
|
348
|
+
mailboxParseCache.delete(filePath);
|
|
349
|
+
return [];
|
|
350
|
+
}
|
|
351
|
+
const hit = mailboxParseCache.get(filePath);
|
|
352
|
+
if (hit && hit.mtimeMs === stat.mtimeMs && hit.size === stat.size) return hit.messages.slice();
|
|
353
|
+
const messages = parseMailboxFile(filePath, direction);
|
|
354
|
+
if (mailboxParseCache.size >= MAILBOX_PARSE_CACHE_MAX) {
|
|
355
|
+
const oldest = mailboxParseCache.keys().next().value;
|
|
356
|
+
if (oldest !== undefined) mailboxParseCache.delete(oldest);
|
|
357
|
+
}
|
|
358
|
+
mailboxParseCache.set(filePath, { mtimeMs: stat.mtimeMs, size: stat.size, messages });
|
|
359
|
+
return messages.slice();
|
|
360
|
+
}
|
|
361
|
+
|
|
327
362
|
function safeReadMailboxFile(filePath: string, direction: MailboxDirection): MailboxMessage[] {
|
|
328
|
-
|
|
329
|
-
const messages
|
|
363
|
+
// PERF: stat-gated primary read — ENOENT is handled inside cachedMailboxRead.
|
|
364
|
+
const messages = cachedMailboxRead(filePath, direction);
|
|
330
365
|
// 3.3 — also include any rotated archive files alongside the live file.
|
|
331
366
|
// Archive naming: `<filename>.<isoTimestamp>.archive.jsonl`.
|
|
367
|
+
// PERF: archives are immutable once written (rotation only ever creates
|
|
368
|
+
// new ones — see rotateMailboxFileIfNeeded's rename + atomicWriteFile
|
|
369
|
+
// sequence), so they route through cachedMailboxRead too and permanently
|
|
370
|
+
// hit after the first parse. pruneOldMailboxArchives deleting an archive
|
|
371
|
+
// is covered by cachedMailboxRead's ENOENT → cache-delete branch.
|
|
332
372
|
try {
|
|
333
373
|
const dir = path.dirname(filePath);
|
|
334
374
|
const base = path.basename(filePath);
|
|
335
375
|
for (const entry of fs.readdirSync(dir)) {
|
|
336
376
|
if (!entry.startsWith(`${base}.`) || !entry.endsWith(".archive.jsonl")) continue;
|
|
337
377
|
const archivePath = path.join(dir, entry);
|
|
338
|
-
messages.push(...
|
|
378
|
+
messages.push(...cachedMailboxRead(archivePath, direction));
|
|
339
379
|
}
|
|
340
380
|
} catch {
|
|
341
381
|
// Directory missing — nothing to read.
|
|
@@ -610,8 +650,10 @@ export function appendMailboxMessage(
|
|
|
610
650
|
const delivery = readDeliveryState(manifest);
|
|
611
651
|
delivery.messages[complete.id] = complete.status;
|
|
612
652
|
delivery.updatedAt = createdAt;
|
|
613
|
-
// F4:
|
|
614
|
-
|
|
653
|
+
// F4: delivery state is informational and overwritten by the next message —
|
|
654
|
+
// drop explicit full durability so the default (best-effort) applies; a hard
|
|
655
|
+
// crash only risks re-delivery, the accepted semantics of the default path.
|
|
656
|
+
writeDeliveryState(manifest, delivery);
|
|
615
657
|
});
|
|
616
658
|
notifyMailboxAppended(complete);
|
|
617
659
|
return complete;
|
|
@@ -721,7 +763,11 @@ export async function appendMailboxMessageAsync(
|
|
|
721
763
|
const delivery = readDeliveryState(manifest);
|
|
722
764
|
delivery.messages[complete.id] = complete.status;
|
|
723
765
|
delivery.updatedAt = createdAt;
|
|
724
|
-
|
|
766
|
+
// PERF round 2 (mirror of the sync-twin fix at :647): delivery.json is
|
|
767
|
+
// informational and the next message overwrites it — drop the forced
|
|
768
|
+
// full durability so the default (best-effort) applies here too. A
|
|
769
|
+
// crash risks re-delivery only, the accepted default-path semantics.
|
|
770
|
+
writeDeliveryState(manifest, delivery);
|
|
725
771
|
});
|
|
726
772
|
notifyMailboxAppended(complete);
|
|
727
773
|
return complete;
|