pi-crew 0.10.2 → 0.10.4
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/AGENTS.md +2 -1
- package/CHANGELOG.md +249 -0
- package/README.md +5 -1
- package/dist/index.mjs +10844 -7250
- package/docs/architecture.md +4 -4
- package/docs/commands-reference.md +3 -0
- package/docs/publishing.md +15 -3
- package/install.mjs +90 -39
- package/package.json +9 -3
- package/schema.json +11 -0
- package/scripts/README.md +4 -3
- package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +7 -2
- package/skills/real-test-pi-crew/SKILL.md +428 -82
- 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/migration-validator.ts +113 -0
- package/src/config/types.ts +36 -0
- package/src/extension/cross-extension-rpc.ts +3 -7
- package/src/extension/register.ts +13 -0
- package/src/extension/registration/lifecycle-handlers.ts +40 -9
- package/src/extension/registration/observability.ts +3 -7
- package/src/extension/registration/subagent-tools.ts +3 -7
- package/src/extension/registration/team-tool.ts +56 -12
- package/src/extension/registration/ui.ts +3 -8
- package/src/extension/registration/viewers.ts +3 -10
- package/src/extension/team-manager-command.ts +3 -7
- package/src/extension/team-tool/api/agent-control.ts +17 -10
- package/src/extension/team-tool/api/heartbeat.ts +4 -3
- package/src/extension/team-tool/api/mailbox.ts +33 -20
- package/src/extension/team-tool/api/plan-approval.ts +5 -5
- package/src/extension/team-tool/api/task-claims.ts +8 -7
- package/src/extension/team-tool/cancel.ts +6 -0
- package/src/extension/team-tool/doctor.ts +364 -7
- package/src/extension/team-tool/handle-settings.ts +23 -1
- package/src/extension/team-tool/inspect.ts +10 -2
- package/src/extension/team-tool/run.ts +3 -7
- package/src/extension/team-tool/status.ts +12 -0
- package/src/extension/team-tool.ts +41 -16
- package/src/hooks/registry.ts +62 -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/scratchpad-lifecycle.ts +3 -3
- 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 +43 -42
- 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 +334 -443
- package/src/runtime/broker/delegate/delegate-event.ts +37 -0
- package/src/runtime/broker/mailbox-observer/mailbox-fanout.ts +59 -0
- package/src/runtime/broker/protocol/connection-state.ts +103 -0
- package/src/runtime/broker/protocol/events-replay.ts +68 -0
- package/src/runtime/broker/protocol/manifest-loader.ts +20 -0
- package/src/runtime/broker/protocol/msg-inbox.ts +69 -0
- package/src/runtime/broker/protocol/request-parsers.ts +175 -0
- package/src/runtime/broker/protocol/wait-auth.ts +46 -0
- 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 +368 -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 +19 -7
- package/src/runtime/foreground-control.ts +19 -6
- package/src/runtime/goal-workflow/dynamic-workflow-context.ts +6 -0
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +3 -0
- package/src/runtime/goal-workflow/goal-loop-runner.ts +29 -27
- package/src/runtime/goal-workflow/goal-state-store.ts +3 -0
- package/src/runtime/heartbeat/heartbeat-watcher.ts +3 -3
- 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 +59 -65
- package/src/runtime/output/sidechain-output.ts +61 -6
- package/src/runtime/plan-replan.ts +3 -0
- 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/stale-reconciler.ts +28 -3
- package/src/runtime/supervisor-contact.ts +3 -0
- 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 +80 -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 +104 -3
- 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/ownership-map.ts +5 -4
- package/src/state/stores/plan-store.ts +12 -0
- package/src/state/stores/state-store.ts +103 -6
- package/src/state/types.ts +51 -0
- package/src/ui/inline-panel/agent-pane.ts +3 -0
- package/src/ui/powerbar-publisher.ts +3 -7
- package/src/ui/render-diff.ts +16 -8
- package/src/ui/run-action-dispatcher.ts +7 -10
- 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/settings-overlay.ts +4 -1
- 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
|
@@ -7,16 +7,13 @@ import { emitFromTeamEvent } from "../../ui/run-event-bus.ts";
|
|
|
7
7
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
8
8
|
import { redactSecrets } from "../../utils/redaction.ts";
|
|
9
9
|
import { sleep, sleepSync } from "../../utils/sleep.ts";
|
|
10
|
-
import { atomicWriteFile } from "../atomic-write.ts";
|
|
11
10
|
import { applyCompactionUnlocked, needsRotation, prepareCompaction, rotateEventLogUnlocked } from "./event-log-rotation.ts";
|
|
12
11
|
import {
|
|
13
12
|
advanceSequenceCounter,
|
|
14
|
-
evictOldestSequenceCacheEntries,
|
|
15
|
-
MAX_SEQUENCE_CACHE_ENTRIES,
|
|
16
13
|
persistSequenceMonotonic,
|
|
14
|
+
reservedSequenceEnd,
|
|
17
15
|
reserveSequence,
|
|
18
16
|
reserveSequenceUnderLockAsync,
|
|
19
|
-
sequenceCache,
|
|
20
17
|
} from "./sequence-cache.ts";
|
|
21
18
|
import { appendFileViaWorker, isWorkerAtomicWriterEnabled } from "./worker-atomic-writer.ts";
|
|
22
19
|
|
|
@@ -134,9 +131,18 @@ export function withEventLogLockSync<T>(eventsPath: string, fn: () => T, options
|
|
|
134
131
|
// (reduced from 120s) is appropriate.
|
|
135
132
|
fs.mkdirSync(lockDir);
|
|
136
133
|
try {
|
|
137
|
-
|
|
134
|
+
// PERF (2026-08-25): pid file is disposable + mtime-stale-detected, so the
|
|
135
|
+
// full atomicWriteFile (2 fsync) here was dead ceremony — the .alock async
|
|
136
|
+
// path already writes pid via "wx". Mirror it: O_EXCL open, plain write.
|
|
137
|
+
// The lock dir itself is the mutex.
|
|
138
|
+
const pidFd = fs.openSync(pidFile, "wx");
|
|
139
|
+
try {
|
|
140
|
+
fs.writeSync(pidFd, String(process.pid));
|
|
141
|
+
} finally {
|
|
142
|
+
fs.closeSync(pidFd);
|
|
143
|
+
}
|
|
138
144
|
} catch {
|
|
139
|
-
/* best-effort */
|
|
145
|
+
/* best-effort — e.g. EEXIST under a re-taken dir; stale pid is mtime-detected */
|
|
140
146
|
}
|
|
141
147
|
acquired = true;
|
|
142
148
|
break;
|
|
@@ -330,8 +336,18 @@ async function withEventLogLockAsync<T>(
|
|
|
330
336
|
try {
|
|
331
337
|
await fs.promises.mkdir(lockDir);
|
|
332
338
|
try {
|
|
333
|
-
//
|
|
334
|
-
|
|
339
|
+
// PERF (2026-08-24): "wx" (O_CREAT|O_EXCL) — fails rather than
|
|
340
|
+
// following a planted symlink, so no O_NOFOLLOW/temp/rename
|
|
341
|
+
// ceremony needed for this disposable, mtime-stale-detected
|
|
342
|
+
// 4-byte file. We own the lock dir (we just mkdir'd it), so
|
|
343
|
+
// EEXIST means a crashed holder's leftover under OUR fresh dir
|
|
344
|
+
// or an attack — either way, skip: the dir itself is the mutex.
|
|
345
|
+
const fh = await fs.promises.open(pidFile, "wx");
|
|
346
|
+
try {
|
|
347
|
+
await fh.write(String(process.pid));
|
|
348
|
+
} finally {
|
|
349
|
+
await fh.close();
|
|
350
|
+
}
|
|
335
351
|
} catch {
|
|
336
352
|
/* best-effort */
|
|
337
353
|
}
|
|
@@ -566,9 +582,6 @@ export async function appendEventAsync(eventsPath: string, event: AppendTeamEven
|
|
|
566
582
|
logInternalError("event-log.size-check", error, `eventsPath=${eventsPath}`);
|
|
567
583
|
}
|
|
568
584
|
|
|
569
|
-
// FIND-10: post-append stat captured from the same fd (non-worker path)
|
|
570
|
-
// for reuse in the cache update below, avoiding a redundant path stat.
|
|
571
|
-
let postAppendStat: fs.Stats | undefined;
|
|
572
585
|
if (!skippedDueToSize) {
|
|
573
586
|
const line = JSON.stringify(redactSecrets(fullEvent)) + "\n";
|
|
574
587
|
// Phase 1.5: when worker atomic writer is enabled, append via worker.
|
|
@@ -595,16 +608,6 @@ export async function appendEventAsync(eventsPath: string, event: AppendTeamEven
|
|
|
595
608
|
await fd.appendFile(line, "utf-8");
|
|
596
609
|
// P0-4 (F3a mirror): skip the data fsync for non-terminal events.
|
|
597
610
|
if (isTerminal) await fd.sync();
|
|
598
|
-
// FIND-10 R1 fix: the cache-optimization fd.stat() must NOT sit in the
|
|
599
|
-
// seq-durability critical path. If it threw (rare — fd invalidated),
|
|
600
|
-
// it would skip persistSequence below and reopen the seq-reuse
|
|
601
|
-
// window the fsync just closed. Guard it; fall back to undefined
|
|
602
|
-
// (the later cache-update takes a path stat instead).
|
|
603
|
-
try {
|
|
604
|
-
postAppendStat = await fd.stat();
|
|
605
|
-
} catch {
|
|
606
|
-
postAppendStat = undefined;
|
|
607
|
-
}
|
|
608
611
|
} finally {
|
|
609
612
|
await fd.close();
|
|
610
613
|
}
|
|
@@ -618,11 +621,7 @@ export async function appendEventAsync(eventsPath: string, event: AppendTeamEven
|
|
|
618
621
|
// can never roll the sidecar back either.
|
|
619
622
|
if (baseMetadata?.seq !== undefined) persistSequenceMonotonic(eventsPath, seq);
|
|
620
623
|
}
|
|
621
|
-
// FIND-10: track whether compaction happened after the append so the
|
|
622
|
-
// cache-update stat can safely reuse postAppendStat (file unchanged).
|
|
623
|
-
let compactedAfterAppend = false;
|
|
624
624
|
if (tickAppendCounter(eventsPath) && needsRotation(eventsPath)) {
|
|
625
|
-
compactedAfterAppend = true;
|
|
626
625
|
try {
|
|
627
626
|
const prepared = prepareCompaction(eventsPath);
|
|
628
627
|
if (prepared) applyCompactionUnlocked(eventsPath, prepared);
|
|
@@ -641,39 +640,13 @@ export async function appendEventAsync(eventsPath: string, event: AppendTeamEven
|
|
|
641
640
|
}
|
|
642
641
|
}
|
|
643
642
|
|
|
644
|
-
//
|
|
645
|
-
//
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
let statResult: fs.Stats | undefined;
|
|
652
|
-
if (postAppendStat && !compactedAfterAppend) {
|
|
653
|
-
statResult = postAppendStat;
|
|
654
|
-
} else {
|
|
655
|
-
try {
|
|
656
|
-
statResult = await fs.promises.stat(eventsPath).catch(() => undefined);
|
|
657
|
-
} catch {
|
|
658
|
-
/* file may not exist */
|
|
659
|
-
}
|
|
660
|
-
}
|
|
661
|
-
if (statResult) {
|
|
662
|
-
if (sequenceCache.size >= MAX_SEQUENCE_CACHE_ENTRIES) {
|
|
663
|
-
evictOldestSequenceCacheEntries();
|
|
664
|
-
}
|
|
665
|
-
sequenceCache.set(eventsPath, {
|
|
666
|
-
size: statResult.size,
|
|
667
|
-
mtimeMs: statResult.mtimeMs,
|
|
668
|
-
seq: finalSeq,
|
|
669
|
-
lastAccessMs: Date.now(),
|
|
670
|
-
});
|
|
671
|
-
}
|
|
672
|
-
// Note: persistSequence is NOT called here again - it was already called
|
|
673
|
-
// after the append to ensure the sidecar is current after the event is written.
|
|
674
|
-
} catch (error) {
|
|
675
|
-
logInternalError("event-log.persist-sequence", error, `eventsPath=${eventsPath}`);
|
|
676
|
-
}
|
|
643
|
+
// PERF (2026-08-24): the per-append sequenceCache upkeep that lived here
|
|
644
|
+
// (post-append stat + Map set + occasional O(n log n) evict sort) fed no
|
|
645
|
+
// hot reader — sequenceCache is read only by nextSequence, which is
|
|
646
|
+
// consulted solely by seeding/test helpers (see the ST-12 note in
|
|
647
|
+
// sequence-cache.ts); all three append paths allocate seqs via
|
|
648
|
+
// reserveSequence, which reads the .seq sidecar + seqCounters instead.
|
|
649
|
+
// nextSequence() re-seeds via its sidecar/scan fallback when called.
|
|
677
650
|
return fullEvent;
|
|
678
651
|
};
|
|
679
652
|
// C-01: Two-tier lock — asyncQueues (in-process serialize) →
|
|
@@ -726,11 +699,13 @@ export async function appendEventAsync(eventsPath: string, event: AppendTeamEven
|
|
|
726
699
|
/**
|
|
727
700
|
* Batch variant used by the buffered flush path. Computes metadata for each
|
|
728
701
|
* event, writes the whole batch in a single appendFileSync + fsync, persists
|
|
729
|
-
* the sequence sidecar once with the last seq
|
|
730
|
-
*
|
|
731
|
-
*
|
|
732
|
-
* the
|
|
733
|
-
*
|
|
702
|
+
* the sequence sidecar once with the last seq (no per-append sequenceCache
|
|
703
|
+
* upkeep — that cache no longer exists on the append paths; see the PERF
|
|
704
|
+
* 2026-08-24 note in appendEventAsync). Resolves each item with its finalized
|
|
705
|
+
* event (carrying the assigned seq). This collapses N fsyncs into 1 for the
|
|
706
|
+
* buffered write path, which is the entire point of buffering — the previous
|
|
707
|
+
* per-event fsync made buffer coalescing useless and added ~30ms/event on
|
|
708
|
+
* tmpfs.
|
|
734
709
|
*/
|
|
735
710
|
async function appendEventBatchInsideLock(eventsPath: string, queue: BufferedAppend[]): Promise<void> {
|
|
736
711
|
if (queue.length === 0) return;
|
|
@@ -738,12 +713,23 @@ async function appendEventBatchInsideLock(eventsPath: string, queue: BufferedApp
|
|
|
738
713
|
tickAppendCounter(eventsPath, queue.length);
|
|
739
714
|
fs.mkdirSync(path.dirname(eventsPath), { recursive: true });
|
|
740
715
|
|
|
716
|
+
// PERF (2026-08-24): one hoisted pre-append stat replaces the pre-flight
|
|
717
|
+
// existsSync+statSync pair below. The after-compaction re-checks further
|
|
718
|
+
// down keep their own fresh stats — compaction/rotation may have changed
|
|
719
|
+
// the file since preStat was taken, so they must not see a stale
|
|
720
|
+
// (pre-compaction) size.
|
|
721
|
+
let preStat: fs.Stats | undefined;
|
|
722
|
+
try {
|
|
723
|
+
preStat = fs.statSync(eventsPath);
|
|
724
|
+
} catch {
|
|
725
|
+
/* log absent — first append */
|
|
726
|
+
}
|
|
727
|
+
|
|
741
728
|
// Pre-flight size check (mirrors appendEventInsideLock). We do it once for
|
|
742
729
|
// the batch instead of once per event.
|
|
743
730
|
try {
|
|
744
|
-
if (
|
|
745
|
-
|
|
746
|
-
if (stat.size > MAX_EVENTS_BYTES) {
|
|
731
|
+
if (preStat) {
|
|
732
|
+
if (preStat.size > MAX_EVENTS_BYTES) {
|
|
747
733
|
try {
|
|
748
734
|
const prepared = prepareCompaction(eventsPath);
|
|
749
735
|
if (prepared) applyCompactionUnlocked(eventsPath, prepared);
|
|
@@ -774,8 +760,8 @@ async function appendEventBatchInsideLock(eventsPath: string, queue: BufferedApp
|
|
|
774
760
|
// then increment locally for each subsequent event in the batch. Calling
|
|
775
761
|
// nextSequence() per-event would re-read file stat/sidecar with no writes
|
|
776
762
|
// in between — every call would see the same file state and return the same
|
|
777
|
-
// seq, breaking the "unique monotonic seq" contract.
|
|
778
|
-
//
|
|
763
|
+
// seq, breaking the "unique monotonic seq" contract. persistSequenceMonotonic
|
|
764
|
+
// at the end refreshes the sidecar to the last assigned seq.
|
|
779
765
|
// B7: use reserveSequence for atomic seq assignment across all paths.
|
|
780
766
|
// R16-B1 (Phase 3.6): reserve the WHOLE batch range (count = queue.length)
|
|
781
767
|
// under the .seqlock in one acquire — the locally incremented nextSeq below
|
|
@@ -816,6 +802,13 @@ async function appendEventBatchInsideLock(eventsPath: string, queue: BufferedApp
|
|
|
816
802
|
finalized.push({ item, line: `${JSON.stringify(redactSecrets(fullEvent))}\n`, fullEvent });
|
|
817
803
|
lastSeq = seq;
|
|
818
804
|
}
|
|
805
|
+
// PERF (2026-08-24): snapshot the in-process reservation end BEFORE the B7
|
|
806
|
+
// advance below — seqCounters is about to be raised to lastSeq, so reading
|
|
807
|
+
// reservedSequenceEnd() at the persist site would ALWAYS see lastSeq ≤
|
|
808
|
+
// counter and skip the persist even for explicit seqs the reservation never
|
|
809
|
+
// covered (sidecar would lag the file → cross-process re-reservation inside
|
|
810
|
+
// the file's true range — the exact R16-B1 duplicate-seq race).
|
|
811
|
+
const reservedEnd = reservedSequenceEnd(eventsPath);
|
|
819
812
|
// B7: advance counter past the entire batch so next reserveSequence returns the correct value.
|
|
820
813
|
advanceSequenceCounter(eventsPath, lastSeq);
|
|
821
814
|
|
|
@@ -838,41 +831,56 @@ async function appendEventBatchInsideLock(eventsPath: string, queue: BufferedApp
|
|
|
838
831
|
}
|
|
839
832
|
|
|
840
833
|
fs.appendFileSync(eventsPath, finalized.map((f) => f.line).join(""), "utf-8");
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
834
|
+
// PERF (2026-08-25): skip the fsync when the whole batch is non-terminal.
|
|
835
|
+
// Terminal events never route through this buffer (appendEventBuffered
|
|
836
|
+
// bypasses to appendEvent, which fsyncs itself), so a batch here has no
|
|
837
|
+
// terminal event unless a caller deliberately mixed one in — mirror F3a:
|
|
838
|
+
// the event-reconstructor tolerates an inconsistent tail; the explicit
|
|
839
|
+
// persistSequenceMonotonic below still lands the reserved end range.
|
|
840
|
+
const hasTerminal = finalized.some((f) => TERMINAL_EVENT_TYPES.has(f.fullEvent.type));
|
|
841
|
+
if (hasTerminal) {
|
|
842
|
+
const fd = fs.openSync(eventsPath, "r+");
|
|
843
|
+
try {
|
|
844
|
+
fs.fsyncSync(fd);
|
|
845
|
+
} catch {
|
|
846
|
+
// EPERM on Windows CI: best-effort flush
|
|
847
|
+
} finally {
|
|
848
|
+
fs.closeSync(fd);
|
|
849
|
+
}
|
|
848
850
|
}
|
|
849
851
|
// R16-B1 (Phase 3.6): monotonic persist under the .seqlock — the reservation
|
|
850
852
|
// already persisted the batch range end; this covers explicit (pre-assigned)
|
|
851
853
|
// seqs that may exceed it and can never REGRESS the sidecar (the old bare
|
|
852
854
|
// persistSequence could write a lower lastSeq over a higher reserved value).
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
//
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
sequenceCache.set(eventsPath, {
|
|
862
|
-
size: stat.size,
|
|
863
|
-
mtimeMs: stat.mtimeMs,
|
|
864
|
-
seq: lastSeq,
|
|
865
|
-
lastAccessMs: Date.now(),
|
|
866
|
-
});
|
|
867
|
-
} catch (error) {
|
|
868
|
-
logInternalError("event-log.batch-cache-update", error, `eventsPath=${eventsPath}`);
|
|
855
|
+
// PERF (2026-08-24): R16-B1 advance-on-reserve already persisted the
|
|
856
|
+
// reserved end inside the .seqlock at reservation time. Re-acquiring
|
|
857
|
+
// the seqlock (~12 syscalls) to conclude "no write needed" is pure
|
|
858
|
+
// overhead in the single-writer common case — skip when lastSeq is
|
|
859
|
+
// covered by the reservation snapshot above; explicit seqs beyond the
|
|
860
|
+
// reserved range still persist.
|
|
861
|
+
if (lastSeq > reservedEnd) {
|
|
862
|
+
persistSequenceMonotonic(eventsPath, lastSeq);
|
|
869
863
|
}
|
|
870
864
|
|
|
865
|
+
// Phase 3: resolve all promises. (PERF 2026-08-24: the sequenceCache upkeep
|
|
866
|
+
// that used to live here fed no hot reader — see the note in
|
|
867
|
+
// appendEventAsync; nextSequence re-seeds via its sidecar/scan fallback.)
|
|
871
868
|
for (const { item, fullEvent } of finalized) item.resolve(fullEvent);
|
|
872
869
|
}
|
|
873
870
|
|
|
874
871
|
function appendEventInsideLock(eventsPath: string, event: AppendTeamEvent): TeamEvent {
|
|
875
872
|
fs.mkdirSync(path.dirname(eventsPath), { recursive: true });
|
|
873
|
+
// PERF (2026-08-24): one hoisted pre-append stat replaces the overflow
|
|
874
|
+
// existsSync+statSync pair below. The after-compaction re-checks further
|
|
875
|
+
// down keep their own fresh stats — compaction/rotation may have changed
|
|
876
|
+
// the file since preStat was taken, so they must not see a stale
|
|
877
|
+
// (pre-compaction) size.
|
|
878
|
+
let preStat: fs.Stats | undefined;
|
|
879
|
+
try {
|
|
880
|
+
preStat = fs.statSync(eventsPath);
|
|
881
|
+
} catch {
|
|
882
|
+
/* log absent — first append */
|
|
883
|
+
}
|
|
876
884
|
const baseMetadata = event.metadata;
|
|
877
885
|
// B7: use reserveSequence for atomic seq assignment across all paths.
|
|
878
886
|
const explicitSeq = baseMetadata?.seq;
|
|
@@ -909,9 +917,8 @@ function appendEventInsideLock(eventsPath: string, event: AppendTeamEvent): Team
|
|
|
909
917
|
// 3. After compact, if still over limit, rotate.
|
|
910
918
|
const isTerminal = TERMINAL_EVENT_TYPES.has(fullEvent.type);
|
|
911
919
|
let skippedDueToSize = false;
|
|
912
|
-
if (!isTerminal &&
|
|
913
|
-
|
|
914
|
-
if (stat.size > MAX_EVENTS_BYTES) {
|
|
920
|
+
if (!isTerminal && preStat) {
|
|
921
|
+
if (preStat.size > MAX_EVENTS_BYTES) {
|
|
915
922
|
// Try immediate compact (not waiting for counter % 100).
|
|
916
923
|
// Round 24 (BUG 1): we are INSIDE withEventLogLockSync. Use the unlocked
|
|
917
924
|
// apply/rotate cores — the locked variants would deadlock (mkdir lock
|
|
@@ -967,7 +974,8 @@ function appendEventInsideLock(eventsPath: string, event: AppendTeamEvent): Team
|
|
|
967
974
|
} catch (error) {
|
|
968
975
|
logInternalError("event-log.size-check", error, `eventsPath=${eventsPath}`);
|
|
969
976
|
}
|
|
970
|
-
// seq is already computed above via reserveSequence — reuse it for
|
|
977
|
+
// seq is already computed above via reserveSequence — reuse it for the
|
|
978
|
+
// explicit-seq persist below (no sequenceCache upkeep on append paths).
|
|
971
979
|
// const seq declaration removed (B7: seq is now computed before metadata object).
|
|
972
980
|
if (!skippedDueToSize) {
|
|
973
981
|
fs.appendFileSync(eventsPath, `${JSON.stringify(redactSecrets(fullEvent))}\n`, "utf-8");
|
|
@@ -997,24 +1005,11 @@ function appendEventInsideLock(eventsPath: string, event: AppendTeamEvent): Team
|
|
|
997
1005
|
// Only EXPLICIT (pre-assigned) seqs bypassed the reservation — persist
|
|
998
1006
|
// those, monotonically and under the .seqlock.
|
|
999
1007
|
if (explicitSeq !== undefined) persistSequenceMonotonic(eventsPath, seq);
|
|
1000
|
-
//
|
|
1001
|
-
//
|
|
1002
|
-
//
|
|
1003
|
-
//
|
|
1004
|
-
|
|
1005
|
-
const stat = fs.statSync(eventsPath);
|
|
1006
|
-
if (sequenceCache.size >= MAX_SEQUENCE_CACHE_ENTRIES) {
|
|
1007
|
-
evictOldestSequenceCacheEntries();
|
|
1008
|
-
}
|
|
1009
|
-
sequenceCache.set(eventsPath, {
|
|
1010
|
-
size: stat.size,
|
|
1011
|
-
mtimeMs: stat.mtimeMs,
|
|
1012
|
-
seq,
|
|
1013
|
-
lastAccessMs: Date.now(),
|
|
1014
|
-
});
|
|
1015
|
-
} catch (error) {
|
|
1016
|
-
logInternalError("event-log.persist-sequence", error, `eventsPath=${eventsPath}`);
|
|
1017
|
-
}
|
|
1008
|
+
// PERF (2026-08-24): the per-append sequenceCache upkeep that lived here
|
|
1009
|
+
// (post-append stat + Map set + occasional evict sort) fed no hot reader —
|
|
1010
|
+
// see the note in appendEventAsync. The .seq sidecar is already current
|
|
1011
|
+
// (advance-on-reserve / persistSequenceMonotonic above), and nextSequence()
|
|
1012
|
+
// re-seeds via its sidecar/scan fallback when called.
|
|
1018
1013
|
}
|
|
1019
1014
|
if (tickAppendCounter(eventsPath) && needsRotation(eventsPath)) {
|
|
1020
1015
|
// Round 24 (BUG 1): we are INSIDE withEventLogLockSync here (called via
|
|
@@ -1190,6 +1185,18 @@ export function flushBufferedQueuesSync(): void {
|
|
|
1190
1185
|
for (const eventsPath of [...bufferedTimers.keys()]) bufferedTimers.delete(eventsPath);
|
|
1191
1186
|
}
|
|
1192
1187
|
|
|
1188
|
+
// Exported only for the buffered-batch unit tests. NOT part of the public API —
|
|
1189
|
+
// the `__test__` prefix follows the project convention used in atomic-write.ts,
|
|
1190
|
+
// state-store.ts, team-runner.ts, etc. `appendEventBuffered` rejects terminal
|
|
1191
|
+
// events BEFORE they reach the batch (see appendEventBuffered), so a batch that
|
|
1192
|
+
// deliberately mixes in a terminal event is unreachable through the public API;
|
|
1193
|
+
// this hook lets T2 assert the fsync gate directly. Both production call paths
|
|
1194
|
+
// (async flush via withEventLogLockAsync and the EL-2 sync flush via
|
|
1195
|
+
// withEventLogLockSync) route through appendEventBatchInsideLock.
|
|
1196
|
+
export function __test__appendBatchForUnitTest(eventsPath: string, queue: BufferedAppend[]): Promise<void> {
|
|
1197
|
+
return appendEventBatchInsideLock(eventsPath, queue);
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1193
1200
|
/**
|
|
1194
1201
|
* Schedule an async event append without waiting for the result.
|
|
1195
1202
|
* Uses the non-blocking async queue to avoid blocking the event loop.
|
|
@@ -221,10 +221,20 @@ function withSeqLock<T>(eventsPath: string, fn: () => T): T {
|
|
|
221
221
|
try {
|
|
222
222
|
fs.mkdirSync(lockDir);
|
|
223
223
|
try {
|
|
224
|
-
//
|
|
225
|
-
|
|
224
|
+
// PERF (2026-08-24): "wx" (O_CREAT|O_EXCL) — fails rather than
|
|
225
|
+
// following a planted symlink, so no O_NOFOLLOW/temp/rename
|
|
226
|
+
// ceremony needed for this disposable, mtime-stale-detected
|
|
227
|
+
// 4-byte file. We own the lock dir (we just mkdir'd it), so
|
|
228
|
+
// EEXIST means a crashed holder's leftover under OUR fresh dir
|
|
229
|
+
// or an attack — either way, skip: the dir itself is the mutex.
|
|
230
|
+
const fd = fs.openSync(pidFile, "wx");
|
|
231
|
+
try {
|
|
232
|
+
fs.writeSync(fd, String(process.pid));
|
|
233
|
+
} finally {
|
|
234
|
+
fs.closeSync(fd);
|
|
235
|
+
}
|
|
226
236
|
} catch {
|
|
227
|
-
/* best-effort */
|
|
237
|
+
/* best-effort — see withEventLogLockAsync note */
|
|
228
238
|
}
|
|
229
239
|
acquired = true;
|
|
230
240
|
} catch {
|
|
@@ -286,6 +296,14 @@ export function persistSequenceMonotonic(eventsPath: string, seq: number): void
|
|
|
286
296
|
});
|
|
287
297
|
}
|
|
288
298
|
|
|
299
|
+
/** PERF (2026-08-24): callers that just reserved a range can skip the lock+read
|
|
300
|
+
* sidecar round-trip in persistSequenceMonotonic when their value is already
|
|
301
|
+
* covered by the in-process reservation (R16-B1 advance-on-reserve persisted it
|
|
302
|
+
* inside the .seqlock at reservation time). */
|
|
303
|
+
export function reservedSequenceEnd(eventsPath: string): number {
|
|
304
|
+
return seqCounters.get(eventsPath) ?? 0;
|
|
305
|
+
}
|
|
306
|
+
|
|
289
307
|
// B7: single in-process monotonic sequence counter per eventsPath. The three
|
|
290
308
|
// append paths — sync appendEvent (withEventLogLockSync file lock), buffered
|
|
291
309
|
// flush (asyncLocks promise chain), and direct appendEventAsync (asyncQueues
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
import * as fs from "node:fs";
|
|
27
27
|
import * as path from "node:path";
|
|
28
28
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
29
|
-
import { atomicWriteJson
|
|
29
|
+
import { atomicWriteJson } from "../atomic-write.ts";
|
|
30
30
|
import { withRunLockSync } from "../coordination/locks.ts";
|
|
31
31
|
import type { TeamRunManifest } from "../types.ts";
|
|
32
32
|
|
|
@@ -100,9 +100,10 @@ export function readOwnershipMap(manifest: TeamRunManifest): OwnershipMapFile {
|
|
|
100
100
|
export function upsertOwnershipEntry(manifest: TeamRunManifest, entry: OwnershipEntry): void {
|
|
101
101
|
try {
|
|
102
102
|
withRunLockSync(manifest, () => {
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
|
|
103
|
+
// REVIEW FIX (2026-09-10): reverted WI-2.2's coalesced conversion (and
|
|
104
|
+
// its flush-before-reload guard). The map is small, written at spawn /
|
|
105
|
+
// steer frequency (not a hot loop), and round-trip readers expect
|
|
106
|
+
// read-your-writes immediately after upsert returns.
|
|
106
107
|
const fresh = readOwnershipMap(manifest);
|
|
107
108
|
const prev = fresh.entries[entry.taskId] ?? {};
|
|
108
109
|
const merged: OwnershipEntry = {
|
|
@@ -74,6 +74,13 @@ export function getCurrentPlanRecord(manifest: TeamRunManifest): PlanRecord | un
|
|
|
74
74
|
function writePlanFile(manifest: TeamRunManifest, revisions: PlanRecord[]): void {
|
|
75
75
|
const file: PlanFile = { version: 1, revisions };
|
|
76
76
|
fs.mkdirSync(path.dirname(planFilePath(manifest)), { recursive: true });
|
|
77
|
+
// REVIEW FIX (2026-09-10): reverted WI-2.2's coalesced conversion.
|
|
78
|
+
// `loadPlanRecords` is the PUBLIC read API and every caller expects
|
|
79
|
+
// read-your-writes (getCurrentPlanRecord, plan-replan, handleStatus); a
|
|
80
|
+
// 50ms coalesce window made the mutators' own reload-inside-lock read
|
|
81
|
+
// stale state (lost-update) and broke plan-store.test.ts 11/15. Plan
|
|
82
|
+
// writes are low-frequency (revision append / linkage / approval) — the
|
|
83
|
+
// coalesce win is negligible. Same exclusion rule as status.ts A2/A4.
|
|
77
84
|
atomicWriteJson(planFilePath(manifest), file);
|
|
78
85
|
}
|
|
79
86
|
|
|
@@ -141,6 +148,10 @@ export function appendPlanRevision(manifest: TeamRunManifest, record: PlanRecord
|
|
|
141
148
|
revisions.push(record);
|
|
142
149
|
writePlanFile(manifest, revisions);
|
|
143
150
|
const dropped = record.items.filter((i) => i.status === "dropped").length;
|
|
151
|
+
// REVIEW FIX (2026-09-10): reverted M2b buffered conversion — plan lifecycle
|
|
152
|
+
// events are low-frequency and consumers (plan-store tests, status display)
|
|
153
|
+
// expect read-your-writes; also removes the policy-A catch (sync errors
|
|
154
|
+
// propagate to the producer inside the lock, as at base).
|
|
144
155
|
appendEvent(manifest.eventsPath, {
|
|
145
156
|
type: record.version === 1 ? "plan.created" : "plan.revised",
|
|
146
157
|
runId: manifest.runId,
|
|
@@ -199,6 +210,7 @@ export function setPlanApproval(
|
|
|
199
210
|
// ADR-4 §9: events for approval MUTATIONS. `pending` emits none — the
|
|
200
211
|
// request surface (ensurePlanApprovalRequested) appends its own
|
|
201
212
|
// plan.approval_required event.
|
|
213
|
+
// REVIEW FIX (2026-09-10): reverted M2b buffered conversion — see above.
|
|
202
214
|
if (approval.status !== "pending") {
|
|
203
215
|
appendEvent(manifest.eventsPath, {
|
|
204
216
|
type: approval.status === "approved" ? "plan.approved" : "plan.rejected",
|
|
@@ -10,6 +10,7 @@ import { findRepoRoot, projectCrewRoot, userCrewRoot } from "../../utils/paths.t
|
|
|
10
10
|
import { assertSafePathId, resolveContainedRelativePath, resolveRealContainedPath } from "../../utils/safe-paths.ts";
|
|
11
11
|
import { toPiSessionId } from "../../utils/session-utils.ts";
|
|
12
12
|
import type { WorkflowConfig } from "../../workflows/workflow-config.ts";
|
|
13
|
+
import type { WriteDurability } from "../atomic-write.ts";
|
|
13
14
|
import {
|
|
14
15
|
atomicWriteJson,
|
|
15
16
|
atomicWriteJsonAsync,
|
|
@@ -207,6 +208,26 @@ function resolveRunStateRoot(cwd: string, runId: string): string | undefined {
|
|
|
207
208
|
return scopedPath;
|
|
208
209
|
}
|
|
209
210
|
|
|
211
|
+
// PERF (2026-08-24): the artifacts containment verdict (existsSync + lstat +
|
|
212
|
+
// resolveRealContainedPath ≈ 10-25 syscalls) cannot change for a run dir that
|
|
213
|
+
// is not replaced — mirror the P1-12 runStateRootCache tradeoff: positive
|
|
214
|
+
// verdicts cached 10s, negatives never cached (a newly created artifacts dir
|
|
215
|
+
// must be found promptly). A stale positive is safe the same way P1-12 is:
|
|
216
|
+
// downstream manifest stat catches deleted runs, and any write through
|
|
217
|
+
// atomic-write re-runs isSymlinkSafeDirCached independently.
|
|
218
|
+
const artifactsVerdictCache = new Map<string, { expiresAt: number }>();
|
|
219
|
+
const ARTIFACTS_VERDICT_TTL_MS = 10_000;
|
|
220
|
+
const ARTIFACTS_VERDICT_CACHE_MAX = 256;
|
|
221
|
+
|
|
222
|
+
/** @internal — artifacts verdict cache introspection for unit tests. */
|
|
223
|
+
export function __test__artifactsVerdictCacheSize(): number {
|
|
224
|
+
return artifactsVerdictCache.size;
|
|
225
|
+
}
|
|
226
|
+
/** @internal */
|
|
227
|
+
export function __test__clearArtifactsVerdictCache(): void {
|
|
228
|
+
artifactsVerdictCache.clear();
|
|
229
|
+
}
|
|
230
|
+
|
|
210
231
|
function validateRunManifestPaths(cwd: string, runId: string, manifest: TeamRunManifest, stateRoot: string, tasksPath: string): boolean {
|
|
211
232
|
// Issue 2 fix: Reject manifests missing status field to prevent undefined
|
|
212
233
|
// behavior in callers like canTransitionRunStatus(manifest.status, newStatus).
|
|
@@ -221,6 +242,12 @@ function validateRunManifestPaths(cwd: string, runId: string, manifest: TeamRunM
|
|
|
221
242
|
const artifactsParent = path.join(scopeBaseRoot(cwd), DEFAULT_PATHS.state.artifactsSubdir);
|
|
222
243
|
const expectedArtifactsRoot = resolveContainedRelativePath(artifactsParent, runId, "runId");
|
|
223
244
|
if (manifest.artifactsRoot !== expectedArtifactsRoot) return false;
|
|
245
|
+
// PERF (2026-08-24): memoized verdict — see artifactsVerdictCache above.
|
|
246
|
+
// Hit only after the cheap manifest-identity checks above, so a tampered
|
|
247
|
+
// manifest (wrong paths/status) is still rejected without touching the memo.
|
|
248
|
+
const verdictKey = `${cwd}\0${runId}`;
|
|
249
|
+
const cachedVerdict = artifactsVerdictCache.get(verdictKey);
|
|
250
|
+
if (cachedVerdict && cachedVerdict.expiresAt > Date.now()) return true;
|
|
224
251
|
// Always validate artifactsRoot is not a symlink, even when manifest has
|
|
225
252
|
// no artifacts entries. A symlinked artifactsRoot pointing outside the
|
|
226
253
|
// artifacts parent is a security violation (could write to attacker-
|
|
@@ -237,8 +264,18 @@ function validateRunManifestPaths(cwd: string, runId: string, manifest: TeamRunM
|
|
|
237
264
|
} else if (manifest.artifacts && manifest.artifacts.length > 0) {
|
|
238
265
|
// Has artifacts entries but directory doesn't exist - benign state for
|
|
239
266
|
// runs still in progress.
|
|
267
|
+
if (artifactsVerdictCache.size >= ARTIFACTS_VERDICT_CACHE_MAX) {
|
|
268
|
+
const oldest = artifactsVerdictCache.keys().next().value;
|
|
269
|
+
if (oldest !== undefined) artifactsVerdictCache.delete(oldest);
|
|
270
|
+
}
|
|
271
|
+
artifactsVerdictCache.set(verdictKey, { expiresAt: Date.now() + ARTIFACTS_VERDICT_TTL_MS });
|
|
240
272
|
return true;
|
|
241
273
|
}
|
|
274
|
+
if (artifactsVerdictCache.size >= ARTIFACTS_VERDICT_CACHE_MAX) {
|
|
275
|
+
const oldest = artifactsVerdictCache.keys().next().value;
|
|
276
|
+
if (oldest !== undefined) artifactsVerdictCache.delete(oldest);
|
|
277
|
+
}
|
|
278
|
+
artifactsVerdictCache.set(verdictKey, { expiresAt: Date.now() + ARTIFACTS_VERDICT_TTL_MS });
|
|
242
279
|
return true;
|
|
243
280
|
}
|
|
244
281
|
|
|
@@ -465,6 +502,11 @@ export function saveRunManifest(manifest: TeamRunManifest): void {
|
|
|
465
502
|
// which is always safe.
|
|
466
503
|
invalidateRunCache(manifest.stateRoot);
|
|
467
504
|
const manifestPath = path.join(manifest.stateRoot, "manifest.json");
|
|
505
|
+
// REVIEW FIX (2026-09-10): reverted WI-2.2's coalesced conversion —
|
|
506
|
+
// saveRunManifest is a SYNCHRONOUS persist by name/contract (tests assert
|
|
507
|
+
// it, broker loadRunManifestById is a cross-process reader, and the
|
|
508
|
+
// statSync-based cache repopulation below needs the real post-write
|
|
509
|
+
// mtime/size). The 50ms coalesce window broke all three.
|
|
468
510
|
atomicWriteJson(manifestPath, manifest);
|
|
469
511
|
// FIX: Re-populate cache with actual mtime/size so loadRunManifestById
|
|
470
512
|
// doesn't miss the cache on next read. Without this, every load until
|
|
@@ -624,19 +666,52 @@ export function saveRunTasks(manifest: TeamRunManifest, tasks: TeamTaskState[]):
|
|
|
624
666
|
* tasks.json stale (showing "running") while events.jsonl already shows
|
|
625
667
|
* the terminal event, causing false zombie detection / double-execution
|
|
626
668
|
* on crash recovery.
|
|
669
|
+
*
|
|
670
|
+
* PERF round 2, Task 3: optional `durability` (default "full") overrides the
|
|
671
|
+
* coalesced entry's durability. When the caller wants a non-terminal checkpoint
|
|
672
|
+
* written without fsync (persistence.skipTasksFsync opt-in), pass
|
|
673
|
+
* `durability: "best-effort"` — the coalesced entry stores it and the flush
|
|
674
|
+
* forwards it to atomicWriteFile (see atomic-write.ts:997). Terminal
|
|
675
|
+
* transitions (skipCoalesce=true) always fall to atomicWriteJson with full
|
|
676
|
+
* durability regardless of this param.
|
|
627
677
|
*/
|
|
628
678
|
/** @internal */
|
|
629
|
-
export function saveRunTasksCoalesced(
|
|
679
|
+
export function saveRunTasksCoalesced(
|
|
680
|
+
manifest: TeamRunManifest,
|
|
681
|
+
tasks: TeamTaskState[],
|
|
682
|
+
skipCoalesce: boolean = false,
|
|
683
|
+
durability: WriteDurability = "full",
|
|
684
|
+
): void {
|
|
630
685
|
// ST-4: refuse to persist [] over a previously-non-empty tasks file.
|
|
631
686
|
if (!shouldPersistTasks(manifest, tasks)) return;
|
|
632
|
-
//
|
|
633
|
-
|
|
687
|
+
// PERF (2026-08-24, Task 12): invalidating the WHOLE entry made every
|
|
688
|
+
// loadRunManifestById after a coalesced save re-read + re-parse
|
|
689
|
+
// manifest.json (24KB+) even though the manifest file did not change —
|
|
690
|
+
// persistSingleTaskUpdate's next call (~500ms later) always paid it. Keep
|
|
691
|
+
// the manifest half of the entry and zero only the tasks stamps, which is
|
|
692
|
+
// the exact pre-existing signal for "tasks on disk may be stale" (coalesced
|
|
693
|
+
// write not landed yet). The load path cooperates: the zeroed tasks stamps
|
|
694
|
+
// force the slow path (tasks are always re-read from disk), but when the
|
|
695
|
+
// retained manifest stamps still match a fresh stat of manifest.json — the
|
|
696
|
+
// same mtime/size verification the fast path performs — the retry loop
|
|
697
|
+
// reuses the cached manifest object and skips the re-read + re-parse.
|
|
698
|
+
// Crash safety is unchanged: a zeroed tasks stamp can only cause a miss,
|
|
699
|
+
// never a stale hit, and a concurrent manifest rewrite changes mtime/size
|
|
700
|
+
// so the manifest reuse never serves stale content. Generation semantics
|
|
701
|
+
// unchanged — setManifestCache stamps the CURRENT generation, so a
|
|
702
|
+
// concurrent writer's bump still invalidates us.
|
|
703
|
+
const cached = manifestCache.get(manifest.stateRoot);
|
|
704
|
+
if (cached) {
|
|
705
|
+
setManifestCache(manifest.stateRoot, { ...cached, tasks, tasksMtimeMs: 0, tasksSize: 0 });
|
|
706
|
+
} else {
|
|
707
|
+
invalidateRunCache(manifest.stateRoot);
|
|
708
|
+
}
|
|
634
709
|
try {
|
|
635
710
|
fs.statSync(manifest.stateRoot);
|
|
636
711
|
} catch {
|
|
637
712
|
return;
|
|
638
713
|
}
|
|
639
|
-
atomicWriteJsonCoalesced(manifest.tasksPath, tasks, undefined, { compact: true }, skipCoalesce);
|
|
714
|
+
atomicWriteJsonCoalesced(manifest.tasksPath, tasks, undefined, { compact: true, durability }, skipCoalesce);
|
|
640
715
|
}
|
|
641
716
|
|
|
642
717
|
export async function saveRunTasksAsync(manifest: TeamRunManifest, tasks: TeamTaskState[]): Promise<void> {
|
|
@@ -943,7 +1018,19 @@ export function loadRunManifestById(cwd: string, runId: string): { manifest: Tea
|
|
|
943
1018
|
let tasks: TeamTaskState[] | undefined;
|
|
944
1019
|
while (attempts < LOAD_MANIFEST_RETRY_LIMIT) {
|
|
945
1020
|
const freshStat = fs.statSync(manifestPath);
|
|
946
|
-
|
|
1021
|
+
// PERF (2026-08-24, Task 12 realized): after saveRunTasksCoalesced the
|
|
1022
|
+
// cache keeps the manifest half of the entry while zeroing only the
|
|
1023
|
+
// tasks stamps, so this slow path runs solely to refresh tasks. When the
|
|
1024
|
+
// retained manifest stamps still match the fresh stat — the exact
|
|
1025
|
+
// mtime/size verification the fast path performs — reuse the cached
|
|
1026
|
+
// manifest object instead of re-reading + re-parsing manifest.json.
|
|
1027
|
+
// Stamp verification is NOT bypassed: any manifest.json rewrite changes
|
|
1028
|
+
// mtime/size and falls back to the disk read. The tasks re-read below
|
|
1029
|
+
// still always happens — zeroed tasks stamps must force it.
|
|
1030
|
+
manifest =
|
|
1031
|
+
cached && cached.manifestMtimeMs === freshStat.mtimeMs && cached.manifestSize === freshStat.size
|
|
1032
|
+
? cached.manifest
|
|
1033
|
+
: readJsonFile<TeamRunManifest>(manifestPath);
|
|
947
1034
|
const freshTasksStat = fs.existsSync(tasksPath) ? fs.statSync(tasksPath) : undefined;
|
|
948
1035
|
tasks = loadTasksWithRecovery(tasksPath, manifest?.eventsPath ?? path.join(stateRoot, "events.jsonl"), manifest?.runId ?? runId);
|
|
949
1036
|
// If size/mtime didn't change between stat and read, we're consistent.
|
|
@@ -1085,7 +1172,17 @@ export async function loadRunManifestByIdAsync(
|
|
|
1085
1172
|
let attempts = 0;
|
|
1086
1173
|
while (attempts < LOAD_MANIFEST_RETRY_LIMIT) {
|
|
1087
1174
|
const freshStat = await fs.promises.stat(manifestPath);
|
|
1088
|
-
|
|
1175
|
+
// PERF (2026-08-24, Task 12 realized): async twin of the sync reuse —
|
|
1176
|
+
// after saveRunTasksCoalesced only the tasks stamps are zeroed, so when
|
|
1177
|
+
// the retained manifest stamps match the fresh stat (same verification
|
|
1178
|
+
// the fast path performs), reuse the cached manifest object instead of
|
|
1179
|
+
// re-reading + re-parsing manifest.json. A manifest.json rewrite changes
|
|
1180
|
+
// mtime/size and falls back to the disk read; the tasks re-read below
|
|
1181
|
+
// always happens.
|
|
1182
|
+
manifest =
|
|
1183
|
+
cached && cached.manifestMtimeMs === freshStat.mtimeMs && cached.manifestSize === freshStat.size
|
|
1184
|
+
? cached.manifest
|
|
1185
|
+
: await readJsonFileAsync<TeamRunManifest>(manifestPath);
|
|
1089
1186
|
const freshTasksStat = await fs.promises.stat(tasksPath).catch(() => undefined);
|
|
1090
1187
|
tasks = await loadTasksWithRecoveryAsync(
|
|
1091
1188
|
tasksPath,
|