pi-crew 0.11.1 → 0.11.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/CHANGELOG.md +82 -9
- package/README.md +161 -1036
- package/agents/verifier.md +18 -7
- package/dist/index.mjs +742 -91462
- package/docs/README.md +57 -46
- package/docs/architecture.md +87 -33
- package/docs/commands-reference.md +9 -5
- package/docs/troubleshooting.md +3 -2
- package/package.json +5 -4
- package/schema.json +29 -0
- package/skills/real-test-pi-crew/SKILL.md +206 -41
- package/src/agents/agent-config.ts +1 -1
- package/src/agents/discover-agents.ts +1 -1
- package/src/config/config-validation.ts +15 -0
- package/src/config/config.ts +47 -13
- package/src/config/env-vars.ts +35 -0
- package/src/config/types.ts +19 -0
- package/src/errors.ts +2 -2
- package/src/extension/async-notifier.ts +55 -8
- package/src/extension/crew-input-router.ts +9 -2
- package/src/extension/help.ts +21 -10
- package/src/extension/knowledge-injection.ts +2 -1
- package/src/extension/management.ts +8 -3
- package/src/extension/notification-sink.ts +17 -0
- package/src/extension/plan-orchestrate.ts +19 -3
- package/src/extension/registration/command-utils.ts +28 -2
- package/src/extension/registration/commands/dashboard.ts +11 -1
- package/src/extension/registration/commands/manage.ts +31 -15
- package/src/extension/registration/commands/run.ts +24 -2
- package/src/extension/registration/commands/shared.ts +34 -10
- package/src/extension/registration/commands/status.ts +25 -2
- package/src/extension/registration/context-builder.ts +8 -2
- package/src/extension/registration/foreground-run-controller.ts +2 -2
- package/src/extension/registration/health-notify-policy.ts +100 -0
- package/src/extension/registration/lazy-configurers.ts +38 -0
- package/src/extension/registration/lifecycle-handlers.ts +107 -40
- package/src/extension/registration/lifecycle.ts +77 -10
- package/src/extension/registration/observability.ts +125 -53
- package/src/extension/registration/registration-types.ts +7 -5
- package/src/extension/registration/runtime-cleanup.ts +9 -3
- package/src/extension/registration/subagent-helpers.ts +38 -0
- package/src/extension/registration/team-tool.ts +9 -2
- package/src/extension/registration/wire-cross-extension.ts +30 -0
- package/src/extension/run-compare.ts +220 -0
- package/src/extension/run-export.ts +37 -5
- package/src/extension/run-maintenance.ts +155 -5
- package/src/extension/team-manager-command.ts +3 -2
- package/src/extension/team-tool/dispatch/index.ts +3 -2
- package/src/extension/team-tool/dispatch/manage.ts +5 -2
- package/src/extension/team-tool/goal-wrap.ts +7 -0
- package/src/extension/team-tool/goal.ts +16 -1
- package/src/extension/team-tool/handle-settings.ts +19 -2
- package/src/extension/team-tool/health-monitor.ts +21 -7
- package/src/extension/team-tool/lifecycle-actions.ts +49 -1
- package/src/extension/team-tool/parallel-dispatch.ts +16 -1
- package/src/extension/team-tool/plan.ts +10 -0
- package/src/extension/team-tool/routing-hint.ts +63 -0
- package/src/extension/team-tool/run-intent.ts +1 -1
- package/src/extension/team-tool/run.ts +1 -1
- package/src/extension/team-tool/status.ts +4 -0
- package/src/extension/team-tool.ts +52 -6
- package/src/extension/webhook-notify.ts +382 -0
- package/src/observability/metric-sink.ts +12 -2
- package/src/prompt/prompt-runtime.ts +82 -31
- package/src/prompt/worker-events-channel.ts +12 -0
- package/src/runtime/README.md +1 -1
- package/src/runtime/async-runner.ts +123 -1
- package/src/runtime/background-runner.ts +313 -234
- package/src/runtime/broker/crew-broker.ts +76 -71
- package/src/runtime/broker/delegate/delegate-event.ts +12 -8
- package/src/runtime/broker/delegate/shadow-lifecycle.ts +92 -0
- package/src/runtime/broker/protocol/connection-state.ts +10 -0
- package/src/runtime/broker/protocol/lock-busy.ts +76 -0
- package/src/runtime/broker/wait-status-cache.ts +1 -1
- package/src/runtime/child-pi/child-pi-timers.ts +1 -1
- package/src/runtime/child-pi/mock-fixtures.ts +48 -0
- package/src/runtime/crew-agent-records.ts +337 -45
- package/src/runtime/deadletter.ts +43 -1
- package/src/runtime/delegate-spawn.ts +21 -3
- package/src/runtime/deterministic-ast.ts +14 -2
- package/src/runtime/dispatch-batch.ts +72 -5
- package/src/runtime/goal-workflow/dynamic-workflow-context.ts +78 -58
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +14 -2
- package/src/runtime/goal-workflow/goal-loop-runner.ts +105 -4
- package/src/runtime/group-join.ts +11 -3
- package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
- package/src/runtime/live-session/live-session-runtime.ts +1 -1
- package/src/runtime/model/model-fallback.ts +21 -1
- package/src/runtime/model/pi-args.ts +8 -10
- package/src/runtime/model/runtime-warmup.ts +2 -0
- package/src/runtime/output/output-validator.ts +10 -5
- package/src/runtime/plan-approval.ts +14 -1
- package/src/runtime/recovery/crash-recovery.ts +96 -49
- package/src/runtime/run-worker.ts +12 -1
- package/src/runtime/scheduling/global-worker-cap.ts +13 -6
- package/src/runtime/scheduling/run-coalesced-task-group.ts +27 -1
- package/src/runtime/scheduling/scheduler.ts +49 -13
- package/src/runtime/scheduling/semaphore.ts +148 -20
- package/src/runtime/scratchpad/README.md +1 -1
- package/src/runtime/scratchpad/protocol.ts +1 -1
- package/src/runtime/settings-store.ts +1 -1
- package/src/runtime/skill-instructions.ts +22 -0
- package/src/runtime/stale-reconciler.ts +85 -13
- package/src/runtime/subagent-manager.ts +43 -13
- package/src/runtime/task-runner/child-executor.ts +17 -6
- package/src/runtime/task-runner/post-execution.ts +44 -14
- package/src/runtime/task-runner/pre-execution.ts +26 -2
- package/src/runtime/task-runner/prompt-builder.ts +150 -45
- package/src/runtime/task-runner/retrieval-orchestrator.ts +36 -14
- package/src/runtime/task-runner.ts +28 -1
- package/src/runtime/team-runner.ts +42 -2
- package/src/runtime/verification/verification-worktree.ts +3 -0
- package/src/runtime/workspace-lock.ts +4 -1
- package/src/schema/config-schema.ts +14 -0
- package/src/schema/team-tool-schema.ts +17 -0
- package/src/state/atomic-write.ts +53 -0
- package/src/state/contracts.ts +109 -0
- package/src/state/coordination/locks.ts +191 -33
- package/src/state/coordination/mailbox.ts +147 -17
- package/src/state/crew-init.ts +87 -12
- package/src/state/event-log/cursor.ts +37 -1
- package/src/state/event-log/event-log-rotation.ts +72 -7
- package/src/state/stores/active-run-registry.ts +13 -1
- package/src/state/stores/state-store.ts +120 -23
- package/src/state/types.ts +4 -0
- package/src/ui/dashboard-panes/agents-pane.ts +22 -10
- package/src/ui/heartbeat-aggregator.ts +34 -0
- package/src/ui/inline-panel/index.ts +1 -1
- package/src/ui/keybinding-map.ts +22 -4
- package/src/ui/live-conversation-overlay.ts +6 -3
- package/src/ui/render-diff.ts +19 -6
- package/src/ui/run-dashboard.ts +109 -6
- package/src/ui/run-snapshot-cache.ts +27 -4
- package/src/ui/spinner.ts +26 -2
- package/src/ui/tool-progress-formatter.ts +2 -1
- package/src/ui/tool-renderers/brief-mode.ts +2 -1
- package/src/ui/tool-renderers/index.ts +3 -3
- package/src/ui/widget/task-list.ts +8 -5
- package/src/ui/widget/widget-model.ts +3 -1
- package/src/utils/incremental-reader.ts +11 -3
- package/src/utils/paths.ts +94 -12
- package/src/utils/project-markers.ts +40 -0
- package/src/worktree/worktree-manager.ts +206 -26
- package/tsconfig.json +2 -0
- package/workflows/distill.workflow.md +3 -3
- package/workflows/fast-fix.workflow.md +1 -1
- package/workflows/plan-execute.workflow.md +1 -1
- package/workflows/review.workflow.md +1 -1
- package/workflows/strict-fast-fix.workflow.md +1 -1
- package/docs/migration-v0.4-v0.5.md +0 -208
- package/docs/runtime-flow.md +0 -148
- package/src/ui/card-colors.ts +0 -168
- package/src/utils/completion-dedupe.ts +0 -63
- package/src/utils/resolve-shell.ts +0 -36
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import * as fs from "node:fs";
|
|
2
3
|
import * as path from "node:path";
|
|
3
4
|
import { DEFAULT_MAILBOX } from "../../config/defaults.ts";
|
|
@@ -467,19 +468,20 @@ function readAllInboxMessages(manifest: TeamRunManifest): MailboxMessage[] {
|
|
|
467
468
|
// FIND-01: in-process delivery cache to avoid O(N²) re-reads on every append.
|
|
468
469
|
// Keyed by delivery file path; invalidated by mtime check on read + updated on
|
|
469
470
|
// write. Team-runner is single-process so in-process caching is sufficient.
|
|
470
|
-
const deliveryCache = new Map<string, { mtimeMs: number; state: MailboxDeliveryState }>();
|
|
471
|
+
const deliveryCache = new Map<string, { mtimeMs: number; size: number; state: MailboxDeliveryState }>();
|
|
471
472
|
const MAX_DELIVERY_CACHE_ENTRIES = 256;
|
|
472
473
|
// R1 review fix: setDeliveryCacheEntry stores an immutable snapshot (deep
|
|
473
474
|
// copy of `messages`) so callers mutating the returned state cannot corrupt
|
|
474
475
|
// the cache (TOCTOU race), and bounds the map size with FIFO eviction to
|
|
475
476
|
// prevent unbounded growth across runs.
|
|
476
|
-
function setDeliveryCacheEntry(filePath: string, entry: { mtimeMs: number; state: MailboxDeliveryState }): void {
|
|
477
|
+
function setDeliveryCacheEntry(filePath: string, entry: { mtimeMs: number; size: number; state: MailboxDeliveryState }): void {
|
|
477
478
|
if (deliveryCache.size >= MAX_DELIVERY_CACHE_ENTRIES) {
|
|
478
479
|
const oldest = deliveryCache.keys().next().value;
|
|
479
480
|
if (oldest !== undefined) deliveryCache.delete(oldest);
|
|
480
481
|
}
|
|
481
482
|
deliveryCache.set(filePath, {
|
|
482
483
|
mtimeMs: entry.mtimeMs,
|
|
484
|
+
size: entry.size,
|
|
483
485
|
state: { ...entry.state, messages: { ...entry.state.messages } },
|
|
484
486
|
});
|
|
485
487
|
}
|
|
@@ -497,7 +499,7 @@ export function readDeliveryState(manifest: TeamRunManifest): MailboxDeliverySta
|
|
|
497
499
|
return { messages: {}, updatedAt: new Date().toISOString() };
|
|
498
500
|
}
|
|
499
501
|
const cached = deliveryCache.get(filePath);
|
|
500
|
-
if (cached && cached.mtimeMs === stat.mtimeMs) {
|
|
502
|
+
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
|
|
501
503
|
// R2 review fix: return a copy so callers mutating the result cannot
|
|
502
504
|
// leak into the cached snapshot (residual TOCTOU: the cache holds the
|
|
503
505
|
// snapshot until the next write replaces it; without this copy, a
|
|
@@ -517,7 +519,7 @@ export function readDeliveryState(manifest: TeamRunManifest): MailboxDeliverySta
|
|
|
517
519
|
messages,
|
|
518
520
|
updatedAt: typeof obj.updatedAt === "string" ? obj.updatedAt : new Date().toISOString(),
|
|
519
521
|
};
|
|
520
|
-
setDeliveryCacheEntry(filePath, { mtimeMs: stat.mtimeMs, state });
|
|
522
|
+
setDeliveryCacheEntry(filePath, { mtimeMs: stat.mtimeMs, size: stat.size, state });
|
|
521
523
|
return state;
|
|
522
524
|
} catch (error) {
|
|
523
525
|
// NEW-R4: a corrupt delivery.json was previously swallowed silently, returning
|
|
@@ -543,22 +545,146 @@ export function readDeliveryState(manifest: TeamRunManifest): MailboxDeliverySta
|
|
|
543
545
|
}
|
|
544
546
|
}
|
|
545
547
|
|
|
548
|
+
const MAX_DELIVERY_MESSAGES = 10000;
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* F09 (RR-016): an `acknowledged` delivery entry is the ONLY durable record
|
|
552
|
+
* that a message was handled — the inbox line keeps `status: "queued"` forever
|
|
553
|
+
* (nothing writes the ack back to the message), so `replayPendingMailboxMessages`
|
|
554
|
+
* keys on the delivery map. The old prune sorted `queued(0) < delivered(1) <
|
|
555
|
+
* acknowledged(2)` and kept `slice(0, MAX)`, i.e. it evicted ACKNOWLEDGED entries
|
|
556
|
+
* FIRST — the moment the cap was crossed, an acked message became replayable
|
|
557
|
+
* again, and it never self-healed (replay only writes "delivered"), so it was
|
|
558
|
+
* re-delivered on EVERY resume.
|
|
559
|
+
*
|
|
560
|
+
* Fix: the prune may only drop an acknowledged entry when the message it refers
|
|
561
|
+
* to is provably no longer replayable (absent from the whole replayable history:
|
|
562
|
+
* live inbox files + retained archives — the exact set `replayPendingMailboxMessages`
|
|
563
|
+
* reads). Non-acknowledged entries keep the previous eviction semantics
|
|
564
|
+
* (queued → delivered → acknowledged, oldest-inserted first within a tier).
|
|
565
|
+
*
|
|
566
|
+
* Acknowledged entries are tiny (`"<id>":"acknowledged"`), but they must not
|
|
567
|
+
* grow without bound either: a sweep that drops the PROVABLY-dead acks is run
|
|
568
|
+
* once the ack set is large enough to matter, and at most once per
|
|
569
|
+
* ACK_SWEEP_MIN_INTERVAL_MS (the sweep reads the mailbox history, so it is
|
|
570
|
+
* throttled rather than run on every append).
|
|
571
|
+
*/
|
|
572
|
+
const ACK_SWEEP_MIN_ACKS = 1000;
|
|
573
|
+
const ACK_SWEEP_FORCE_ACKS = 5000;
|
|
574
|
+
const ACK_SWEEP_MIN_INTERVAL_MS = 30_000;
|
|
575
|
+
// Bounded FIFO (the asyncAgentReaderCache pattern) so a long-running process
|
|
576
|
+
// cannot accumulate one timestamp per run ever seen.
|
|
577
|
+
const ACK_SWEEP_TIMESTAMP_MAX_ENTRIES = 256;
|
|
578
|
+
const lastAckSweepAt = new Map<string, number>();
|
|
579
|
+
|
|
580
|
+
function recordAckSweep(filePath: string, at: number): void {
|
|
581
|
+
if (lastAckSweepAt.has(filePath)) lastAckSweepAt.delete(filePath);
|
|
582
|
+
lastAckSweepAt.set(filePath, at);
|
|
583
|
+
while (lastAckSweepAt.size > ACK_SWEEP_TIMESTAMP_MAX_ENTRIES) {
|
|
584
|
+
const oldest = lastAckSweepAt.keys().next().value;
|
|
585
|
+
if (oldest === undefined) break;
|
|
586
|
+
lastAckSweepAt.delete(oldest);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** Ids of every message `replayPendingMailboxMessages` could return (live inbox
|
|
591
|
+
* files + retained archives, run-level and per-task). Returns `undefined` when
|
|
592
|
+
* the history could not be READ (fail-closed, review MAJOR 1): an unreadable
|
|
593
|
+
* history must never be conflated with an empty one — the sweep treats an
|
|
594
|
+
* empty set as "every ack is dead" and would delete all acknowledged entries,
|
|
595
|
+
* replaying already-processed messages (the F09 bug class). */
|
|
596
|
+
function collectReplayableInboxIds(manifest: TeamRunManifest): Set<string> | undefined {
|
|
597
|
+
const ids = new Set<string>();
|
|
598
|
+
try {
|
|
599
|
+
for (const message of readAllInboxMessages(manifest)) ids.add(message.id);
|
|
600
|
+
} catch (error) {
|
|
601
|
+
logInternalError("mailbox.collect-replayable-ids", error, `runId=${manifest.runId}`);
|
|
602
|
+
return undefined;
|
|
603
|
+
}
|
|
604
|
+
return ids;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** Drop acknowledged entries whose message can no longer be replayed. Returns
|
|
608
|
+
* the number of entries dropped. Never drops an entry whose message is still
|
|
609
|
+
* in the replayable history. FAIL-CLOSED (review MAJOR 1): if the replayable
|
|
610
|
+
* history cannot be read, the sweep aborts dropping NOTHING and does not
|
|
611
|
+
* consume the throttle window (`recordAckSweep` is only recorded after a
|
|
612
|
+
* successful read), so the next prune retries once the FS is readable. */
|
|
613
|
+
function sweepDeadAcknowledgements(manifest: TeamRunManifest, state: MailboxDeliveryState): number {
|
|
614
|
+
const ackedIds = Object.entries(state.messages)
|
|
615
|
+
.filter(([, status]) => status === "acknowledged")
|
|
616
|
+
.map(([id]) => id);
|
|
617
|
+
if (ackedIds.length === 0) return 0;
|
|
618
|
+
const filePath = deliveryFile(manifest, true);
|
|
619
|
+
const now = Date.now();
|
|
620
|
+
const last = lastAckSweepAt.get(filePath) ?? 0;
|
|
621
|
+
// The sweep reads the whole replayable history, so it is time-throttled:
|
|
622
|
+
// at most once per ACK_SWEEP_MIN_INTERVAL_MS below ACK_SWEEP_FORCE_ACKS,
|
|
623
|
+
// and immediately above it so the ack set cannot grow unbounded.
|
|
624
|
+
const shouldSweep =
|
|
625
|
+
ackedIds.length >= ACK_SWEEP_FORCE_ACKS || (ackedIds.length >= ACK_SWEEP_MIN_ACKS && now - last >= ACK_SWEEP_MIN_INTERVAL_MS);
|
|
626
|
+
if (!shouldSweep) return 0;
|
|
627
|
+
const replayable = collectReplayableInboxIds(manifest);
|
|
628
|
+
if (replayable === undefined) return 0; // unreadable history → abort, keep every ack
|
|
629
|
+
recordAckSweep(filePath, now);
|
|
630
|
+
let dropped = 0;
|
|
631
|
+
for (const id of ackedIds) {
|
|
632
|
+
if (replayable.has(id)) continue;
|
|
633
|
+
delete state.messages[id];
|
|
634
|
+
dropped++;
|
|
635
|
+
}
|
|
636
|
+
return dropped;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function pruneDeliveryMessages(manifest: TeamRunManifest, state: MailboxDeliveryState): void {
|
|
640
|
+
const entries = Object.entries(state.messages);
|
|
641
|
+
const ackedCount = entries.reduce((count, [, status]) => (status === "acknowledged" ? count + 1 : count), 0);
|
|
642
|
+
if (ackedCount > ACK_SWEEP_MIN_ACKS) sweepDeadAcknowledgements(manifest, state);
|
|
643
|
+
const remaining = Object.entries(state.messages);
|
|
644
|
+
if (remaining.length <= MAX_DELIVERY_MESSAGES) return;
|
|
645
|
+
// Stable sort: within a status tier the previous insertion order (which is
|
|
646
|
+
// the order entries were first written) is preserved, so the eviction choice
|
|
647
|
+
// for non-acknowledged entries is unchanged from before this fix.
|
|
648
|
+
const sorted = [...remaining].sort(([, a], [, b]) => {
|
|
649
|
+
const order = { queued: 0, delivered: 1, acknowledged: 2 };
|
|
650
|
+
return (order[a] ?? 3) - (order[b] ?? 3);
|
|
651
|
+
});
|
|
652
|
+
const ackedAfterSweep = sorted.filter(([, status]) => status === "acknowledged").length;
|
|
653
|
+
if (ackedAfterSweep > MAX_DELIVERY_MESSAGES) {
|
|
654
|
+
// Pathological: more acks than the cap and every one of them still refers
|
|
655
|
+
// to a replayable message. Correctness (an acked message must never
|
|
656
|
+
// replay) wins over the memory bound — keep them and make it visible.
|
|
657
|
+
logInternalError(
|
|
658
|
+
"mailbox.delivery-ack-over-cap",
|
|
659
|
+
new Error(`delivery.json holds ${ackedAfterSweep} acknowledged entries for replayable messages (cap ${MAX_DELIVERY_MESSAGES})`),
|
|
660
|
+
`runId=${manifest.runId}`,
|
|
661
|
+
"warn",
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
const keptIds = new Set<string>();
|
|
665
|
+
let evictableBudget = Math.max(0, MAX_DELIVERY_MESSAGES - ackedAfterSweep);
|
|
666
|
+
for (const [id, status] of sorted) {
|
|
667
|
+
if (status === "acknowledged") {
|
|
668
|
+
keptIds.add(id);
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
if (evictableBudget > 0) {
|
|
672
|
+
keptIds.add(id);
|
|
673
|
+
evictableBudget--;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
// Filter the ORIGINAL entry order so surviving entries keep their positions.
|
|
677
|
+
state.messages = Object.fromEntries(remaining.filter(([id]) => keptIds.has(id)));
|
|
678
|
+
}
|
|
679
|
+
|
|
546
680
|
function writeDeliveryState(
|
|
547
681
|
manifest: TeamRunManifest,
|
|
548
682
|
state: MailboxDeliveryState,
|
|
549
683
|
options?: { durability?: "full" | "best-effort" },
|
|
550
684
|
): void {
|
|
551
685
|
ensureRunMailbox(manifest);
|
|
552
|
-
// Prune oldest entries if capped
|
|
553
|
-
|
|
554
|
-
if (Object.keys(state.messages).length > MAX_DELIVERY_MESSAGES) {
|
|
555
|
-
const sorted = Object.entries(state.messages).sort(([, a], [, b]) => {
|
|
556
|
-
const order = { queued: 0, delivered: 1, acknowledged: 2 };
|
|
557
|
-
return (order[a] ?? 3) - (order[b] ?? 3);
|
|
558
|
-
});
|
|
559
|
-
const trimmed = sorted.slice(0, MAX_DELIVERY_MESSAGES);
|
|
560
|
-
state.messages = Object.fromEntries(trimmed);
|
|
561
|
-
}
|
|
686
|
+
// Prune oldest entries if capped (F09: acknowledged entries are protected).
|
|
687
|
+
pruneDeliveryMessages(manifest, state);
|
|
562
688
|
// F4: mailbox delivery is informational — accept losing the very last write on
|
|
563
689
|
// a hard crash (the next message will overwrite it on disk). Cheaper fsync on
|
|
564
690
|
// the hot path; terminal/reply paths still pass full durability below.
|
|
@@ -572,7 +698,7 @@ function writeDeliveryState(
|
|
|
572
698
|
// setDeliveryCacheEntry stores an immutable snapshot (deep copy of
|
|
573
699
|
// messages) so subsequent read-modify-write callers mutating the
|
|
574
700
|
// returned state cannot corrupt the cache.
|
|
575
|
-
setDeliveryCacheEntry(filePath, { mtimeMs: postStat.mtimeMs, state });
|
|
701
|
+
setDeliveryCacheEntry(filePath, { mtimeMs: postStat.mtimeMs, size: postStat.size, state });
|
|
576
702
|
} catch {
|
|
577
703
|
deliveryCache.delete(filePath);
|
|
578
704
|
}
|
|
@@ -601,7 +727,9 @@ export function appendMailboxMessage(
|
|
|
601
727
|
else ensureRunMailbox(manifest);
|
|
602
728
|
const createdAt = new Date().toISOString();
|
|
603
729
|
const complete: MailboxMessage = {
|
|
604
|
-
|
|
730
|
+
// RR-021 WI-4.3j: randomUUID instead of Date.now()+Math.random() —
|
|
731
|
+
// collision-free under the msg_ prefix, no clock-ordering leakage.
|
|
732
|
+
id: message.id ?? `msg_${randomUUID()}`,
|
|
605
733
|
runId: manifest.runId,
|
|
606
734
|
direction: message.direction,
|
|
607
735
|
from: message.from,
|
|
@@ -729,7 +857,9 @@ export async function appendMailboxMessageAsync(
|
|
|
729
857
|
else ensureRunMailbox(manifest);
|
|
730
858
|
const createdAt = new Date().toISOString();
|
|
731
859
|
const complete: MailboxMessage = {
|
|
732
|
-
|
|
860
|
+
// RR-021 WI-4.3j: randomUUID instead of Date.now()+Math.random() —
|
|
861
|
+
// collision-free under the msg_ prefix, no clock-ordering leakage.
|
|
862
|
+
id: message.id ?? `msg_${randomUUID()}`,
|
|
733
863
|
runId: manifest.runId,
|
|
734
864
|
direction: message.direction,
|
|
735
865
|
from: message.from,
|
package/src/state/crew-init.ts
CHANGED
|
@@ -13,10 +13,20 @@
|
|
|
13
13
|
* The `node:path` import is retained as a *fallback* (only used when the
|
|
14
14
|
* binding is healthy). Don't add new dependencies on other pi-crew modules.
|
|
15
15
|
*
|
|
16
|
+
* TWO sanctioned exceptions (RR-020):
|
|
17
|
+
* - `node:os` — a builtin like fs/path, used ONLY inside try/catch so a
|
|
18
|
+
* jiti namespace race degrades to "no home/tmp boundary" (the pre-RR-020
|
|
19
|
+
* walk) instead of crashing ensureCrewDirectory.
|
|
20
|
+
* - `../utils/project-markers.ts` — a zero-import module of plain string
|
|
21
|
+
* literals, so it cannot itself suffer the namespace race and it keeps
|
|
22
|
+
* this resolver from drifting away from `src/utils/paths.ts`.
|
|
23
|
+
*
|
|
16
24
|
* See: https://github.com/baphuongna/pi-crew/issues/28
|
|
17
25
|
*/
|
|
18
26
|
import * as fs from "node:fs";
|
|
27
|
+
import * as os from "node:os";
|
|
19
28
|
import * as path from "node:path";
|
|
29
|
+
import { PROJECT_DIR_MARKERS, PROJECT_FILE_MARKERS } from "../utils/project-markers.ts";
|
|
20
30
|
import { atomicWriteFile } from "./atomic-write.ts";
|
|
21
31
|
import { updateGitignore } from "./gitignore-manager.ts";
|
|
22
32
|
|
|
@@ -164,29 +174,94 @@ function safeResolve(p: string, pathDep?: typeof path): string {
|
|
|
164
174
|
}
|
|
165
175
|
|
|
166
176
|
function findProjectRoot(start: string, pathDep?: typeof path): string | undefined {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
|
|
177
|
+
// Marker lists come from src/utils/project-markers.ts (RR-020 Fix 1) so this
|
|
178
|
+
// resolver can never drift from src/utils/paths.ts:computeRepoRoot again —
|
|
179
|
+
// the drift resolved `parent/subproject` (.pi) to `parent/.crew` here while
|
|
180
|
+
// paths.ts resolved the same cwd to `subproject/.pi/teams` (two roots).
|
|
181
|
+
// The module has NO imports of its own, so it is safe under the jiti
|
|
182
|
+
// namespace race documented at the top of this file.
|
|
183
|
+
// RR-020 hardening (cold-verify round 2): the marker arrays are the ONE
|
|
184
|
+
// critical-path dependency on a static import binding. Under the jiti
|
|
185
|
+
// namespace race (issue #28) a binding can arrive undefined — degrade to the
|
|
186
|
+
// narrow `.git`-only probe (findProjectRoot then returns undefined more
|
|
187
|
+
// often, so computeCrewRoot anchors the crew root at the cwd — the same
|
|
188
|
+
// fallback paths.ts uses) instead of throwing inside ensureCrewDirectory,
|
|
189
|
+
// the very function hardened for #28. Mirrors the defensive style of
|
|
190
|
+
// safeJoin/safeDirname/parseRoot and the lazy updateGitignore import.
|
|
191
|
+
function markerLists(): { dirs: string[]; files: string[] } {
|
|
192
|
+
try {
|
|
193
|
+
if (Array.isArray(PROJECT_DIR_MARKERS) && Array.isArray(PROJECT_FILE_MARKERS)) {
|
|
194
|
+
return { dirs: PROJECT_DIR_MARKERS as string[], files: PROJECT_FILE_MARKERS as string[] };
|
|
195
|
+
}
|
|
196
|
+
} catch {
|
|
197
|
+
// namespace unavailable — fall through to the narrow probe
|
|
198
|
+
}
|
|
199
|
+
return { dirs: [".git"], files: [] };
|
|
200
|
+
}
|
|
201
|
+
const markers = markerLists();
|
|
202
|
+
const hasMarker = (dir: string): boolean =>
|
|
203
|
+
markers.dirs.some((marker) => fs.existsSync(safeJoin(dir, marker))) ||
|
|
204
|
+
markers.files.some((marker) => fs.existsSync(safeJoin(dir, marker)));
|
|
172
205
|
let current = safeResolve(start, pathDep);
|
|
206
|
+
// RR-020 cold-verify follow-up: match findRepoRoot (paths.ts) which
|
|
207
|
+
// realpaths the start BEFORE walking, so the boundary comparisons below
|
|
208
|
+
// compare canonical-to-canonical (macOS /var -> /private/var). Best-effort:
|
|
209
|
+
// ENOENT keeps the lexical path, exactly like findRepoRoot's fallback.
|
|
210
|
+
try {
|
|
211
|
+
current = fs.realpathSync(current);
|
|
212
|
+
} catch {
|
|
213
|
+
// keep the lexical resolution
|
|
214
|
+
}
|
|
215
|
+
// Use `parseRoot` (inlined above) to avoid `path.parse` for the critical
|
|
216
|
+
// termination root — fixes the jiti namespace race in issue #28. Computed
|
|
217
|
+
// from the RESOLVED `current` (post-realpath) so a root-prefix change
|
|
218
|
+
// through a symlink cannot desync `current !== root` (paths.ts computes
|
|
219
|
+
// path.parse on the realpath'd start for the same reason).
|
|
220
|
+
const root = parseRoot(current);
|
|
221
|
+
// RR-020 cold-verify follow-up (bug-029 parity): home/tmp boundary STOP.
|
|
222
|
+
// computeRepoRoot (paths.ts) refuses to check markers at $HOME or the temp
|
|
223
|
+
// root; this walk did not, so once the marker lists were unified onto the
|
|
224
|
+
// wide set, `$HOME/.pi` (created by userPiRoot()) became a marker HERE — a
|
|
225
|
+
// MARKERLESS cwd under $HOME resolved crew-init to $HOME (⇒
|
|
226
|
+
// $HOME/.pi/teams) while projectCrewRoot resolved to <cwd>/.crew: the
|
|
227
|
+
// two-roots bug Fix 1 was meant to kill, in a new shape. `os` access is
|
|
228
|
+
// defensive: under the jiti namespace race (issue #28) the binding can be
|
|
229
|
+
// undefined — degrade to "no boundary" (the pre-RR-020 walk), never crash.
|
|
230
|
+
let home: string | undefined;
|
|
231
|
+
let tempRoot: string | undefined;
|
|
232
|
+
try {
|
|
233
|
+
home = canonicalBoundary(os.homedir());
|
|
234
|
+
tempRoot = canonicalBoundary(os.tmpdir());
|
|
235
|
+
} catch {
|
|
236
|
+
home = undefined;
|
|
237
|
+
tempRoot = undefined;
|
|
238
|
+
}
|
|
239
|
+
const atBoundary = (dir: string): boolean => (home !== undefined && dir === home) || (tempRoot !== undefined && dir === tempRoot);
|
|
173
240
|
// Walk up to find project root
|
|
174
241
|
while (current !== root) {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
for (const marker of fileMarkers) {
|
|
179
|
-
if (fs.existsSync(safeJoin(current, marker))) return current;
|
|
180
|
-
}
|
|
242
|
+
// Stop walking before checking markers at home or temp root.
|
|
243
|
+
if (atBoundary(current)) return undefined;
|
|
244
|
+
if (hasMarker(current)) return current;
|
|
181
245
|
const parent = safeDirname(current);
|
|
182
246
|
if (parent === current) break;
|
|
183
247
|
current = parent;
|
|
184
248
|
}
|
|
185
249
|
// Check root as fallback
|
|
186
|
-
if (
|
|
250
|
+
if (atBoundary(root)) return undefined;
|
|
251
|
+
if (hasMarker(root)) return root;
|
|
187
252
|
return undefined;
|
|
188
253
|
}
|
|
189
254
|
|
|
255
|
+
/** Canonicalize a boundary dir for comparison with the (realpath'd) walk.
|
|
256
|
+
* Best-effort: an unresolvable path stays lexical. */
|
|
257
|
+
function canonicalBoundary(p: string): string {
|
|
258
|
+
try {
|
|
259
|
+
return fs.realpathSync(p);
|
|
260
|
+
} catch {
|
|
261
|
+
return p;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
190
265
|
/**
|
|
191
266
|
* Compute the crew root directory for a given working directory.
|
|
192
267
|
* Matches src/utils/paths.ts:projectCrewRoot() logic.
|
|
@@ -114,6 +114,10 @@ export function readEvents(eventsPath: string): TeamEvent[] {
|
|
|
114
114
|
|
|
115
115
|
export interface EventCursorOptions {
|
|
116
116
|
sinceSeq?: number;
|
|
117
|
+
/** Delivery cap for one read. When `limit` truncates the result, `events` is
|
|
118
|
+
* a PREFIX of everything available — resume from the returned `nextSeq`
|
|
119
|
+
* (never from `nextByteOffset`; see the continuation contract on
|
|
120
|
+
* `readEventsCursor`). */
|
|
117
121
|
limit?: number;
|
|
118
122
|
fromByteOffset?: number;
|
|
119
123
|
/** R-03: generation the caller captured on its previous read. When set, a
|
|
@@ -528,6 +532,30 @@ export function clearEventsCursorTailCache(eventsPath?: string): void {
|
|
|
528
532
|
cursorTailCache.delete(eventsPath);
|
|
529
533
|
}
|
|
530
534
|
|
|
535
|
+
/**
|
|
536
|
+
* Read team events with an optional incremental anchor (`sinceSeq`) plus a
|
|
537
|
+
* delivery cap (`limit`).
|
|
538
|
+
*
|
|
539
|
+
* Continuation / backpressure contract (BR-08):
|
|
540
|
+
* - `nextSeq` is the continuation token for events that CARRY `metadata.seq`
|
|
541
|
+
* (scheduler-emitted events): the max seq among the events DELIVERED by
|
|
542
|
+
* this call — never a watermark past an undelivered event. Resume with
|
|
543
|
+
* `{ sinceSeq: nextSeq }` (`>` comparison) to receive the rest.
|
|
544
|
+
* LIMITS (cold-verify correction — documented, not fixed here): events
|
|
545
|
+
* WITHOUT `metadata.seq` (e.g. the worker progress channel) are filtered
|
|
546
|
+
* out of the sinceSeq path entirely, and DUPLICATE seqs cannot be paged
|
|
547
|
+
* through by seq. Those cases need the `fromByteOffset` anchor.
|
|
548
|
+
* - `total` is the number of events AVAILABLE for this read (after the
|
|
549
|
+
* sinceSeq filter, before the `limit` slice) — EXCEPT that the tail cap
|
|
550
|
+
* (TAIL_EVENT_CAP) may drop a prefix first, in which case `total` counts
|
|
551
|
+
* only the retained suffix.
|
|
552
|
+
* - `nextByteOffset` is an OPTIONAL incremental-read accelerator, and is only
|
|
553
|
+
* returned when it is a SAFE resume anchor: on the `fromByteOffset` path it
|
|
554
|
+
* is OMITTED whenever `limit` truncated the delta, because the underlying
|
|
555
|
+
* offset then points past the ENTIRE delta (undelivered tail included) —
|
|
556
|
+
* resuming from it would silently skip events. The default (non-byte)
|
|
557
|
+
* path never returns it at all.
|
|
558
|
+
*/
|
|
531
559
|
export function readEventsCursor(eventsPath: string, options: EventCursorOptions = {}): EventCursorResult {
|
|
532
560
|
// Incremental byte-offset path: read only new bytes since last known offset
|
|
533
561
|
if (options.fromByteOffset !== undefined) {
|
|
@@ -558,11 +586,19 @@ export function readEventsCursor(eventsPath: string, options: EventCursorOptions
|
|
|
558
586
|
const limit = positiveInteger(options.limit);
|
|
559
587
|
const events = limit !== undefined ? merged.slice(0, limit) : merged;
|
|
560
588
|
const returnedMaxSeq = events.reduce((max, event) => Math.max(max, event.metadata?.seq ?? 0), sinceSeq);
|
|
589
|
+
// BR-08: `newState.byteOffset` sits past the ENTIRE delta read above —
|
|
590
|
+
// including the events the `limit` slice just dropped. Handing that back
|
|
591
|
+
// as `nextByteOffset` let a caller resume PAST events it never saw (silent
|
|
592
|
+
// loss: the delta read then returns an empty tail). Only expose the
|
|
593
|
+
// accelerator when this call delivered the whole delta; under truncation
|
|
594
|
+
// the caller must continue from `nextSeq` (see the doc comment on
|
|
595
|
+
// readEventsCursor).
|
|
596
|
+
const deliveredWholeDelta = limit === undefined || merged.length <= limit;
|
|
561
597
|
return {
|
|
562
598
|
events,
|
|
563
599
|
nextSeq: returnedMaxSeq,
|
|
564
600
|
total: merged.length,
|
|
565
|
-
nextByteOffset: newState.byteOffset,
|
|
601
|
+
...(deliveredWholeDelta ? { nextByteOffset: newState.byteOffset } : {}),
|
|
566
602
|
generation: liveGen,
|
|
567
603
|
};
|
|
568
604
|
}
|
|
@@ -169,18 +169,83 @@ export interface CompactionResult {
|
|
|
169
169
|
* 6. Return compaction stats
|
|
170
170
|
*/
|
|
171
171
|
export function compactEventLog(eventsPath: string, config?: Partial<RotationConfig>): CompactionResult | undefined {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
172
|
+
// US-001 (2026-09-22): lock-scope reduction. The expensive part is the READ +
|
|
173
|
+
// parse of the whole log (prepareCompaction). It is read-only, so it does NOT
|
|
174
|
+
// need the append lock: hold the lock ONLY for the write+recover phase
|
|
175
|
+
// (applyCompactionUnlocked), which is what must be atomic w.r.t. appends.
|
|
176
|
+
//
|
|
177
|
+
// Why this is safe: appenders take the SAME lock, so an append can only land
|
|
178
|
+
// before our prepareCompaction read or after our applyCompactionUnlocked
|
|
179
|
+
// write — never inside the write. applyCompactionUnlocked already re-reads
|
|
180
|
+
// after the write and splices back any events that arrived in the window
|
|
181
|
+
// (C2 recovery), so no event is lost; it only becomes slightly more likely to
|
|
182
|
+
// splice, which is exactly the case it was built for.
|
|
183
|
+
//
|
|
184
|
+
// No concurrent-rotator guard is needed: this function is fully SYNCHRONOUS
|
|
185
|
+
// (prepareCompaction and withEventLogLockSync both block), so two calls on the
|
|
186
|
+
// same path cannot interleave within a process. An in-flight flag was tried
|
|
187
|
+
// and removed — it was untestable dead code (mutation survived), and the
|
|
188
|
+
// synchronous execution already serializes rotators. Cross-process rotators
|
|
189
|
+
// are serialized by the on-disk lock in withEventLogLockSync.
|
|
177
190
|
//
|
|
178
191
|
// NOTE (Round 24 BUG 1): callers ALREADY holding the event-log lock (e.g.
|
|
179
|
-
// appendEventInsideLock in event-log.ts) must call applyCompactionUnlocked
|
|
192
|
+
// appendEventInsideLock in event-log.ts) must still call applyCompactionUnlocked
|
|
180
193
|
// directly — calling compactEventLog from inside the lock deadlocks (the
|
|
181
194
|
// mkdir lock is not re-entrant → 5s timeout → compaction never ran → the
|
|
182
195
|
// log grew unbounded until events were silently dropped past 50MB).
|
|
183
|
-
|
|
196
|
+
const prepared = prepareCompaction(eventsPath, config);
|
|
197
|
+
if (!prepared) return undefined;
|
|
198
|
+
return compactPreparedEventLog(eventsPath, prepared);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* US-001 phase 3 — the LOCKED write phase, extracted so the read→write race is
|
|
203
|
+
* deterministically testable: a caller (test) can run prepareCompaction, append
|
|
204
|
+
* to the file (simulating another process landing events in the window), then
|
|
205
|
+
* call this and assert nothing was lost.
|
|
206
|
+
*
|
|
207
|
+
* prepareCompaction reads OUTSIDE the lock, so an appender may have added events
|
|
208
|
+
* between that read and this write. applyCompactionUnlocked writes exactly
|
|
209
|
+
* `prepared.lines` (the kept window from the snapshot), which would CLOBBER those
|
|
210
|
+
* newer events; its C2 recovery only re-splices events that were in `kept`, not
|
|
211
|
+
* ones it never saw. So under the lock we read the bytes appended after the
|
|
212
|
+
* snapshot offset and append them to the write. Appenders hold the same lock,
|
|
213
|
+
* so this read is a complete tail snapshot.
|
|
214
|
+
*/
|
|
215
|
+
export function compactPreparedEventLog(
|
|
216
|
+
eventsPath: string,
|
|
217
|
+
prepared: { lines: string; originalSize: number; originalCount: number; kept: TeamEvent[] },
|
|
218
|
+
): CompactionResult | undefined {
|
|
219
|
+
return withEventLogLockSync(eventsPath, () => {
|
|
220
|
+
const merged = { ...prepared, lines: prepared.lines + readTailSince(eventsPath, prepared.originalSize) };
|
|
221
|
+
return applyCompactionUnlocked(eventsPath, merged);
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* US-001: return the raw text appended to `filePath` after `offset`, or "" when
|
|
227
|
+
* nothing was appended / the file was replaced by a smaller one (a foreign
|
|
228
|
+
* rotation — caller then proceeds without a splice, which is the pre-US-001
|
|
229
|
+
* behaviour). Must be called while holding the event-log lock.
|
|
230
|
+
*/
|
|
231
|
+
function readTailSince(filePath: string, offset: number): string {
|
|
232
|
+
try {
|
|
233
|
+
const size = fs.statSync(filePath).size;
|
|
234
|
+
if (size <= offset) return "";
|
|
235
|
+
const fd = fs.openSync(filePath, "r");
|
|
236
|
+
try {
|
|
237
|
+
const length = size - offset;
|
|
238
|
+
const buf = Buffer.alloc(length);
|
|
239
|
+
const read = fs.readSync(fd, buf, 0, length, offset);
|
|
240
|
+
const tail = buf.subarray(0, read).toString("utf-8");
|
|
241
|
+
// Only splice complete lines (a partial trailing write would corrupt JSONL).
|
|
242
|
+
return tail.endsWith("\n") ? tail : tail.slice(0, tail.lastIndexOf("\n") + 1);
|
|
243
|
+
} finally {
|
|
244
|
+
fs.closeSync(fd);
|
|
245
|
+
}
|
|
246
|
+
} catch {
|
|
247
|
+
return "";
|
|
248
|
+
}
|
|
184
249
|
}
|
|
185
250
|
|
|
186
251
|
/** Round 24 (BUG 1): the lock-free pre-read for compaction. Safe to run
|
|
@@ -61,6 +61,14 @@ function removeStaleRegistryLock(lockPath: string, staleMs: number): boolean {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
function withRegistryLock<T>(fn: () => T): T {
|
|
64
|
+
// US-010 (2026-09-22): sleepSync below is INTENTIONAL — this lock is sync-only
|
|
65
|
+
// (fn is synchronous, and its callers at :352/:378 are sync). Converting the
|
|
66
|
+
// backoff to `await sleep` here would repeat the documented v0.9.26
|
|
67
|
+
// starvation: the sync spinner's sleepSync blocks the event loop while an
|
|
68
|
+
// in-process async holder's `await`-based release can never run. The lock is
|
|
69
|
+
// held only for a sub-millisecond read-modify-write and the loop is bounded by
|
|
70
|
+
// a 10s deadline, so blocking is bounded and rare (contention is cross-process
|
|
71
|
+
// only). See src/state/event-log/sequence-cache.ts:270 for the same reasoning.
|
|
64
72
|
const filePath = registryLockPath();
|
|
65
73
|
const staleMs = 30_000;
|
|
66
74
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
@@ -84,7 +92,11 @@ function withRegistryLock<T>(fn: () => T): T {
|
|
|
84
92
|
break;
|
|
85
93
|
} catch (error) {
|
|
86
94
|
const code = (error as NodeJS.ErrnoException).code;
|
|
87
|
-
|
|
95
|
+
// Windows lock contention surfaces as EPERM/EACCES/EBUSY (not EEXIST) while
|
|
96
|
+
// another handle holds the lock file open — cross-process contention in
|
|
97
|
+
// CI (run 36019884258). Treat as contention: bounded retry via the 10s
|
|
98
|
+
// deadline below, mirroring isLockContention() in coordination/locks.ts.
|
|
99
|
+
if (code !== "EEXIST" && code !== "EPERM" && code !== "EACCES" && code !== "EBUSY") throw error;
|
|
88
100
|
if (!removeStaleRegistryLock(filePath, staleMs) && Date.now() > deadline)
|
|
89
101
|
throw new Error("Active-run registry is locked by another operation.");
|
|
90
102
|
sleepSync(Math.min(250, 25 * 2 ** attempt));
|