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,9 +1,17 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
atomicWriteJson,
|
|
6
|
+
atomicWriteJsonCoalesced,
|
|
7
|
+
cancelPendingCoalescedWriteForPath,
|
|
8
|
+
flushPendingAtomicWrites,
|
|
9
|
+
peekPendingCoalescedWrite,
|
|
10
|
+
readJsonFile,
|
|
11
|
+
} from "../state/atomic-write.ts";
|
|
5
12
|
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
6
13
|
import { readJsonFileCoalesced } from "../utils/file-coalescer.ts";
|
|
14
|
+
import { readLinesSince } from "../utils/incremental-reader.ts";
|
|
7
15
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
8
16
|
import { redactSecretString, redactSecrets } from "../utils/redaction.ts";
|
|
9
17
|
import { assertSafePathId, resolveRealContainedPath } from "../utils/safe-paths.ts";
|
|
@@ -237,7 +245,9 @@ function withAgentsLock<T>(manifest: TeamRunManifest, fn: () => T): T {
|
|
|
237
245
|
break;
|
|
238
246
|
} catch (error) {
|
|
239
247
|
const code = (error as NodeJS.ErrnoException).code;
|
|
240
|
-
|
|
248
|
+
// EPERM/EACCES/EBUSY: Windows cross-process contention (another handle
|
|
249
|
+
// holds the lock open) — retry like EEXIST, bounded by the deadline.
|
|
250
|
+
if (code !== "EEXIST" && code !== "EISDIR" && code !== "EPERM" && code !== "EACCES" && code !== "EBUSY") throw error;
|
|
241
251
|
if (code === "EISDIR") {
|
|
242
252
|
try {
|
|
243
253
|
fs.rmSync(filePath, { recursive: true, force: true });
|
|
@@ -282,19 +292,22 @@ function setAsyncAgentReaderCache(
|
|
|
282
292
|
}
|
|
283
293
|
|
|
284
294
|
export function readCrewAgents(manifest: TeamRunManifest): CrewAgentRecord[] {
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
|
|
295
|
+
// F06 / RR-017: read-after-write WITHOUT forcing the pending coalesced write
|
|
296
|
+
// to disk. `upsertCrewAgent` always reads first, so the previous scoped
|
|
297
|
+
// `flushPendingAtomicWrites(agentsPath)` (a scoped flush IS a write when an
|
|
298
|
+
// entry exists — atomic-write.ts:1147) turned every non-terminal upsert into
|
|
299
|
+
// a durable agents.json rewrite, destroying the very coalescing window the
|
|
300
|
+
// caller had just created (measured: 20 progress upserts → 19 agents.json
|
|
301
|
+
// renames). The buffered value is byte-for-byte what a flush would have
|
|
302
|
+
// written, so overlaying it preserves the "intra-process readers see the
|
|
303
|
+
// latest data" contract (and cross-process readers still see the file within
|
|
304
|
+
// one coalesce window) with zero I/O.
|
|
305
|
+
const agentsFilePath = agentsPath(manifest);
|
|
306
|
+
const pending = peekPendingCoalescedWrite<CrewAgentRecord[]>(agentsFilePath);
|
|
292
307
|
try {
|
|
293
|
-
const records =
|
|
294
|
-
|
|
295
|
-
AGENT_READER_TTL_MS,
|
|
296
|
-
() => readJsonFile<CrewAgentRecord[]>(agentsPath(manifest)) ?? [],
|
|
297
|
-
);
|
|
308
|
+
const records =
|
|
309
|
+
pending ??
|
|
310
|
+
readJsonFileCoalesced(agentsFilePath, AGENT_READER_TTL_MS, () => readJsonFile<CrewAgentRecord[]>(agentsFilePath) ?? []);
|
|
298
311
|
// Validate schema and deduplicate by id to handle concurrent write conflicts
|
|
299
312
|
const seen = new Set<string>();
|
|
300
313
|
const deduped = records.filter((r) => {
|
|
@@ -368,6 +381,122 @@ export async function readCrewAgentsAsync(manifest: TeamRunManifest): Promise<Cr
|
|
|
368
381
|
return inFlight;
|
|
369
382
|
}
|
|
370
383
|
|
|
384
|
+
// ---------------------------------------------------------------------------
|
|
385
|
+
// F06 / RR-017: dirty tracking for per-task status.json writes.
|
|
386
|
+
//
|
|
387
|
+
// `saveCrewAgents`/`saveCrewAgentsCoalesced` used to loop over EVERY record and
|
|
388
|
+
// write its status.json, with no notion of which records actually changed. The
|
|
389
|
+
// measured cost of updating ONE of four `completed` records was 6 renames / 12
|
|
390
|
+
// fsyncs, and the three unchanged records each paid a full durable rename they
|
|
391
|
+
// had no reason to pay.
|
|
392
|
+
//
|
|
393
|
+
// SCOPE OF THE SKIP — durable (terminal) writes only, deliberately:
|
|
394
|
+
// - a coalesced write is buffered in RAM and collapses per path, so skipping
|
|
395
|
+
// it saves no I/O; and a skip there could not be trusted anyway, since the
|
|
396
|
+
// buffered value may never reach disk (the memo would then claim a write
|
|
397
|
+
// that a crash erased). Coalesced writes therefore stay unconditional.
|
|
398
|
+
// - a DURABLE write is only skipped when the exact same bytes are already
|
|
399
|
+
// durably on disk: the memo is written ONLY by durable writes, and the skip
|
|
400
|
+
// is re-validated against the file actually existing. So a skip can never
|
|
401
|
+
// lose durability — it can only avoid re-writing identical bytes.
|
|
402
|
+
// Durability is otherwise untouched: agents.json keeps full durability on every
|
|
403
|
+
// save, terminal status keeps its deliberate DOUBLE write (H2/F4 — the second
|
|
404
|
+
// write is unconditional and is NOT removed here), and non-terminal status keeps
|
|
405
|
+
// the coalesced best-effort path.
|
|
406
|
+
// ---------------------------------------------------------------------------
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Last record content (redacted, JSON-serialized) written DURABLY per status
|
|
410
|
+
* path.
|
|
411
|
+
*
|
|
412
|
+
* INVARIANT (single-writer, RM-03 / review MINOR 4): this memo is a
|
|
413
|
+
* PROCESS-GLOBAL Map with no locking, and it is only correct because exactly
|
|
414
|
+
* ONE writer owns a given `statusPath` at a time. Two concurrent writers for
|
|
415
|
+
* the same path would each see a stale memo and could skip a durable write
|
|
416
|
+
* that the other expects to have happened. The single-writer property is
|
|
417
|
+
* provided by the run-lock / task-claim protocol (see
|
|
418
|
+
* src/state/coordination/locks.ts): a task is claimed by one agent, and only
|
|
419
|
+
* that agent calls saveCrewAgents for its statusPath.
|
|
420
|
+
*
|
|
421
|
+
* Do NOT add a second writer for the same statusPath without first replacing
|
|
422
|
+
* this memo with a lock-protected (or content-addressed) structure — and do
|
|
423
|
+
* NOT rely on the memo across processes (it is per-process; cross-process
|
|
424
|
+
* readers see the file, which is why needsDurableStatusWrite re-checks
|
|
425
|
+
* fs.existsSync rather than trusting the memo).
|
|
426
|
+
*
|
|
427
|
+
* Bounded by LAST_WRITTEN_STATUS_MAX_ENTRIES with FIFO eviction, so an evicted
|
|
428
|
+
* entry merely causes one redundant durable write (safe), never a skipped one.
|
|
429
|
+
*/
|
|
430
|
+
const lastWrittenStatus = new Map<string, string>();
|
|
431
|
+
const LAST_WRITTEN_STATUS_MAX_ENTRIES = 2048;
|
|
432
|
+
|
|
433
|
+
/** @internal Test-only: how many records a save actually persisted status for. */
|
|
434
|
+
let lastSaveStatusWriteCount = 0;
|
|
435
|
+
/** @internal Test-only: read the counter from the most recent save call. */
|
|
436
|
+
export function __test__lastAgentStatusWriteCount(): number {
|
|
437
|
+
return lastSaveStatusWriteCount;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** @internal Test-only: drop the durable-write memo. */
|
|
441
|
+
export function __test__clearWrittenStatusMemo(): void {
|
|
442
|
+
lastWrittenStatus.clear();
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function statusSnapshotKey(record: CrewAgentRecord): string {
|
|
446
|
+
return JSON.stringify(redactSecrets(record));
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* True when `record`'s status.json must be written durably: either its content
|
|
451
|
+
* differs from the last durable snapshot for that path, or the file is gone
|
|
452
|
+
* (someone else deleted it — never trust the memo over the filesystem).
|
|
453
|
+
*/
|
|
454
|
+
function needsDurableStatusWrite(manifest: TeamRunManifest, record: CrewAgentRecord): boolean {
|
|
455
|
+
const statusPath = agentStatusPath(manifest, record.taskId);
|
|
456
|
+
const snapshot = statusSnapshotKey(record);
|
|
457
|
+
if (lastWrittenStatus.get(statusPath) !== snapshot) return true;
|
|
458
|
+
return !fs.existsSync(statusPath);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function rememberWrittenStatus(manifest: TeamRunManifest, record: CrewAgentRecord): void {
|
|
462
|
+
const statusPath = agentStatusPath(manifest, record.taskId);
|
|
463
|
+
if (lastWrittenStatus.has(statusPath)) lastWrittenStatus.delete(statusPath);
|
|
464
|
+
lastWrittenStatus.set(statusPath, statusSnapshotKey(record));
|
|
465
|
+
while (lastWrittenStatus.size > LAST_WRITTEN_STATUS_MAX_ENTRIES) {
|
|
466
|
+
const oldest = lastWrittenStatus.keys().next().value;
|
|
467
|
+
if (oldest === undefined) break;
|
|
468
|
+
lastWrittenStatus.delete(oldest);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Drop the memo for a status path whose file was deleted (cancel/removal). */
|
|
473
|
+
function forgetWrittenStatus(statusPath: string): void {
|
|
474
|
+
lastWrittenStatus.delete(statusPath);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function writeStatusForRecords(manifest: TeamRunManifest, records: CrewAgentRecord[]): void {
|
|
478
|
+
lastSaveStatusWriteCount = 0;
|
|
479
|
+
for (const record of records) {
|
|
480
|
+
// H2 (2026-08-10): per-task status.json is a DENORMALIZED read
|
|
481
|
+
// optimization for the dashboard/notifier, not an authoritative
|
|
482
|
+
// record — agents.json + events.jsonl cover crash recovery.
|
|
483
|
+
// Previously EVERY record (including running/queued progress
|
|
484
|
+
// snapshots) got a full fsync: N+1 fsyncs per saveCrewAgents call
|
|
485
|
+
// (50-task team ≈ 750ms blocking). Only TERMINAL records keep
|
|
486
|
+
// full durability (F4: notifier/dashboard must see the final
|
|
487
|
+
// state immediately); non-terminal per-task status is best-effort
|
|
488
|
+
// coalesced like the upsertCrewAgent non-terminal path.
|
|
489
|
+
if (TERMINAL_AGENT_STATUSES.has(record.status ?? "")) {
|
|
490
|
+
if (!needsDurableStatusWrite(manifest, record)) continue;
|
|
491
|
+
lastSaveStatusWriteCount++;
|
|
492
|
+
writeCrewAgentStatus(manifest, record);
|
|
493
|
+
} else {
|
|
494
|
+
lastSaveStatusWriteCount++;
|
|
495
|
+
writeCrewAgentStatusCoalesced(manifest, record);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
371
500
|
export function saveCrewAgents(manifest: TeamRunManifest, records: CrewAgentRecord[]): void {
|
|
372
501
|
// P0-3: flush any pending coalesced (best-effort) write first so a stale
|
|
373
502
|
// debounced progress snapshot can't clobber this durable write.
|
|
@@ -379,22 +508,7 @@ export function saveCrewAgents(manifest: TeamRunManifest, records: CrewAgentReco
|
|
|
379
508
|
// durability.
|
|
380
509
|
atomicWriteJson(filePath, redactSecrets(records));
|
|
381
510
|
asyncAgentReaderCache.delete(filePath);
|
|
382
|
-
|
|
383
|
-
// H2 (2026-08-10): per-task status.json is a DENORMALIZED read
|
|
384
|
-
// optimization for the dashboard/notifier, not an authoritative
|
|
385
|
-
// record — agents.json + events.jsonl cover crash recovery.
|
|
386
|
-
// Previously EVERY record (including running/queued progress
|
|
387
|
-
// snapshots) got a full fsync: N+1 fsyncs per saveCrewAgents call
|
|
388
|
-
// (50-task team ≈ 750ms blocking). Only TERMINAL records keep
|
|
389
|
-
// full durability (F4: notifier/dashboard must see the final
|
|
390
|
-
// state immediately); non-terminal per-task status is best-effort
|
|
391
|
-
// coalesced like the upsertCrewAgent non-terminal path.
|
|
392
|
-
if (TERMINAL_AGENT_STATUSES.has(record.status ?? "")) {
|
|
393
|
-
writeCrewAgentStatus(manifest, record);
|
|
394
|
-
} else {
|
|
395
|
-
writeCrewAgentStatusCoalesced(manifest, record);
|
|
396
|
-
}
|
|
397
|
-
}
|
|
511
|
+
writeStatusForRecords(manifest, records);
|
|
398
512
|
});
|
|
399
513
|
}
|
|
400
514
|
|
|
@@ -431,6 +545,15 @@ export function removeCrewAgent(manifest: TeamRunManifest, taskId: string): { re
|
|
|
431
545
|
// 2. Remove per-task status.json
|
|
432
546
|
try {
|
|
433
547
|
const statusPath = agentStatusPath(manifest, taskId);
|
|
548
|
+
// F10 / RR-016: cancel the pending coalesced status write for THIS exact
|
|
549
|
+
// path BEFORE unlinking. The record was just dropped from the index, so
|
|
550
|
+
// `flushPendingAgentWrites` (which only iterates records still in the
|
|
551
|
+
// index) will never drain — or cancel — its buffered write. Without this
|
|
552
|
+
// the 250ms timer fired AFTER the unlink and RE-CREATED the file with the
|
|
553
|
+
// stale pre-cancel snapshot (measured: status.json reappearing as
|
|
554
|
+
// `status:"running"` after the exit drain while agents.json stayed empty).
|
|
555
|
+
cancelPendingCoalescedWriteForPath(statusPath);
|
|
556
|
+
forgetWrittenStatus(statusPath);
|
|
434
557
|
if (fs.existsSync(statusPath)) {
|
|
435
558
|
fs.unlinkSync(statusPath);
|
|
436
559
|
removedStatus = true;
|
|
@@ -480,6 +603,10 @@ export function writeCrewAgentStatus(manifest: TeamRunManifest, record: CrewAgen
|
|
|
480
603
|
// F4: terminal agent status (completed/failed/cancelled/blocked) — keep full
|
|
481
604
|
// durability so the notifier/dashboard health sees the final state immediately.
|
|
482
605
|
atomicWriteJson(agentStatusPath(manifest, record.taskId), redactSecrets(record), { durability: "full" });
|
|
606
|
+
// F06/RR-017: record the DURABLE snapshot so a later save can skip re-writing
|
|
607
|
+
// byte-identical content for this record (see writeStatusForRecords). Only
|
|
608
|
+
// durable writes memoize — a coalesced write may never reach disk.
|
|
609
|
+
rememberWrittenStatus(manifest, record);
|
|
483
610
|
}
|
|
484
611
|
|
|
485
612
|
// 2.5 — coalesced variants. Buffer per-agent record + aggregate writes for
|
|
@@ -496,7 +623,7 @@ export function saveCrewAgentsCoalesced(manifest: TeamRunManifest, records: Crew
|
|
|
496
623
|
// events.jsonl independently of these JSON files.
|
|
497
624
|
atomicWriteJsonCoalesced(filePath, redactSecrets(records), AGENT_COALESCE_MS, { durability: "best-effort" });
|
|
498
625
|
asyncAgentReaderCache.delete(filePath);
|
|
499
|
-
|
|
626
|
+
writeStatusForRecords(manifest, records);
|
|
500
627
|
}
|
|
501
628
|
|
|
502
629
|
export function writeCrewAgentStatusCoalesced(manifest: TeamRunManifest, record: CrewAgentRecord): void {
|
|
@@ -640,6 +767,183 @@ function readCrewAgentEvents(manifest: TeamRunManifest, taskId: string): unknown
|
|
|
640
767
|
return readCrewAgentEventsCursor(manifest, taskId).events;
|
|
641
768
|
}
|
|
642
769
|
|
|
770
|
+
// ---------------------------------------------------------------------------
|
|
771
|
+
// F07 / RR-017: incremental agent-event cursor.
|
|
772
|
+
//
|
|
773
|
+
// The legacy reader did `readFileSync → split → JSON.parse each line → filter →
|
|
774
|
+
// slice`, applying `sinceSeq`/`limit` only AFTER reading and parsing the WHOLE
|
|
775
|
+
// file. Measured on a 10000-event file: 697788 bytes read per IDLE poll, linear
|
|
776
|
+
// in file size, and the inline panel polls it every 700 ms
|
|
777
|
+
// (src/ui/inline-panel/index.ts:46, consumer agent-transcript.ts:291).
|
|
778
|
+
//
|
|
779
|
+
// The fix REUSES the existing incremental primitive
|
|
780
|
+
// (`readLinesSince` in src/utils/incremental-reader.ts — the same module
|
|
781
|
+
// src/state/event-log/cursor.ts uses) instead of adding a new reader. A per-path
|
|
782
|
+
// watermark holds `{byteOffset, lineCount, prefixMaxSeq, size, mtimeMs, ino,
|
|
783
|
+
// dev}` — O(1) memory per path, bounded to 512 paths:
|
|
784
|
+
// - idle poll (unchanged size/mtimeMs/inode): ZERO event bytes read;
|
|
785
|
+
// - append: reads only the delta [byteOffset, size);
|
|
786
|
+
// - shrink (truncation) or inode replacement (rotation/rewrite): the watermark
|
|
787
|
+
// is discarded and the file is re-read from 0 — the same "rewrite-then-
|
|
788
|
+
// regrow" hole cursor.ts closes with inode stamping.
|
|
789
|
+
//
|
|
790
|
+
// WHY A PREFIX IS NEVER RE-READ (soundness): the cached prefix is the set of
|
|
791
|
+
// events already consumed before `byteOffset`. `prefixMaxSeq` is the maximum seq
|
|
792
|
+
// seen in that prefix, so EVERY prefix event satisfies `seq <= prefixMaxSeq`.
|
|
793
|
+
// When `sinceSeq >= prefixMaxSeq`, no prefix event can pass the
|
|
794
|
+
// `seq > sinceSeq` filter, so the answer is exactly "the delta's matches" —
|
|
795
|
+
// provable without assuming file order equals seq order. When
|
|
796
|
+
// `sinceSeq < prefixMaxSeq` the reader falls back to a full read from 0, so
|
|
797
|
+
// correctness never depends on the bound being tight.
|
|
798
|
+
//
|
|
799
|
+
// LEGACY SEQ SEMANTICS ARE PRESERVED EXACTLY: a line without a numeric `seq`
|
|
800
|
+
// gets `seq = lineIndex + 1`, where lineIndex is the index of that line among
|
|
801
|
+
// the file's NON-EMPTY lines. `readLinesSince` only increments `lineCount` for
|
|
802
|
+
// non-empty lines, so a delta's absolute base index is the cached `lineCount`
|
|
803
|
+
// and the parity holds without re-reading from offset 0.
|
|
804
|
+
//
|
|
805
|
+
// A trailing fragment without "\n" (a mid-append partial line) is held back by
|
|
806
|
+
// `readLinesSince` (it does not advance the committed offset past it), so it is
|
|
807
|
+
// neither lost nor parsed as a torn event — the next poll sees the completed
|
|
808
|
+
// line. Every writer of this file appends a trailing "\n", so this only ever
|
|
809
|
+
// defers a mid-write fragment.
|
|
810
|
+
// ---------------------------------------------------------------------------
|
|
811
|
+
|
|
812
|
+
interface AgentEventsCursorState {
|
|
813
|
+
byteOffset: number;
|
|
814
|
+
lineCount: number;
|
|
815
|
+
/** Max seq among events already consumed before `byteOffset`. */
|
|
816
|
+
prefixMaxSeq: number;
|
|
817
|
+
size: number;
|
|
818
|
+
mtimeMs: number;
|
|
819
|
+
ino: number;
|
|
820
|
+
dev: number;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
const agentEventsCursorCache = new Map<string, AgentEventsCursorState>();
|
|
824
|
+
const AGENT_EVENTS_CURSOR_CACHE_MAX_ENTRIES = 512;
|
|
825
|
+
|
|
826
|
+
// F07 test hook (AC-5/AC-6/AC-11): bytes of event payload the cursor actually
|
|
827
|
+
// read. The tests assert STRUCTURALLY (bytes read, not wall-clock), so the
|
|
828
|
+
// reader exposes this counter instead of the suite monkey-patching `node:fs`
|
|
829
|
+
// (whose ESM named exports are frozen). Mirrors the `__test__agentRecordBufferCount`
|
|
830
|
+
// precedent in this file. Only the incremental read path increments it.
|
|
831
|
+
let agentEventBytesRead = 0;
|
|
832
|
+
|
|
833
|
+
/** @internal Test-only: reset the F07 byte counter. */
|
|
834
|
+
export function __test__resetAgentEventBytesRead(): void {
|
|
835
|
+
agentEventBytesRead = 0;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/** @internal Test-only: event-file bytes read since the last reset. */
|
|
839
|
+
export function __test__agentEventBytesRead(): number {
|
|
840
|
+
return agentEventBytesRead;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/** @internal Test-only: drop the per-path incremental cursor watermarks. */
|
|
844
|
+
export function __test__clearAgentEventsCursorCache(): void {
|
|
845
|
+
agentEventsCursorCache.clear();
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/** @internal Test-only: inspect a watermark (undefined when uncached). */
|
|
849
|
+
export function __test__agentEventsCursorState(filePath: string): AgentEventsCursorState | undefined {
|
|
850
|
+
return agentEventsCursorCache.get(filePath);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function setAgentEventsCursorState(filePath: string, state: AgentEventsCursorState): void {
|
|
854
|
+
if (agentEventsCursorCache.has(filePath)) agentEventsCursorCache.delete(filePath);
|
|
855
|
+
agentEventsCursorCache.set(filePath, state);
|
|
856
|
+
while (agentEventsCursorCache.size > AGENT_EVENTS_CURSOR_CACHE_MAX_ENTRIES) {
|
|
857
|
+
const oldest = agentEventsCursorCache.keys().next().value;
|
|
858
|
+
if (oldest === undefined) break;
|
|
859
|
+
agentEventsCursorCache.delete(oldest);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* Parse raw JSONL lines into cursor events, assigning the legacy `seq = index+1`
|
|
865
|
+
* fallback for lines that carry no numeric seq. `lineIndexBase` is the index of
|
|
866
|
+
* the FIRST line in this batch among the file's non-empty lines (0 when reading
|
|
867
|
+
* from offset 0).
|
|
868
|
+
*
|
|
869
|
+
* A single trailing "\r" is stripped so CRLF files behave like the legacy
|
|
870
|
+
* `split(/\r?\n/)` reader (JSON.parse tolerates it either way; the difference
|
|
871
|
+
* only shows up in the `raw` payload of an already-corrupt line).
|
|
872
|
+
*/
|
|
873
|
+
function parseAgentEventLines(lines: string[], lineIndexBase: number): { events: Array<Record<string, unknown>>; maxSeq: number } {
|
|
874
|
+
const events: Array<Record<string, unknown>> = [];
|
|
875
|
+
let maxSeq = 0;
|
|
876
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
877
|
+
const raw = lines[index]!;
|
|
878
|
+
const text = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
|
|
879
|
+
const lineIndex = lineIndexBase + index;
|
|
880
|
+
let event: Record<string, unknown>;
|
|
881
|
+
try {
|
|
882
|
+
const parsed = JSON.parse(text) as unknown;
|
|
883
|
+
event = parsed !== null && typeof parsed === "object" ? (parsed as Record<string, unknown>) : { seq: lineIndex + 1, raw: text };
|
|
884
|
+
if (typeof event.seq !== "number") event.seq = lineIndex + 1;
|
|
885
|
+
} catch {
|
|
886
|
+
event = { seq: lineIndex + 1, raw: text };
|
|
887
|
+
}
|
|
888
|
+
events.push(event);
|
|
889
|
+
if (typeof event.seq === "number" && event.seq > maxSeq) maxSeq = event.seq;
|
|
890
|
+
}
|
|
891
|
+
return { events, maxSeq };
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* Read the agent events file, consuming only the bytes that are new since the
|
|
896
|
+
* last read of this path, and return every event with `seq > sinceSeq` (the
|
|
897
|
+
* legacy `total` is this array's length before `limit` is applied).
|
|
898
|
+
*/
|
|
899
|
+
function readAgentEventMatches(filePath: string, sinceSeq: number): Array<Record<string, unknown>> {
|
|
900
|
+
let stat: fs.Stats;
|
|
901
|
+
try {
|
|
902
|
+
stat = fs.statSync(filePath);
|
|
903
|
+
} catch {
|
|
904
|
+
return [];
|
|
905
|
+
}
|
|
906
|
+
const cached = agentEventsCursorCache.get(filePath);
|
|
907
|
+
const sameFile = cached !== undefined && cached.ino === stat.ino && cached.dev === stat.dev;
|
|
908
|
+
// A shrink (truncate) or an inode change (rotation / rewrite) invalidates the
|
|
909
|
+
// byte offset: the new content must be read from 0 or events are lost.
|
|
910
|
+
const usable = sameFile && stat.size > cached.byteOffset;
|
|
911
|
+
const canServeDelta = usable && sinceSeq >= cached.prefixMaxSeq;
|
|
912
|
+
const canServeIdle = sameFile && stat.size === cached.byteOffset && stat.mtimeMs === cached.mtimeMs && sinceSeq >= cached.prefixMaxSeq;
|
|
913
|
+
|
|
914
|
+
if (canServeIdle) {
|
|
915
|
+
// Nothing changed AND no prefix event can pass the filter — no bytes read.
|
|
916
|
+
return [];
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const baseOffset = canServeDelta ? cached.byteOffset : 0;
|
|
920
|
+
const baseLineCount = canServeDelta ? cached.lineCount : 0;
|
|
921
|
+
const baseMaxSeq = canServeDelta ? cached.prefixMaxSeq : 0;
|
|
922
|
+
const result = readLinesSince(filePath, { byteOffset: baseOffset, lineCount: baseLineCount });
|
|
923
|
+
// `readLinesSince` reads [state.byteOffset, fileSize); account for the bytes
|
|
924
|
+
// it requested (a held-back trailing fragment is still READ, just not parsed).
|
|
925
|
+
agentEventBytesRead += Math.max(0, stat.size - baseOffset);
|
|
926
|
+
const { events, maxSeq } = parseAgentEventLines(result.lines, baseLineCount);
|
|
927
|
+
const nextSeqInFile = Math.max(baseMaxSeq, maxSeq);
|
|
928
|
+
setAgentEventsCursorState(filePath, {
|
|
929
|
+
byteOffset: result.state.byteOffset,
|
|
930
|
+
lineCount: result.state.lineCount,
|
|
931
|
+
prefixMaxSeq: nextSeqInFile,
|
|
932
|
+
// `Math.max` covers a concurrent append that landed between the stat above
|
|
933
|
+
// and the read: storing the SMALLER (stale) size would make the next poll
|
|
934
|
+
// see `size === byteOffset` and needlessly re-read the whole file.
|
|
935
|
+
size: Math.max(stat.size, result.state.byteOffset),
|
|
936
|
+
mtimeMs: stat.mtimeMs,
|
|
937
|
+
ino: stat.ino,
|
|
938
|
+
dev: stat.dev,
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
// `events` is the whole-file parse when we could not serve a delta, and the
|
|
942
|
+
// delta-only parse otherwise — either way it holds EVERY event with
|
|
943
|
+
// seq > sinceSeq, because in the delta case the prefix is provably all <= sinceSeq.
|
|
944
|
+
return events.filter((event) => typeof event.seq === "number" && event.seq > sinceSeq);
|
|
945
|
+
}
|
|
946
|
+
|
|
643
947
|
export function readCrewAgentEventsCursor(
|
|
644
948
|
manifest: TeamRunManifest,
|
|
645
949
|
taskId: string,
|
|
@@ -676,20 +980,8 @@ export function readCrewAgentEventsCursor(
|
|
|
676
980
|
const sinceSeq =
|
|
677
981
|
typeof options.sinceSeq === "number" && Number.isInteger(options.sinceSeq) && options.sinceSeq >= 0 ? options.sinceSeq : 0;
|
|
678
982
|
const limit = typeof options.limit === "number" && Number.isInteger(options.limit) && options.limit >= 0 ? options.limit : undefined;
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
.split(/\r?\n/)
|
|
682
|
-
.filter(Boolean)
|
|
683
|
-
.map((line, index) => {
|
|
684
|
-
try {
|
|
685
|
-
const event = JSON.parse(line) as Record<string, unknown>;
|
|
686
|
-
if (typeof event.seq !== "number") event.seq = index + 1;
|
|
687
|
-
return event;
|
|
688
|
-
} catch {
|
|
689
|
-
return { seq: index + 1, raw: line };
|
|
690
|
-
}
|
|
691
|
-
});
|
|
692
|
-
const filtered = parsed.filter((event) => typeof event.seq === "number" && event.seq > sinceSeq);
|
|
983
|
+
|
|
984
|
+
const filtered = readAgentEventMatches(filePath, sinceSeq);
|
|
693
985
|
const events = limit !== undefined ? filtered.slice(0, limit) : filtered;
|
|
694
986
|
const returnedMaxSeq = events.reduce((max, event) => (typeof event.seq === "number" ? Math.max(max, event.seq) : max), sinceSeq);
|
|
695
987
|
return {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import type { TeamRunManifest } from "../state/types.ts";
|
|
4
|
-
|
|
5
4
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
5
|
+
import { projectCrewRoot } from "../utils/paths.ts";
|
|
6
6
|
|
|
7
7
|
export type DeadletterReason = "max-retries" | "heartbeat-dead" | "manual";
|
|
8
8
|
|
|
@@ -14,12 +14,30 @@ export interface DeadletterEntry {
|
|
|
14
14
|
lastError?: string;
|
|
15
15
|
attemptId?: string;
|
|
16
16
|
timestamp: string;
|
|
17
|
+
/** US-003 (2026-09-22): richer schema so the entry is self-sufficient for
|
|
18
|
+
* post-mortem AFTER the run dir is pruned (the project-level index below is
|
|
19
|
+
* the record that survives; the run-local file dies with the run dir). */
|
|
20
|
+
agent?: string;
|
|
21
|
+
role?: string;
|
|
22
|
+
/** Model-fallback attempt count at give-up time. */
|
|
23
|
+
modelAttempts?: number;
|
|
24
|
+
/** Run manifest status at the time of the write. */
|
|
25
|
+
runStatus?: string;
|
|
17
26
|
}
|
|
18
27
|
|
|
19
28
|
export function deadletterPath(manifest: TeamRunManifest): string {
|
|
20
29
|
return path.join(manifest.stateRoot, "deadletter.jsonl");
|
|
21
30
|
}
|
|
22
31
|
|
|
32
|
+
/**
|
|
33
|
+
* US-003: project-level dead-letter index, `<projectRoot>/.crew/state/deadletter/<runId>.jsonl`.
|
|
34
|
+
* Lives OUTSIDE any run dir, so per-run auto-prune (DP-01 keep=10) cannot delete it —
|
|
35
|
+
* this is the durable out-of-band record for post-mortem analysis.
|
|
36
|
+
*/
|
|
37
|
+
export function deadletterIndexPath(manifest: TeamRunManifest): string {
|
|
38
|
+
return path.join(projectCrewRoot(manifest.cwd), "state", "deadletter", `${manifest.runId}.jsonl`);
|
|
39
|
+
}
|
|
40
|
+
|
|
23
41
|
export function appendDeadletter(manifest: TeamRunManifest, entry: DeadletterEntry): void {
|
|
24
42
|
try {
|
|
25
43
|
fs.mkdirSync(manifest.stateRoot, { recursive: true });
|
|
@@ -27,6 +45,19 @@ export function appendDeadletter(manifest: TeamRunManifest, entry: DeadletterEnt
|
|
|
27
45
|
} catch (error) {
|
|
28
46
|
logInternalError("deadletter.append", error, `taskId=${entry.taskId}`);
|
|
29
47
|
}
|
|
48
|
+
// US-003: project-level index (survives run pruning). Best-effort AFTER the
|
|
49
|
+
// run-local record — appendDeadletter must never throw into the retry path.
|
|
50
|
+
// Honest deviation from the spec's "atomically (both or neither)" wording:
|
|
51
|
+
// cross-file atomicity without a journal is not physically possible; the
|
|
52
|
+
// contract is "both on success, never a caller-visible failure", with each
|
|
53
|
+
// failure logged via logInternalError.
|
|
54
|
+
try {
|
|
55
|
+
const indexPath = deadletterIndexPath(manifest);
|
|
56
|
+
fs.mkdirSync(path.dirname(indexPath), { recursive: true });
|
|
57
|
+
fs.appendFileSync(indexPath, `${JSON.stringify({ ...entry, runStatus: entry.runStatus ?? manifest.status })}\n`, "utf-8");
|
|
58
|
+
} catch (error) {
|
|
59
|
+
logInternalError("deadletter.index-append", error, `taskId=${entry.taskId}`);
|
|
60
|
+
}
|
|
30
61
|
}
|
|
31
62
|
|
|
32
63
|
export function readDeadletter(manifest: TeamRunManifest, maxEntries = 1000): DeadletterEntry[] {
|
|
@@ -45,3 +76,14 @@ export function readDeadletter(manifest: TeamRunManifest, maxEntries = 1000): De
|
|
|
45
76
|
}
|
|
46
77
|
});
|
|
47
78
|
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* US-003 AC-3: `handleStatus` surfacing — a Deadletter line, only when entries
|
|
82
|
+
* exist (silent otherwise; a zero line would be noise on every healthy run).
|
|
83
|
+
*/
|
|
84
|
+
export function deadletterStatusLine(manifest: TeamRunManifest): string | undefined {
|
|
85
|
+
const entries = readDeadletter(manifest);
|
|
86
|
+
if (!entries.length) return undefined;
|
|
87
|
+
const reasons = [...new Set(entries.map((entry) => entry.reason))].join(", ");
|
|
88
|
+
return `Deadletter: ${entries.length} (${reasons}) — ${deadletterPath(manifest)}`;
|
|
89
|
+
}
|
|
@@ -95,8 +95,27 @@ export function grandchildArtifactsRoot(cwd: string, runId: string, parentTaskId
|
|
|
95
95
|
return path.join(cwd, ".crew", "artifacts", runId, parentTaskId, "nested", subId);
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/** RR-023 F3 (2026-09-29 battery finding #3): derive the relayed grandchild
|
|
99
|
+
* result text from a ChildPiRunResult — pure so it is unit-testable without
|
|
100
|
+
* spawning. Same precedence as the original inline expression at the capture
|
|
101
|
+
* site: rawFinalText → stdout → stderr (failure only).
|
|
102
|
+
* Diagnosability guard: exit 0 with ZERO assistant text (run
|
|
103
|
+
* team_20260929041427: the model-less gc fell through to the pi global default
|
|
104
|
+
* and returned empty completions) used to fence an EMPTY mailbox body —
|
|
105
|
+
* indistinguishable from a relay bug. An explicit marker keeps the relay
|
|
106
|
+
* honest; the real fix (parent-model inheritance) lives in the broker. */
|
|
107
|
+
export function grandchildResultText(result: ChildPiRunResult): string {
|
|
108
|
+
const ok = result.exitCode === 0;
|
|
109
|
+
const text = (result.rawFinalText ?? result.stdout ?? "").trim() || (ok ? "" : result.stderr.trim());
|
|
110
|
+
return ok && text === "" ? "[grandchild exited 0 with no assistant output]" : text;
|
|
111
|
+
}
|
|
112
|
+
|
|
98
113
|
export async function spawnDelegateGrandchild(input: GrandchildSpawnInput): Promise<GrandchildSpawnResult> {
|
|
99
|
-
|
|
114
|
+
// RR-012 F03: single artifacts formula — grandchildArtifactsRoot(input.cwd, …).
|
|
115
|
+
// input.cwd is the PARENT TASK cwd threaded by the broker (the admitted
|
|
116
|
+
// worktree in worktree mode), so artifacts land under the admitted cwd —
|
|
117
|
+
// never under the leader/broker workspace.
|
|
118
|
+
const artifactsRoot = grandchildArtifactsRoot(input.cwd, input.runId, input.parentTaskId, input.subId);
|
|
100
119
|
fs.mkdirSync(artifactsRoot, { recursive: true });
|
|
101
120
|
|
|
102
121
|
const abort = new AbortController();
|
|
@@ -133,8 +152,7 @@ export async function spawnDelegateGrandchild(input: GrandchildSpawnInput): Prom
|
|
|
133
152
|
onSpawn: input.onSpawn,
|
|
134
153
|
});
|
|
135
154
|
const ok = result.exitCode === 0;
|
|
136
|
-
|
|
137
|
-
return { ok, resultText: text, ...(usageTokens !== undefined ? { usageTokens } : {}) };
|
|
155
|
+
return { ok, resultText: grandchildResultText(result), ...(usageTokens !== undefined ? { usageTokens } : {}) };
|
|
138
156
|
} catch (error) {
|
|
139
157
|
const timedOut = abort.signal.aborted;
|
|
140
158
|
return {
|
|
@@ -17,9 +17,21 @@
|
|
|
17
17
|
* parse error downstream. We don't double-report parse errors.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import {
|
|
20
|
+
import { createRequire } from "node:module";
|
|
21
21
|
import { getCrewEnv } from "../config/env-vars.ts";
|
|
22
22
|
|
|
23
|
+
// RR-021 WI-2.1: acorn parses ONLY when the determinism check actually runs —
|
|
24
|
+
// every dynamic-workflow-runner import was paying acorn's load cost up front.
|
|
25
|
+
// Lazy sync accessor (model: ui/syntax-highlight.ts); acorn stays external in
|
|
26
|
+
// the bundle and resolves through this shim on first use.
|
|
27
|
+
const require = createRequire(import.meta.url);
|
|
28
|
+
type AcornModule = typeof import("acorn");
|
|
29
|
+
let cachedAcorn: AcornModule | undefined;
|
|
30
|
+
function acorn(): AcornModule {
|
|
31
|
+
if (!cachedAcorn) cachedAcorn = require("acorn") as AcornModule;
|
|
32
|
+
return cachedAcorn;
|
|
33
|
+
}
|
|
34
|
+
|
|
23
35
|
const NONDETERMINISM_ERROR =
|
|
24
36
|
"Workflow scripts must be deterministic: Date.now()/Math.random()/new Date() are unavailable. These introduce non-reproducible behavior across runs. Use ctx.vars for cached state, or pass a fixed seed via ctx.setArgs(). To bypass this check (escape hatch), set PI_CREW_DWF_SKIP_DETERMINISM_CHECK=1.";
|
|
25
37
|
|
|
@@ -38,7 +50,7 @@ export class DeterminismError extends Error {
|
|
|
38
50
|
export function assertDeterministicScript(script: string): void {
|
|
39
51
|
let ast: AstNode;
|
|
40
52
|
try {
|
|
41
|
-
ast = parse(script, {
|
|
53
|
+
ast = acorn().parse(script, {
|
|
42
54
|
ecmaVersion: "latest",
|
|
43
55
|
sourceType: "module",
|
|
44
56
|
allowAwaitOutsideFunction: true,
|