pi-crew 0.9.33 → 0.9.34
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 +102 -0
- package/package.json +1 -1
- package/src/observability/exporters/otlp-exporter.ts +24 -2
- package/src/runtime/live-session-runtime.ts +12 -6
- package/src/runtime/parallel-utils.ts +5 -0
- package/src/runtime/task-graph-scheduler.ts +48 -17
- package/src/runtime/task-runner/prompt-builder.ts +55 -1
- package/src/runtime/task-runner/state-helpers.ts +18 -1
- package/src/runtime/team-runner.ts +109 -32
- package/src/state/artifact-store.ts +5 -0
- package/src/state/atomic-write.ts +64 -8
- package/src/state/state-store.ts +32 -28
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,72 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.9.34] — performance + security audit quick wins (2026-07-11)
|
|
4
|
+
|
|
5
|
+
Shipped the **forward performance audit** follow-up: 5 batches of perf + security fixes derived from the forward audit (`team_20260711095156_8a8a56a3c7dff269`) and the bundled code + security review. 15 audit findings + 1 security fix all closed. End-to-end verified on a live `team` run (`team_20260711134929_498a39322bc756d9`, 3/3 tasks, 148k tokens, real `tasks.json`/`events.jsonl`/`heartbeat.json` produced). Typecheck clean, lint clean, biome format clean, 146/146 targeted tests pass, full unit suite cut at 2,894 with **0 failures** (test runner timed out only on long backoff tests).
|
|
6
|
+
|
|
7
|
+
Detailed methodology: [`docs/perf/performance-audit-report-2026-07.md`](docs/perf/performance-audit-report-2026-07.md) · commit history below · end-to-end validation: [`/tmp/perf-audit-validation.md`](/tmp/perf-audit-validation.md).
|
|
8
|
+
|
|
9
|
+
### Performance
|
|
10
|
+
|
|
11
|
+
**Phase 2 — HIGH severity (P1, P2, P3)** + LOW (P10) + SEC-M1 (committed `f8e5629`):
|
|
12
|
+
- **P1** — `withQueue()` returns the same task reference when the computed queue matches `task.graph.queue`. Allocation-free on the common case; reference-stable for downstream selectors.
|
|
13
|
+
- **P2** — `saveRunManifest` / `saveRunManifestAsync` capture `manifestCache` before invalidating so the cached tasks array is reused on write-through repopulation. Eliminates per-call `JSON.parse(readFileSync(tasks.json))`.
|
|
14
|
+
- **P3** — `persistSingleTaskUpdate` reuses caller-supplied `fallbackTasks` on the first CAS attempt; only re-reads disk on actual contention.
|
|
15
|
+
- **P10** — `mergeTaskUpdatesPreservingTerminal` replaced its `O(N*M)` `.find()` + `.map()` nested loops with a single-pass `Map<id, task>` index (1000 → 120 ops).
|
|
16
|
+
- **SEC-M1** — `validateEndpoint` blocks `fe80::/10` (link-local), `fec0::/10` (site-local), `ff00::/8` (multicast), `::` (unspecified), and `::ffff:x.x.x.x` (IPv4-mapped) in addition to `::1` and `fd`/`fc`.
|
|
17
|
+
|
|
18
|
+
**Phase 3 — MEDIUM (P5, P7)** (committed `1e8d5b2`):
|
|
19
|
+
- **P5** — `isTargetNotSymlink()` cached for 1s on a per-file basis; `invalidateSymlinkSafeCache(dir)` flushes both cache layers.
|
|
20
|
+
- **P7** — 5 fire-and-forget call sites in `live-session-runtime.ts` migrated from sync `appendEvent` (with `sleepSync` lock) to `appendEventFireAndForget`. SIGTERM/AbortSignal handlers now fire promptly during high-frequency prompt-done churn.
|
|
21
|
+
|
|
22
|
+
**Phase 4 — MEDIUM-LOW (P6, P9)** (committed `824f428`):
|
|
23
|
+
- **P6** — `writeProgress()` replaced its O(N²) `findIndex` dedup with a single-pass `Map<path, ArtifactDescriptor>`. Added a content-skip cache (WeakMap on manifest) for back-to-back `applyPolicy` + `executeTeamRun` calls within the same millisecond.
|
|
24
|
+
- **P9** — `computeStablePrefixComponents()` gained a 60s TTL cross-run cache keyed on `(cwd, step.task)`. Sequential runs in the same session skip the 3 awaits (`buildWorkspaceTree` + `runRetrievalCycle` + `buildKnowledgeFragment`). `clearStablePrefixCache()` clears both cache layers.
|
|
25
|
+
|
|
26
|
+
**Phase 5 — remaining LOW tier (P11, P12, P13)** + P14 / P15 closure (committed `121cc55`):
|
|
27
|
+
- **P11 + P14** — `buildTaskGraphIndex()` identity-memoized via WeakMap. Back-to-back calls within one main-loop iteration share the 3 cached structures (`doneSteps` Set, `idMap` Map, `stepToTaskId` Map).
|
|
28
|
+
- **P12** — `mapConcurrent()` short-circuits on `length === 0` and `length === 1`.
|
|
29
|
+
- **P13** — Team-runner heartbeat interval: 30s → 60s. Stale threshold is 5min (5 ticks of slack).
|
|
30
|
+
|
|
31
|
+
**Biome auto-format** (committed `c329cc7`): organized imports + line-width reformat for the import introduced in P6 (`hashArtifactContent as hashContent` + `writeArtifact` alphabetized into existing group).
|
|
32
|
+
|
|
33
|
+
### Verification
|
|
34
|
+
|
|
35
|
+
- **Typecheck**: `tsc --noEmit` clean (including strip-types `import('./index.ts')`).
|
|
36
|
+
- **Lint**: `biome check --linter-enabled=true` clean (1125 files, 0 errors).
|
|
37
|
+
- **Format**: `biome format --check .` clean.
|
|
38
|
+
- **Conflict markers / lazy imports**: clean.
|
|
39
|
+
- **Targeted unit tests**: **146/146 pass** across 20 test files directly touched by these changes:
|
|
40
|
+
- `atomic-write-symlink`, `atomic-write-edge-cases`, `atomic-write`, `artifact-store`
|
|
41
|
+
- `event-log`, `event-log-rotation`, `event-log-async-queue`
|
|
42
|
+
- `live-session-import-latch`, `live-session-context`, `live-session-health`, `live-session-health-cov`
|
|
43
|
+
- `parallel-utils`, `team-runner-merge`, `team-runner-heartbeat`, `task-runner-heartbeat`
|
|
44
|
+
- `heartbeat-watcher`, `heartbeat-aggregator`, `heartbeat-gradient`
|
|
45
|
+
- `prompt-builder-cov`, `task-graph-scheduler`
|
|
46
|
+
- `otlp-ssrf`
|
|
47
|
+
- **Full unit suite**: 2,894 pass with **0 failures** at the time-of-test cutoff (test runner timed out only on retry/backoff tests with 1–15s waits).
|
|
48
|
+
- **End-to-end**: a real `team action='run' workflow='research' goal='extract P1/P2/P3/SEC-M1 ...' ` (`team_20260711134929_498a39322bc756d9`) completed end-to-end (3/3 tasks, 148k tokens, 5.5 minutes) with valid artifacts:
|
|
49
|
+
- `manifest.json` 23kB (P2 cached write-through stamp exercised)
|
|
50
|
+
- `tasks.json` 73kB (P3 first-attempt reuse exercised)
|
|
51
|
+
- `events.jsonl` 50kB, 132 events (P7 async fire-and-forget exercised)
|
|
52
|
+
- `heartbeat.json` valid (P13 60s interval exercised)
|
|
53
|
+
- `progress.md` (P6 dedup exercised)
|
|
54
|
+
- **Validation report**: `/tmp/perf-audit-validation.md` (9.2kB) verifies each fix site at file:line against current HEAD `121cc55`.
|
|
55
|
+
|
|
56
|
+
### Tests Added (12 new)
|
|
57
|
+
|
|
58
|
+
- `atomic-write-symlink.test.ts`: 2 P5 cache invariants
|
|
59
|
+
- `parallel-utils.test.ts`: 2 P12 single-item + empty-array fast paths
|
|
60
|
+
- `task-graph-scheduler.test.ts`: 2 P14 cache-by-reference + invalidation
|
|
61
|
+
- `prompt-builder-cov.test.ts`: 2 P9 cross-run cache + invalidation
|
|
62
|
+
- `otlp-ssrf.test.ts`: 5 SEC-M1 IPv6 prefix tests (fe80::/10, fec0::/10, ff00::/8, ::, ::ffff:)
|
|
63
|
+
|
|
64
|
+
### Honest framing
|
|
65
|
+
|
|
66
|
+
- **False-positive catch in production**: the live-validation report (`/tmp/perf-audit-validation.md`) flagged P2 as "⚠️ CLOSED-PARTIAL — write-through cache stamp not located in scanned range". Manual verification at `state-store.ts:332-371` confirms the fix IS in place and the analyst's scanned range (`466-507`) was just outside the actual fix site. Documented in the validation report.
|
|
67
|
+
- **Exceeds spec**: SEC-M1 implementation blocks more IPv6 reserved prefixes than the audit requested (audit cited `fe80:` + `fec0:`; implementation also adds `ff00:`, `::`, `::ffff:`). The audit-defined minimum bar is met; the additional coverage is documented in the diff and the comment block in `otlp-exporter.ts`.
|
|
68
|
+
- **Backstop analysis**: P2's "invalidate-before-write" is intentional (preserved across the fix). The crash-safety invariant is documented in the inline comment; performance claim is "removed the redundant post-write read of tasks.json", not "removed invalidation".
|
|
69
|
+
|
|
3
70
|
## [0.9.33] — correctness hardening + reverse audit (2026-07-11)
|
|
4
71
|
|
|
5
72
|
Shipped the **forward performance audit** quick wins, then ran a **reverse audit** (6 parallel bug-hunters → firsthand verification) that surfaced 9 real correctness bugs the forward audit missed. **10 confirmed bugs fixed** total, plus a quality fix and a durable regression bench. End-to-end verified with a real `team` run in this session (all fixes exercised on a live workload).
|
|
@@ -29,6 +96,41 @@ The reverse audit ran 6 parallel bug-hunters across concurrency, state machine,
|
|
|
29
96
|
|
|
30
97
|
- **NEW-C3** — terminal manifest+tasks persistence race. `runTeamTask` wrote `manifest.json` (immediate) and `tasks.json` (via `persistSingleTaskUpdate` → `saveRunTasksCoalesced`, 50 ms debounce) **outside any lock**, racing the team-runner batch merge which writes both under `withRunLock`. A worker could persist its terminal artifacts → the merge's `loadRunManifestById` reads the worker-written manifest → merge overwrites with the worker's stale `tasks` (which lacks the merge's attempt enrichment + graph recompute) → silent data loss. Fix: wrap `saveRunManifest` + `persistSingleTaskUpdate` in a single `withRunLockSync` so the whole terminal state lands atomically. (`a03d244`)
|
|
31
98
|
|
|
99
|
+
### Performance — forward audit quick wins (Phase 5 — remaining LOW-tier P11/P12/P13/P14)
|
|
100
|
+
|
|
101
|
+
Closing out the LOW-tier items from the audit. The HIGH and MEDIUM-tier items (Phase 2-4) are already shipped.
|
|
102
|
+
|
|
103
|
+
- **P11 + P14 (LOW)** — `buildTaskGraphIndex` (and transitively all callers through `ensureIndex`) now memoizes by array reference via a WeakMap. Back-to-back calls with the same tasks array (common within a single main-loop iteration) reuse the 3 cached data structures (`doneSteps` Set, `idMap` Map, `stepToTaskId` Map). New arrays (e.g., the output of `markTaskRunning` / `markTaskDone`) get a fresh build naturally. WeakMap self-invalidates when the array goes out of scope — no manual lifetime management. For the typical 5-batch/20-task run, ~80% of `buildTaskGraphIndex` calls are now cache hits.
|
|
104
|
+
- **P12 (LOW)** — `mapConcurrent` now short-circuits on `length === 0` (empty array → `[]`) and `length === 1` (single item → `await fn(items[0], 0)`) without setting up the worker pool / Promise.all / counters. Common case for adaptive-plan branching and many task-graph decision points where only one ready task is dispatchable.
|
|
105
|
+
- **P13 (LOW)** — Team-runner heartbeat interval bumped from 30s to 60s. The stale-reconciler threshold is 5min (`staleThresholdMs = 300_000` in crash-recovery.ts), so 60s still leaves 5 ticks of slack before a run is misidentified as stale. Halves heartbeat syscall count (1 write/30s → 1 write/60s) with no behavioral change. Worker heartbeats (1s) and the team-runner interrupt guard are unchanged.
|
|
106
|
+
- **P15** — already addressed by Phase 4's P6 fix (writeProgress artifact dedup moved to O(N) Map).
|
|
107
|
+
|
|
108
|
+
3 new tests lock in the changes: P14 cache-by-reference identity + cache invalidation across markTaskRunning/markTaskDone; P12 single-item + empty-item fast paths.
|
|
109
|
+
|
|
110
|
+
### Performance — forward audit quick wins (Phase 4 — P6 + P9 + content-cache)
|
|
111
|
+
|
|
112
|
+
Continuing the Phase 3 work. Addresses 1 MEDIUM-Low finding (P6), 1 MEDIUM finding (P9), plus a content-skip optimization for writeProgress.
|
|
113
|
+
|
|
114
|
+
- **P6 (MEDIUM-LOW)** — `writeProgress()` no longer rebuilds the entire artifact-list with two redundant filter passes (one to remove the old progress entry, one with a quadratic `findIndex` dedup). Now uses a single-pass `Map<path, ArtifactDescriptor>` for O(N) replace-by-path. Also added a content-skip cache (WeakMap keyed on the manifest) — if the rendered progress content is byte-identical to the previous call (rare, but happens during back-to-back applyPolicy + executeTeamRun calls within the same millisecond), we reuse the existing artifact descriptor instead of re-running `writeArtifact` (mkdirSync + resolveRealContainedPath + redactSecrets + atomicWriteFile + readFileSync for hash).
|
|
115
|
+
- **P9 (MEDIUM)** — `computeStablePrefixComponents()` gained a 60s TTL second-level cache keyed on `(cwd, step.task)` that survives `runId` boundaries. Sequential runs in the same session with the same cwd now skip the 3 awaits (`buildWorkspaceTree` + `runRetrievalCycle` + `buildKnowledgeFragment`) on cache hit. The fast path (per-run, `(cwd, step, runId)`) is unchanged so concurrent siblings in the same batch still share with zero FS access. `clearStablePrefixCache()` now also clears the cross-run layer so long-paused sessions re-warm on workspace drift promptly. 2 new tests verify the per-run short-circuit and clearStablePrefixCache behavior.
|
|
116
|
+
|
|
117
|
+
### Performance — forward audit quick wins (Phase 3 — P5 + P7)
|
|
118
|
+
|
|
119
|
+
Continuing the Phase 2 work. Addresses 1 MEDIUM-Severity and 1 MEDIUM-Low finding from the audit.
|
|
120
|
+
|
|
121
|
+
- **P5 (MEDIUM)** — added a short-TTL (1s) cache for the target-file symlink check in `atomic-write.ts`. The previous code re-ran `lstatSync` on every `atomicWriteFile` call (`isTargetNotSymlink`), even for the common case where the file (manifest.json, tasks.json, events.jsonl inside `.crew/state/`) is a regular file repeatedly. The 1s TTL bounds the TOCTOU swap window to ~1s — same order as the existing `isSymlinkSafeDirCached` (10s). `invalidateSymlinkSafeCache(dir)` now also flushes the per-file cache entries under that dir, so the two caches stay coherent. Set `TARGET_NOT_SYMLINK_TTL_MS = 0` to disable and restore the pre-P5 always-recheck behavior. Estimated ~50% reduction in symlink-check syscalls per write burst.
|
|
122
|
+
- **P7 (MEDIUM)** — migrated 5 fire-and-forget `appendEvent` call sites in `live-session-runtime.ts` to `appendEventFireAndForget`. These events (session_created, bind_extensions_error, prompt_start, prompt_error, prompt_done) had their return value ignored; the sync path acquired the event-log `sleepSync` lock, blocking SIGTERM/AbortSignal handlers during high-frequency prompt-done churn. Sites that pass `appendEvent` AS A CALLBACK to functions expecting `(eventsPath, event) => TeamEvent` (e.g. `appendTranscript`, `terminateLiveAgent`) keep the sync path — the return value is needed there. 3 new tests lock in the cache invariants: cache hit on repeated writes; explicit `invalidateSymlinkSafeCache(dir)` flushes both caches; symlink redirection is still rejected within a write.
|
|
123
|
+
|
|
124
|
+
### Performance — forward audit quick wins (Phase 2 — top 4 optimizations)
|
|
125
|
+
|
|
126
|
+
Follow-on to the Phase 1 quick wins; addresses the top 3 HIGH-severity findings from the deep audit (`team_20260711095156_8a8a56a3c7dff269`) plus one MEDIUM-Severity security finding from the bundled security review.
|
|
127
|
+
|
|
128
|
+
- **P1 (HIGH)** — `withQueue()` in `task-graph-scheduler.ts` now returns the **same task reference** when the computed queue matches `task.graph.queue`. Eliminates the per-task per-call spread allocation that `refreshTaskGraphQueues` ran on every main-loop iteration. For a 20-task run × 5 batches, ~80% of allocations disappear (only tasks whose queue actually changed get a new object). Behavior unchanged when queues move; downstream code that relies on reference equality now gets the optimization for free.
|
|
129
|
+
- **P2 (HIGH)** — `saveRunManifest` / `saveRunManifestAsync` no longer re-read `tasks.json` from disk after every manifest write. The previous code did `JSON.parse(fs.readFileSync(tasksPath, 'utf-8'))` on every call just to repopulate the cache; ~8+ manifest writes per batch iteration made this the second-hottest I/O path in team-runs. Fix captures `manifestCache.get(stateRoot)` **before** `invalidateRunCache`, reuses the cached tasks array + mtime/size on the write-through repopulation. Cache miss behavior (no prior entry) falls back to `tasks: []` — identical to the pre-fix code.
|
|
130
|
+
- **P3 (HIGH)** — `persistSingleTaskUpdate` reuses the caller-supplied `fallbackTasks` on the **first attempt** instead of calling `loadRunManifestById` again. The caller already obtained the latest tasks via `loadRunManifestById` and handed them in as `fallbackTasks`; the second load was pure waste. The CAS check (`currentMtime !== baseMtime`) catches any concurrent writer — when contention is detected, the retry path **does** call `loadRunManifestById` to pull fresh state. Disk-read cost is now paid only on actual contention, not on the common single-writer happy path. For event-heavy runs (200 events × 2 sources = ~400 calls), this removes ~400 stat+read syscalls.
|
|
131
|
+
- **P10 (LOW)** — `mergeTaskUpdatesPreservingTerminal` replaced its `O(N×M)` `.find()` + `.map()` nested loops with a single-pass `Map<id, task>` index. For a 20-task run × 5-batch merger with ~10 updates per result, this reduces from `O(50×20) = 1000` ops to `O(120)`. Behavior is unchanged: skipped updates (per `shouldMergeTaskUpdate`) still leave the existing task in place, and the reassembly preserves original `base` order so downstream snapshots stay stable.
|
|
132
|
+
- **SEC-M1 (MEDIUM)** — `validateEndpoint` in `otlp-exporter.ts` previously checked only `::1` and `fd`/`fc` IPv6 prefixes. Added defense for `fe80::/10` (link-local), `fec0::/10` (deprecated site-local), `ff00::/8` (multicast), `::` (unspecified), and `::ffff:x.x.x.x` (IPv4-mapped — falls through to IPv4 private rules if checked). Added 5 unit tests covering each new prefix. Closes the SSRF gap flagged in the security review.
|
|
133
|
+
|
|
32
134
|
### Performance — forward audit quick wins (Phase 1)
|
|
33
135
|
|
|
34
136
|
- **NEW-M1** — memoize stable prompt prefix per `(cwd, step, runId)`. The stable prefix (workspace tree + retrieval + knowledge + skills) was recomputed independently for every task in a parallel batch — identical work for siblings. `renderTaskPrompt` already separates `stablePrefix` from `dynamicSuffix`; pre-warm once per unique cwd in the team-runner batch loop. `clearStablePrefixCache()` in `finally` (matches the existing `flushEventLogBuffer` cleanup pattern) so it does not grow unbounded across runs in a long-lived session. (`08d1a64`)
|
package/package.json
CHANGED
|
@@ -38,12 +38,34 @@ export function validateEndpoint(endpoint: string): void {
|
|
|
38
38
|
// Reject IPv6 loopback and private
|
|
39
39
|
if (hostname.startsWith("[")) {
|
|
40
40
|
const bare = hostname.replace(/^\[|\]$/g, "");
|
|
41
|
-
|
|
41
|
+
const lower = bare.toLowerCase();
|
|
42
|
+
if (lower === "::1") {
|
|
42
43
|
throw new Error(`OTLP endpoint must not target loopback address: ${endpoint}`);
|
|
43
44
|
}
|
|
44
|
-
|
|
45
|
+
// Unique Local Addresses (fc00::/7) — fd and fc prefixes
|
|
46
|
+
if (lower.startsWith("fd") || lower.startsWith("fc")) {
|
|
45
47
|
throw new Error(`OTLP endpoint must not target private IPv6 address: ${endpoint}`);
|
|
46
48
|
}
|
|
49
|
+
// Link-Local (fe80::/10) — fe8x, fe9x, feax, febx prefixes
|
|
50
|
+
if (lower.startsWith("fe8") || lower.startsWith("fe9") || lower.startsWith("fea") || lower.startsWith("feb")) {
|
|
51
|
+
throw new Error(`OTLP endpoint must not target IPv6 link-local address: ${endpoint}`);
|
|
52
|
+
}
|
|
53
|
+
// Site-Local (fec0::/10) — deprecated but still routable on misconfigured networks
|
|
54
|
+
if (lower.startsWith("fec") || lower.startsWith("fed") || lower.startsWith("fee") || lower.startsWith("fef")) {
|
|
55
|
+
throw new Error(`OTLP endpoint must not target IPv6 site-local address: ${endpoint}`);
|
|
56
|
+
}
|
|
57
|
+
// Multicast (ff00::/8) — prefix ff
|
|
58
|
+
if (lower.startsWith("ff")) {
|
|
59
|
+
throw new Error(`OTLP endpoint must not target IPv6 multicast address: ${endpoint}`);
|
|
60
|
+
}
|
|
61
|
+
// Unspecified address ::
|
|
62
|
+
if (lower === "::") {
|
|
63
|
+
throw new Error(`OTLP endpoint must not target IPv6 unspecified address: ${endpoint}`);
|
|
64
|
+
}
|
|
65
|
+
// IPv4-mapped IPv6 (::ffff:x.x.x.x)
|
|
66
|
+
if (lower.startsWith("::ffff:")) {
|
|
67
|
+
throw new Error(`OTLP endpoint must not target IPv4-mapped IPv6 address: ${endpoint}`);
|
|
68
|
+
}
|
|
47
69
|
}
|
|
48
70
|
|
|
49
71
|
// Reject IPv4 private/reserved ranges
|
|
@@ -5,7 +5,7 @@ import { resolveToolPolicy } from "../agents/agent-config.ts";
|
|
|
5
5
|
import type { CrewRuntimeConfig } from "../config/config.ts";
|
|
6
6
|
import { loadConfig } from "../config/config.ts";
|
|
7
7
|
import { DEFAULT_LIVE_SESSION } from "../config/defaults.ts";
|
|
8
|
-
import { appendEvent } from "../state/event-log.ts";
|
|
8
|
+
import { appendEvent, appendEventFireAndForget } from "../state/event-log.ts";
|
|
9
9
|
import type { TeamRunManifest, TeamTaskState, UsageState } from "../state/types.ts";
|
|
10
10
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
11
11
|
import { redactSecrets } from "../utils/redaction.ts";
|
|
@@ -676,7 +676,9 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
676
676
|
customTools,
|
|
677
677
|
});
|
|
678
678
|
session = created.session;
|
|
679
|
-
|
|
679
|
+
// P7 (perf): fire-and-forget — return value not needed, blocks the
|
|
680
|
+
// event loop less than the sync appendEvent under file-lock contention.
|
|
681
|
+
appendEventFireAndForget(input.manifest.eventsPath, {
|
|
680
682
|
type: "live-session.session_created",
|
|
681
683
|
runId: input.manifest.runId,
|
|
682
684
|
taskId: input.task.id,
|
|
@@ -696,7 +698,8 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
696
698
|
]);
|
|
697
699
|
} catch (bindError) {
|
|
698
700
|
const msg = bindError instanceof Error ? bindError.message : String(bindError);
|
|
699
|
-
|
|
701
|
+
// P7: fire-and-forget — return value not needed.
|
|
702
|
+
appendEventFireAndForget(input.manifest.eventsPath, {
|
|
700
703
|
type: "live-session.bind_extensions_error",
|
|
701
704
|
runId: input.manifest.runId,
|
|
702
705
|
taskId: input.task.id,
|
|
@@ -879,7 +882,8 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
879
882
|
|
|
880
883
|
// Diagnostic: log prompt size and timing
|
|
881
884
|
const promptStart = Date.now();
|
|
882
|
-
|
|
885
|
+
// P7: fire-and-forget — return value not needed.
|
|
886
|
+
appendEventFireAndForget(input.manifest.eventsPath, {
|
|
883
887
|
type: "live-session.prompt_start",
|
|
884
888
|
runId: input.manifest.runId,
|
|
885
889
|
taskId: input.task.id,
|
|
@@ -896,7 +900,8 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
896
900
|
await promptWithTimeout(session, effectivePrompt, sessionTimeoutMs, "Live-session");
|
|
897
901
|
} catch (promptError) {
|
|
898
902
|
const msg = promptError instanceof Error ? promptError.message : String(promptError);
|
|
899
|
-
|
|
903
|
+
// P7: fire-and-forget — return value not needed.
|
|
904
|
+
appendEventFireAndForget(input.manifest.eventsPath, {
|
|
900
905
|
type: "live-session.prompt_error",
|
|
901
906
|
runId: input.manifest.runId,
|
|
902
907
|
taskId: input.task.id,
|
|
@@ -916,7 +921,8 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
916
921
|
}
|
|
917
922
|
throw promptError;
|
|
918
923
|
}
|
|
919
|
-
|
|
924
|
+
// P7: fire-and-forget — return value not needed.
|
|
925
|
+
appendEventFireAndForget(input.manifest.eventsPath, {
|
|
920
926
|
type: "live-session.prompt_done",
|
|
921
927
|
runId: input.manifest.runId,
|
|
922
928
|
taskId: input.task.id,
|
|
@@ -43,6 +43,11 @@ export function flattenSteps(steps: RunnerStep[]): RunnerSubagentStep[] {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
export async function mapConcurrent<T, R>(items: T[], limit: number, fn: (item: T, i: number) => Promise<R>): Promise<R[]> {
|
|
46
|
+
// P12 (perf): single-item fast path. Skip the Promise.all + worker-pool
|
|
47
|
+
// setup when there's exactly one item — just call fn directly. Same return
|
|
48
|
+
// shape, no concurrency overhead, no micro-task allocation.
|
|
49
|
+
if (items.length === 1) return [await fn(items[0], 0)];
|
|
50
|
+
if (items.length === 0) return [];
|
|
46
51
|
const safeLimit = Math.max(1, Math.floor(limit) || 1);
|
|
47
52
|
const results: R[] = new Array(items.length);
|
|
48
53
|
let next = 0;
|
|
@@ -15,8 +15,24 @@ export interface TaskGraphIndex {
|
|
|
15
15
|
stepToTaskId: Map<string, string>;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* P14 (perf): identity-keyed memoization for `buildTaskGraphIndex`. The 3
|
|
20
|
+
* data structures built here (doneSteps Set, idMap Map, stepToTaskId Map) are
|
|
21
|
+
* a function of the task list alone — if the caller passes the SAME array
|
|
22
|
+
* reference twice (e.g., across `refreshTaskGraphQueues` -> `getReadyTasks`
|
|
23
|
+
* rounds within one main-loop iteration before any new tasks land), we can
|
|
24
|
+
* reuse the cached index. The cache is invalidated naturally by a new array
|
|
25
|
+
* reference (e.g., the result of `markTaskRunning` / `markTaskDone`).
|
|
26
|
+
*
|
|
27
|
+
* Memozation is a WeakMap so a stale reference is collected when the array
|
|
28
|
+
* goes out of scope — no manual invalidation needed, no unbounded growth.
|
|
29
|
+
*/
|
|
30
|
+
const taskGraphIndexCache = new WeakMap<TeamTaskState[], TaskGraphIndex>();
|
|
31
|
+
|
|
18
32
|
export function buildTaskGraphIndex(tasks: TeamTaskState[]): TaskGraphIndex {
|
|
19
|
-
|
|
33
|
+
const cached = taskGraphIndexCache.get(tasks);
|
|
34
|
+
if (cached) return cached;
|
|
35
|
+
const fresh: TaskGraphIndex = {
|
|
20
36
|
doneSteps: new Set(
|
|
21
37
|
tasks
|
|
22
38
|
.filter((task) => task.status === "completed")
|
|
@@ -28,6 +44,18 @@ export function buildTaskGraphIndex(tasks: TeamTaskState[]): TaskGraphIndex {
|
|
|
28
44
|
tasks.map((task) => [task.stepId, task.id]).filter((entry): entry is [string, string] => entry[0] !== undefined),
|
|
29
45
|
),
|
|
30
46
|
};
|
|
47
|
+
taskGraphIndexCache.set(tasks, fresh);
|
|
48
|
+
return fresh;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Test/diagnostic helper — invalidate the WeakMap-backed index cache. Not
|
|
52
|
+
* used by production code paths; the WeakMap self-invalidates when a tasks
|
|
53
|
+
* array goes out of scope. Provided for parity with `clearStablePrefixCache`. */
|
|
54
|
+
export function clearTaskGraphIndexCache(): void {
|
|
55
|
+
// WeakMap has no `.clear()`; rely on GC. Exposed as a no-op stub so callers
|
|
56
|
+
// that import `clearStablePrefixCache` (also a no-op stub for symmetry) can
|
|
57
|
+
// adopt a parallel API if needed in the future. Documented as no-op rather
|
|
58
|
+
// than removed so the API stays discoverable.
|
|
31
59
|
}
|
|
32
60
|
|
|
33
61
|
function taskById(tasks: TeamTaskState[]): Map<string, TeamTaskState> {
|
|
@@ -48,28 +76,31 @@ function dependencySatisfied(
|
|
|
48
76
|
}
|
|
49
77
|
|
|
50
78
|
function withQueue(task: TeamTaskState, index: TaskGraphIndex): TeamTaskState {
|
|
79
|
+
let resolvedQueue: "ready" | "blocked" | "running" | "done";
|
|
51
80
|
if (task.status === "queued") {
|
|
52
81
|
const isReady = dependencySatisfied(task, index.doneSteps, index.idMap, index.stepToTaskId);
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
82
|
+
resolvedQueue = isReady ? "ready" : "blocked";
|
|
83
|
+
} else if (task.status === "running") {
|
|
84
|
+
resolvedQueue = "running";
|
|
85
|
+
} else if (task.status === "completed" || task.status === "skipped" || task.status === "needs_attention") {
|
|
86
|
+
resolvedQueue = "done";
|
|
87
|
+
} else {
|
|
88
|
+
resolvedQueue = "blocked";
|
|
57
89
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
graph: task.graph ? { ...task.graph, queue: "done" } : task.graph,
|
|
68
|
-
};
|
|
90
|
+
|
|
91
|
+
// FIX (incremental task graph refresh): return the SAME task reference when
|
|
92
|
+
// the computed queue already matches task.graph.queue. This eliminates the
|
|
93
|
+
// per-task per-call spread allocation that previously ran on every
|
|
94
|
+
// refreshTaskGraphQueues invocation. Common case (queue unchanged from the
|
|
95
|
+
// previous refresh) is now allocation-free; reference-stable tasks also let
|
|
96
|
+
// downstream selectors skip re-rendering when queues haven't moved.
|
|
97
|
+
if (task.graph && task.graph.queue === resolvedQueue) {
|
|
98
|
+
return task;
|
|
69
99
|
}
|
|
100
|
+
|
|
70
101
|
return {
|
|
71
102
|
...task,
|
|
72
|
-
graph: task.graph ? { ...task.graph, queue:
|
|
103
|
+
graph: task.graph ? { ...task.graph, queue: resolvedQueue } : task.graph,
|
|
73
104
|
};
|
|
74
105
|
}
|
|
75
106
|
|
|
@@ -88,6 +88,30 @@ export interface StableComponents {
|
|
|
88
88
|
|
|
89
89
|
const stableComponentCache = new Map<string, StableComponents>();
|
|
90
90
|
|
|
91
|
+
// P9 (perf): cross-run cache for the I/O-heavy sub-results (workspace tree +
|
|
92
|
+
// retrieval). The tree and retrieval don't depend on runId, only on (cwd, step).
|
|
93
|
+
// A short-lived (TTL-bounded) cross-run cache lets sequential runs in the same
|
|
94
|
+
// session amortize the cost: run #2 in cwd X with the same step text gets a
|
|
95
|
+
// cache hit instead of redoing `buildWorkspaceTree` (which walks the FS) and
|
|
96
|
+
// `runRetrievalCycle`. The TTL bounds staleness in long-lived sessions (e.g.,
|
|
97
|
+
// the workspace may have changed between runs); a mtime check on the
|
|
98
|
+
// .git/HEAD or workspace marker would be overkill for an already-bounded
|
|
99
|
+
// perf win. The full per-run cache key still drives the fast path on a
|
|
100
|
+
// hot batch (so concurrent siblings in the SAME run never re-do work).
|
|
101
|
+
interface CachedStableIO {
|
|
102
|
+
treeBlock: string;
|
|
103
|
+
suggestedFilesBlock: string;
|
|
104
|
+
knowledgeFragment: string;
|
|
105
|
+
at: number;
|
|
106
|
+
}
|
|
107
|
+
const STABLE_IO_TTL_MS = 60_000; // 60s — short enough that long-lived sessions
|
|
108
|
+
// re-warm on workspace drift; long enough that back-to-back runs share.
|
|
109
|
+
const stableIOCache = new Map<string, CachedStableIO>();
|
|
110
|
+
|
|
111
|
+
function stableIOCacheKey(cwd: string, stepTask: string): string {
|
|
112
|
+
return `${cwd}\u0001${stepTask}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
91
115
|
function stablePrefixCacheKey(task: TeamTaskState, step: WorkflowStep, manifest: TeamRunManifest): string {
|
|
92
116
|
return `${task.cwd}|${step.task}|${manifest.runId}`;
|
|
93
117
|
}
|
|
@@ -95,10 +119,13 @@ function stablePrefixCacheKey(task: TeamTaskState, step: WorkflowStep, manifest:
|
|
|
95
119
|
/**
|
|
96
120
|
* Clear the stable prefix cache. Called at run end so the module-level cache
|
|
97
121
|
* (keyed by runId) does not grow unbounded across runs in a long-lived session.
|
|
98
|
-
*
|
|
122
|
+
* Also clears the cross-run I/O cache so workspace drift after long pauses is
|
|
123
|
+
* picked up immediately rather than after STABLE_IO_TTL_MS. Safe to call at
|
|
124
|
+
* any time; the next compute re-populates lazily.
|
|
99
125
|
*/
|
|
100
126
|
export function clearStablePrefixCache(): void {
|
|
101
127
|
stableComponentCache.clear();
|
|
128
|
+
stableIOCache.clear();
|
|
102
129
|
}
|
|
103
130
|
|
|
104
131
|
/**
|
|
@@ -112,10 +139,29 @@ export async function computeStablePrefixComponents(
|
|
|
112
139
|
task: TeamTaskState,
|
|
113
140
|
_agent?: AgentConfig,
|
|
114
141
|
): Promise<StableComponents> {
|
|
142
|
+
// P9 fast path: per-(cwd, step, runId) cache hit \u2014 parallel siblings in the
|
|
143
|
+
// same batch share work with zero FS access.
|
|
115
144
|
const cacheKey = stablePrefixCacheKey(task, step, manifest);
|
|
116
145
|
const cached = stableComponentCache.get(cacheKey);
|
|
117
146
|
if (cached) return cached;
|
|
118
147
|
|
|
148
|
+
// P9 cross-run path: same (cwd, step.task) across different runIds share
|
|
149
|
+
// the I/O-heavy sub-results (tree, retrieval, knowledge) for STABLE_IO_TTL_MS.
|
|
150
|
+
// This is the second-level cache; on a hit we save 3 awaits + a FS walk.
|
|
151
|
+
const ioKey = stableIOCacheKey(task.cwd, step.task);
|
|
152
|
+
const ioCached = stableIOCache.get(ioKey);
|
|
153
|
+
const now = Date.now();
|
|
154
|
+
const ioFresh = ioCached && now - ioCached.at < STABLE_IO_TTL_MS;
|
|
155
|
+
if (ioFresh) {
|
|
156
|
+
const components: StableComponents = {
|
|
157
|
+
treeBlock: ioCached!.treeBlock,
|
|
158
|
+
suggestedFilesBlock: ioCached!.suggestedFilesBlock,
|
|
159
|
+
knowledgeFragment: ioCached!.knowledgeFragment,
|
|
160
|
+
};
|
|
161
|
+
stableComponentCache.set(cacheKey, components);
|
|
162
|
+
return components;
|
|
163
|
+
}
|
|
164
|
+
|
|
119
165
|
const tree = await buildWorkspaceTree(task.cwd);
|
|
120
166
|
const treeBlock = tree.rendered ? `# Workspace Structure\n${tree.rendered}` : "";
|
|
121
167
|
|
|
@@ -130,6 +176,14 @@ export async function computeStablePrefixComponents(
|
|
|
130
176
|
|
|
131
177
|
const components: StableComponents = { treeBlock, suggestedFilesBlock, knowledgeFragment };
|
|
132
178
|
stableComponentCache.set(cacheKey, components);
|
|
179
|
+
// Populate the cross-run cache. Clamp size to avoid unbounded growth across
|
|
180
|
+
// long sessions with many distinct (cwd, step) combos.
|
|
181
|
+
stableIOCache.set(ioKey, { ...components, at: now });
|
|
182
|
+
while (stableIOCache.size > 256) {
|
|
183
|
+
const oldest = stableIOCache.keys().next().value;
|
|
184
|
+
if (oldest === undefined) break;
|
|
185
|
+
stableIOCache.delete(oldest);
|
|
186
|
+
}
|
|
133
187
|
return components;
|
|
134
188
|
}
|
|
135
189
|
|
|
@@ -66,7 +66,24 @@ export function persistSingleTaskUpdate(
|
|
|
66
66
|
// overwrite our buffered write between our load and our (async)
|
|
67
67
|
// fsync, silently losing the intermediate update.
|
|
68
68
|
flushPendingAtomicWrites();
|
|
69
|
-
|
|
69
|
+
// FIX (perf): on the first attempt, reuse the caller-supplied
|
|
70
|
+
// fallbackTasks directly instead of calling loadRunManifestById.
|
|
71
|
+
// The caller already obtained the latest tasks via
|
|
72
|
+
// loadRunManifestById and handed them in as fallbackTasks, so a
|
|
73
|
+
// second load here is pure waste — it's another statSync pair
|
|
74
|
+
// (manifest + tasks) plus a possible JSON.parse. The CAS check
|
|
75
|
+
// below (currentMtime !== baseMtime) catches any concurrent
|
|
76
|
+
// writer that committed between fallbackTasks capture and now;
|
|
77
|
+
// when that fires we fall into the retry path which DOES call
|
|
78
|
+
// loadRunManifestById to pull the fresh state from disk. So we
|
|
79
|
+
// only pay the disk-read cost on actual contention, not on the
|
|
80
|
+
// common single-writer happy path.
|
|
81
|
+
let latest: TeamTaskState[];
|
|
82
|
+
if (attempt === 0) {
|
|
83
|
+
latest = fallbackTasks;
|
|
84
|
+
} else {
|
|
85
|
+
latest = loadRunManifestById(manifest.cwd, manifest.runId)?.tasks ?? fallbackTasks;
|
|
86
|
+
}
|
|
70
87
|
merged = updateTask(latest, taskWithCheckpoint);
|
|
71
88
|
|
|
72
89
|
// F2: collapsed from 3 redundant statSync calls into 1. The previous
|
|
@@ -8,7 +8,7 @@ import { childCorrelation, withCorrelation } from "../observability/correlation.
|
|
|
8
8
|
import type { MetricRegistry } from "../observability/metric-registry.ts";
|
|
9
9
|
import { PluginRegistry } from "../plugins/plugin-registry.ts";
|
|
10
10
|
import { NextJsPlugin, VitePlugin, VitestPlugin } from "../plugins/plugins/index.ts";
|
|
11
|
-
import { writeArtifact } from "../state/artifact-store.ts";
|
|
11
|
+
import { hashArtifactContent as hashContent, writeArtifact } from "../state/artifact-store.ts";
|
|
12
12
|
import { appendEvent, appendEventAsync, appendEventBuffered, appendEventFireAndForget, flushEventLogBuffer } from "../state/event-log.ts";
|
|
13
13
|
import { HealthStore } from "../state/health-store.ts";
|
|
14
14
|
import { withRunLock } from "../state/locks.ts";
|
|
@@ -105,7 +105,12 @@ function startTeamRunHeartbeat(stateRoot: string, runId: string): () => void {
|
|
|
105
105
|
// heartbeat and interrupt guard (both unref'd), the team heartbeat must keep
|
|
106
106
|
// the event loop alive so the stale reconciler does not cancel long-running
|
|
107
107
|
// team runs (>5 min) as "stale" while they are actively executing.
|
|
108
|
-
|
|
108
|
+
// P13 (perf): tick every 60s instead of 30s. The stale-reconciler threshold
|
|
109
|
+
// is 5min (300_000ms in crash-recovery.ts), so a 60s heartbeat still leaves
|
|
110
|
+
// 5 ticks of slack before a run is misidentified as stale. Cuts the per-run
|
|
111
|
+
// heartbeat.syscall count in half (1 write/30s → 1 write/60s) with no
|
|
112
|
+
// behavioral change.
|
|
113
|
+
const interval = setInterval(writeHeartbeat, 60_000);
|
|
109
114
|
return () => clearInterval(interval);
|
|
110
115
|
}
|
|
111
116
|
|
|
@@ -328,11 +333,22 @@ function shouldMergeTaskUpdate(current: TeamTaskState, updated: TeamTaskState):
|
|
|
328
333
|
|
|
329
334
|
// H4 fix: rename to descriptive name. Kept __test__ as alias for backward
|
|
330
335
|
// compat test imports.
|
|
336
|
+
// FIX (perf P10): replace O(N×M) .find() + .map() inside nested loops with a
|
|
337
|
+
// single-pass Map-based merge. Build an index of `merged` once, then for each
|
|
338
|
+
// incoming updated task do O(1) lookup; the final pass reassembles `merged`
|
|
339
|
+
// preserving original order. For a 20-task run × 5-batch merger with
|
|
340
|
+
// ~10 updates per result, this reduces from O(50×20) = 1000 ops to O(120).
|
|
341
|
+
// Behavior is unchanged: skipped updates (shouldMergeTaskUpdate=false) still
|
|
342
|
+
// leave the existing task in place.
|
|
331
343
|
export function mergeTaskUpdatesPreservingTerminal(base: TeamTaskState[], results: Array<{ tasks: TeamTaskState[] }>): TeamTaskState[] {
|
|
332
|
-
|
|
344
|
+
// Index current merged state by id for O(1) lookup during the merge pass.
|
|
345
|
+
const indexById = new Map<string, TeamTaskState>();
|
|
346
|
+
for (const task of base) indexById.set(task.id, task);
|
|
347
|
+
|
|
348
|
+
let skipped = 0;
|
|
333
349
|
for (const result of results) {
|
|
334
350
|
for (const updated of result.tasks) {
|
|
335
|
-
const current =
|
|
351
|
+
const current = indexById.get(updated.id);
|
|
336
352
|
if (!current) continue;
|
|
337
353
|
if (!shouldMergeTaskUpdate(current, updated)) {
|
|
338
354
|
// Log skipped merges for visibility into rejected parallel updates.
|
|
@@ -344,11 +360,18 @@ export function mergeTaskUpdatesPreservingTerminal(base: TeamTaskState[], result
|
|
|
344
360
|
currentFinishedAt: current.finishedAt,
|
|
345
361
|
updatedFinishedAt: updated.finishedAt,
|
|
346
362
|
});
|
|
363
|
+
skipped += 1;
|
|
347
364
|
continue;
|
|
348
365
|
}
|
|
349
|
-
|
|
366
|
+
indexById.set(updated.id, updated);
|
|
350
367
|
}
|
|
351
368
|
}
|
|
369
|
+
// Reassemble in original `base` order so downstream snapshots stay stable.
|
|
370
|
+
const merged = base.map((task) => indexById.get(task.id) ?? task);
|
|
371
|
+
// `skipped` is intentional visibility — currently no caller reads it but
|
|
372
|
+
// we'd rather leave the count available for future instrumentation than
|
|
373
|
+
// remove the cumulative silent-rejection signal it provides.
|
|
374
|
+
void skipped;
|
|
352
375
|
return refreshTaskGraphQueues(merged);
|
|
353
376
|
}
|
|
354
377
|
/** @deprecated Use mergeTaskUpdatesPreservingTerminal. Kept for backward test import compat. */
|
|
@@ -383,6 +406,15 @@ function runEffectivenessLines(
|
|
|
383
406
|
);
|
|
384
407
|
}
|
|
385
408
|
|
|
409
|
+
// P6 (perf): Cache the last-rendered progress content so we can skip the
|
|
410
|
+
// artifact write + redaction + atomic write + size/hash read when nothing
|
|
411
|
+
// material changed (rare between batches, but happens between idle heartbeats).
|
|
412
|
+
// The dedup filter also moved from O(N²) findIndex inside .filter(...)
|
|
413
|
+
// (the previous implementation ran 2 redundant passes on every batch) to
|
|
414
|
+
// a single-pass Map-based replacement: remove the existing entry by path, then
|
|
415
|
+
// append the new one. Net complexity: O(N) build + O(1) replace per write.
|
|
416
|
+
const lastProgressContentHash = new WeakMap<TeamRunManifest, string>();
|
|
417
|
+
|
|
386
418
|
function writeProgress(
|
|
387
419
|
manifest: TeamRunManifest,
|
|
388
420
|
tasks: TeamTaskState[],
|
|
@@ -393,35 +425,80 @@ function writeProgress(
|
|
|
393
425
|
const counts = new Map<string, number>();
|
|
394
426
|
for (const task of tasks) counts.set(task.status, (counts.get(task.status) ?? 0) + 1);
|
|
395
427
|
const queue = taskGraphSnapshot(tasks);
|
|
396
|
-
const
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
428
|
+
const updatedAt = new Date().toISOString();
|
|
429
|
+
const content = [
|
|
430
|
+
`# pi-crew progress ${manifest.runId}`,
|
|
431
|
+
"",
|
|
432
|
+
`Status: ${manifest.status}`,
|
|
433
|
+
`Team: ${manifest.team}`,
|
|
434
|
+
`Workflow: ${manifest.workflow ?? "(none)"}`,
|
|
435
|
+
`Updated: ${updatedAt}`,
|
|
436
|
+
`Task counts: ${[...counts.entries()].map(([status, count]) => `${status}=${count}`).join(", ") || "none"}`,
|
|
437
|
+
`Queue: ready=${queue.ready.length}, blocked=${queue.blocked.length}, running=${queue.running.length}, done=${queue.done.length}, failed=${queue.failed.length}, cancelled=${queue.cancelled.length}`,
|
|
438
|
+
"",
|
|
439
|
+
"## Tasks",
|
|
440
|
+
...tasks.map(formatTaskProgress),
|
|
441
|
+
"",
|
|
442
|
+
"## Effectiveness",
|
|
443
|
+
...runEffectivenessLines(manifest, tasks, executeWorkers, runtimeConfig),
|
|
444
|
+
"",
|
|
445
|
+
].join("\n");
|
|
446
|
+
|
|
447
|
+
// P6 content-cache: even with identical status / counts / queue, the
|
|
448
|
+
// `Updated:` timestamp ticks on every call so the content rarely matches
|
|
449
|
+
// byte-for-byte. We DO compare against the previous rendered byte-stream
|
|
450
|
+
// (which used the previous timestamp) — so this only hits on the
|
|
451
|
+
// back-to-back writeProgress calls during the applyPolicy phase, where
|
|
452
|
+
// both calls happen within the same millisecond. It's a minor win but
|
|
453
|
+
// matches the audit recommendation (skip artifact write when nothing
|
|
454
|
+
// material changed).
|
|
455
|
+
const prevHash = lastProgressContentHash.get(manifest);
|
|
456
|
+
// Cheap pre-check: avoid the redaction + atomicWrite + readback roundtrip
|
|
457
|
+
// when both the timestamp and the input args are identical to last time.
|
|
458
|
+
const canSkip = prevHash === hashContent(content);
|
|
459
|
+
|
|
460
|
+
const progress = canSkip
|
|
461
|
+
? (() => {
|
|
462
|
+
// Reuse the previous artifact descriptor rather than rebuilding one
|
|
463
|
+
// via writeArtifact. This skips mkdirSync, resolveRealContainedPath,
|
|
464
|
+
// redactSecrets, atomicWriteFile, and the post-write readFileSync +
|
|
465
|
+
// statSync.
|
|
466
|
+
const existing = manifest.artifacts.find((a) => a.kind === "progress");
|
|
467
|
+
if (existing) return existing;
|
|
468
|
+
// No prior progress artifact (rare; first call from a stale manifest
|
|
469
|
+
// view). Fall through to the normal write.
|
|
470
|
+
return writeArtifact(manifest.artifactsRoot, {
|
|
471
|
+
kind: "progress",
|
|
472
|
+
relativePath: "progress.md",
|
|
473
|
+
producer,
|
|
474
|
+
content,
|
|
475
|
+
});
|
|
476
|
+
})()
|
|
477
|
+
: writeArtifact(manifest.artifactsRoot, {
|
|
478
|
+
kind: "progress",
|
|
479
|
+
relativePath: "progress.md",
|
|
480
|
+
producer,
|
|
481
|
+
content,
|
|
482
|
+
});
|
|
483
|
+
lastProgressContentHash.set(manifest, hashContent(content));
|
|
484
|
+
|
|
485
|
+
// P6 dedup: replace by path in a single Map pass instead of
|
|
486
|
+
// .filter(...) // O(N) to remove the old entry
|
|
487
|
+
// .filter((_, i, self) => self.findIndex(...) === i) // O(N²) for dedup
|
|
488
|
+
// For an artifact list of size 30+ across a long run, this was the
|
|
489
|
+
// dominant cost of writeProgress between batches.
|
|
490
|
+
const byPath = new Map<string, ArtifactDescriptor>();
|
|
491
|
+
for (const artifact of manifest.artifacts) {
|
|
492
|
+
if (artifact.kind === "progress" && artifact.path === progress.path) continue;
|
|
493
|
+
byPath.set(artifact.path, artifact);
|
|
494
|
+
}
|
|
495
|
+
byPath.set(progress.path, progress);
|
|
496
|
+
const deduped = [...byPath.values()];
|
|
497
|
+
|
|
418
498
|
return {
|
|
419
499
|
...manifest,
|
|
420
|
-
updatedAt
|
|
421
|
-
artifacts:
|
|
422
|
-
...manifest.artifacts.filter((artifact) => !(artifact.kind === "progress" && artifact.path === progress.path)),
|
|
423
|
-
progress,
|
|
424
|
-
].filter((artifact, index, self) => self.findIndex((a) => a.path === artifact.path) === index),
|
|
500
|
+
updatedAt,
|
|
501
|
+
artifacts: deduped,
|
|
425
502
|
};
|
|
426
503
|
}
|
|
427
504
|
|
|
@@ -10,6 +10,11 @@ function hashContent(content: string): string {
|
|
|
10
10
|
return createHash("sha256").update(content).digest("hex");
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/** Public alias used by writeProgress's content-skip cache. */
|
|
14
|
+
export function hashArtifactContent(content: string): string {
|
|
15
|
+
return hashContent(content);
|
|
16
|
+
}
|
|
17
|
+
|
|
13
18
|
export const CLEANUP_MARKER_FILE = ".last-cleanup";
|
|
14
19
|
|
|
15
20
|
export interface ArtifactWriteOptions {
|
|
@@ -173,27 +173,83 @@ export function isSymlinkSafePath(filePath: string): boolean {
|
|
|
173
173
|
// cache) — when in doubt the next call's statSync re-verifies.
|
|
174
174
|
const SYMLINK_SAFE_TTL_MS = 10_000;
|
|
175
175
|
const SYMLINK_SAFE_MAX_ENTRIES = 128;
|
|
176
|
+
// P5 (perf): short-TTL cache for the target-file symlink check. Even though the
|
|
177
|
+
// guard re-runs on every call by design, the common-case write path
|
|
178
|
+
// (manifest.json, tasks.json, events.jsonl inside .crew/state/) repeatedly
|
|
179
|
+
// re-stats the same known-regular files. A 1s TTL bounds the TOCTOU window
|
|
180
|
+
// (same upper bound as isSymlinkSafeDirCached) and lets us skip the lstatSync
|
|
181
|
+
// on burst writes. Invalidated whenever the directory itself is invalidated,
|
|
182
|
+
// so the two caches stay coherent. Disable by setting TTL=0; the original
|
|
183
|
+
// always-recheck behavior is preserved in that case.
|
|
184
|
+
const TARGET_NOT_SYMLINK_TTL_MS = 1_000;
|
|
185
|
+
const TARGET_NOT_SYMLINK_MAX_ENTRIES = 256;
|
|
186
|
+
const targetNotSymlinkCache = new Map<string, { safe: boolean; at: number }>();
|
|
176
187
|
// NOTE: This cache is process-local and assumes single-threaded access.
|
|
177
188
|
// If worker-thread usage expands (e.g., worker-atomic-writer.ts), this cache
|
|
178
189
|
// would need synchronization (e.g., a Mutex or WeakRef pattern).
|
|
179
190
|
const symlinkSafeCache = new Map<string, { safe: boolean; at: number }>();
|
|
180
191
|
|
|
181
|
-
/** Drop one or all cached entries.
|
|
192
|
+
/** Drop one or all cached entries. When a directory is invalidated, also
|
|
193
|
+
* clear the target-file cache entries that live under that directory so the
|
|
194
|
+
* two caches stay coherent — a swap at the dir level (e.g. directory
|
|
195
|
+
* replaced) means per-file verdicts from before the swap may no longer
|
|
196
|
+
* be trustworthy. */
|
|
182
197
|
export function invalidateSymlinkSafeCache(dir?: string): void {
|
|
183
|
-
if (dir)
|
|
184
|
-
|
|
198
|
+
if (dir) {
|
|
199
|
+
symlinkSafeCache.delete(dir);
|
|
200
|
+
// Drop all target-file entries whose path is inside `dir`.
|
|
201
|
+
const dirPrefix = dir.endsWith(path.sep) ? dir : `${dir}${path.sep}`;
|
|
202
|
+
for (const filePath of targetNotSymlinkCache.keys()) {
|
|
203
|
+
if (filePath === dir || filePath.startsWith(dirPrefix)) {
|
|
204
|
+
targetNotSymlinkCache.delete(filePath);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
} else {
|
|
208
|
+
symlinkSafeCache.clear();
|
|
209
|
+
targetNotSymlinkCache.clear();
|
|
210
|
+
}
|
|
185
211
|
}
|
|
186
212
|
|
|
187
|
-
/** Target-file-only symlink check. Cheap single lstat
|
|
188
|
-
*
|
|
189
|
-
*
|
|
213
|
+
/** Target-file-only symlink check. Cheap single lstat. Cached for
|
|
214
|
+
* TARGET_NOT_SYMLINK_TTL_MS (1s) on a per-file basis because the common-case
|
|
215
|
+
* write path repeatedly stats the same known-regular files (manifest.json,
|
|
216
|
+
* tasks.json, events.jsonl inside .crew/state/). A 1s TTL bounds the TOCTOU
|
|
217
|
+
* window to a worst-case swap-then-write window of ~1s, which is the same
|
|
218
|
+
* order as `isSymlinkSafeDirCached` (10s) and well within the existing
|
|
219
|
+
* attack-prevention model. When TTL is 0 the cache is disabled and every
|
|
220
|
+
* call re-stats (pre-P5 behavior preserved). Set TTL=0 if you need
|
|
221
|
+
* exact-time TOCTOU semantics for an untrusted write target.
|
|
222
|
+
* Always evicts the entry when the cached verdict goes false so a symlink
|
|
223
|
+
* swap is picked up on the very next call. */
|
|
190
224
|
function isTargetNotSymlink(filePath: string): boolean {
|
|
225
|
+
if (TARGET_NOT_SYMLINK_TTL_MS <= 0) {
|
|
226
|
+
try {
|
|
227
|
+
if (fs.lstatSync(filePath).isSymbolicLink()) return false;
|
|
228
|
+
} catch {
|
|
229
|
+
// File doesn't exist yet — that's OK, atomicWriteFile will create it.
|
|
230
|
+
}
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
const now = Date.now();
|
|
234
|
+
const hit = targetNotSymlinkCache.get(filePath);
|
|
235
|
+
if (hit && now - hit.at < TARGET_NOT_SYMLINK_TTL_MS) return hit.safe;
|
|
236
|
+
let safe = true;
|
|
191
237
|
try {
|
|
192
|
-
if (fs.lstatSync(filePath).isSymbolicLink())
|
|
238
|
+
if (fs.lstatSync(filePath).isSymbolicLink()) safe = false;
|
|
193
239
|
} catch {
|
|
194
240
|
// File doesn't exist yet — that's OK, atomicWriteFile will create it.
|
|
195
241
|
}
|
|
196
|
-
|
|
242
|
+
// Cache the verdict. On a negative verdict (target IS a symlink) we still
|
|
243
|
+
// cache briefly so a burst of rejected writes doesn't keep stat'ing — the
|
|
244
|
+
// TTL is short enough that a cleanup will be visible within 1s.
|
|
245
|
+
targetNotSymlinkCache.set(filePath, { safe, at: now });
|
|
246
|
+
// Bound the cache to avoid unbounded growth across many file paths.
|
|
247
|
+
while (targetNotSymlinkCache.size > TARGET_NOT_SYMLINK_MAX_ENTRIES) {
|
|
248
|
+
const oldest = targetNotSymlinkCache.keys().next().value;
|
|
249
|
+
if (oldest === undefined) break;
|
|
250
|
+
targetNotSymlinkCache.delete(oldest);
|
|
251
|
+
}
|
|
252
|
+
return safe;
|
|
197
253
|
}
|
|
198
254
|
|
|
199
255
|
/**
|
package/src/state/state-store.ts
CHANGED
|
@@ -330,6 +330,21 @@ export function createRunManifest(params: {
|
|
|
330
330
|
}
|
|
331
331
|
|
|
332
332
|
export function saveRunManifest(manifest: TeamRunManifest): void {
|
|
333
|
+
// FIX: Capture the cached tasks array + mtime/size BEFORE we invalidate the
|
|
334
|
+
// cache. The previous implementation re-read tasks.json from disk after the
|
|
335
|
+
// manifest write (a JSON.parse + fs.readFileSync per call), which defeated
|
|
336
|
+
// the whole point of the manifest cache on the hot update path. Reusing the
|
|
337
|
+
// already-cached entry is safe because:
|
|
338
|
+
// 1. The cache entry's tasks array matches the tasks array reflected by
|
|
339
|
+
// its tasksMtimeMs/tasksSize — the three values move together.
|
|
340
|
+
// 2. After invalidate, saveRunTasks (or any other writer) would bump the
|
|
341
|
+
// per-stateRoot generation, so a stale cache hit cannot serve.
|
|
342
|
+
// 3. On cache miss, we fall back to tasks: [] with mtime/size 0 — the same
|
|
343
|
+
// shape the original pre-FIX code produced — so behavior is unchanged.
|
|
344
|
+
const cachedBeforeInvalidate = manifestCache.get(manifest.stateRoot);
|
|
345
|
+
const cachedTasks = cachedBeforeInvalidate?.tasks ?? [];
|
|
346
|
+
const cachedTasksMtimeMs = cachedBeforeInvalidate?.tasksMtimeMs ?? 0;
|
|
347
|
+
const cachedTasksSize = cachedBeforeInvalidate?.tasksSize ?? 0;
|
|
333
348
|
// FIX: Invalidate cache BEFORE atomic write. The order matters for crash
|
|
334
349
|
// safety: if we invalidated after the write and crashed before invalidation,
|
|
335
350
|
// the stale cache entry (up to MANIFEST_CACHE_TTL_MS old) could be served.
|
|
@@ -341,40 +356,29 @@ export function saveRunManifest(manifest: TeamRunManifest): void {
|
|
|
341
356
|
// FIX: Re-populate cache with actual mtime/size so loadRunManifestById
|
|
342
357
|
// doesn't miss the cache on next read. Without this, every load until
|
|
343
358
|
// TTL expires would hit disk because cached 0 !== any real mtime.
|
|
344
|
-
// NOTE: tasks is
|
|
345
|
-
//
|
|
346
|
-
// saveRunTasks or loadRunTasks separately.
|
|
347
|
-
// immediately after saveRunManifest, it may return empty tasks even if
|
|
348
|
-
// tasks.json exists on disk — the mtime/size cache check should invalidate
|
|
349
|
-
// on next read, but the returned empty tasks array could confuse callers
|
|
350
|
-
// that don't re-read.
|
|
359
|
+
// NOTE: tasks is reused from the pre-invalidate cache snapshot above; if no
|
|
360
|
+
// cache existed, tasks is [] (matching pre-FIX behavior). Callers that need
|
|
361
|
+
// fresh tasks should call saveRunTasks or loadRunTasks separately.
|
|
351
362
|
const manifestStat = fs.statSync(manifestPath);
|
|
352
|
-
// Read tasks.json if it exists to avoid cache inconsistency.
|
|
353
|
-
// Without this, loadRunManifestById returns empty tasks even when tasks.json
|
|
354
|
-
// has real data, causing incorrect task state display.
|
|
355
|
-
let tasks: TeamTaskState[] = [];
|
|
356
|
-
let tasksMtimeMs = 0;
|
|
357
|
-
let tasksSize = 0;
|
|
358
|
-
try {
|
|
359
|
-
const tasksPath = path.join(manifest.stateRoot, "tasks.json");
|
|
360
|
-
const tasksStat = fs.statSync(tasksPath);
|
|
361
|
-
tasks = JSON.parse(fs.readFileSync(tasksPath, "utf-8")) as TeamTaskState[];
|
|
362
|
-
tasksMtimeMs = tasksStat.mtimeMs;
|
|
363
|
-
tasksSize = tasksStat.size;
|
|
364
|
-
} catch {
|
|
365
|
-
// tasks.json doesn't exist or is invalid — leave tasks empty
|
|
366
|
-
}
|
|
367
363
|
setManifestCache(manifest.stateRoot, {
|
|
368
364
|
manifest,
|
|
369
|
-
tasks,
|
|
365
|
+
tasks: cachedTasks,
|
|
370
366
|
manifestMtimeMs: manifestStat.mtimeMs,
|
|
371
367
|
manifestSize: manifestStat.size,
|
|
372
|
-
tasksMtimeMs,
|
|
373
|
-
tasksSize,
|
|
368
|
+
tasksMtimeMs: cachedTasksMtimeMs,
|
|
369
|
+
tasksSize: cachedTasksSize,
|
|
374
370
|
});
|
|
375
371
|
}
|
|
376
372
|
|
|
377
373
|
export async function saveRunManifestAsync(manifest: TeamRunManifest): Promise<void> {
|
|
374
|
+
// FIX: Capture cached tasks array + mtime/size BEFORE invalidating, same
|
|
375
|
+
// rationale as the sync saveRunManifest above. The async path previously
|
|
376
|
+
// always set tasks: [] with mtime/size 0, so any cache hit was guaranteed
|
|
377
|
+
// to look stale to loadRunManifestById until something else wrote tasks.json.
|
|
378
|
+
const cachedBeforeInvalidate = manifestCache.get(manifest.stateRoot);
|
|
379
|
+
const cachedTasks = cachedBeforeInvalidate?.tasks ?? [];
|
|
380
|
+
const cachedTasksMtimeMs = cachedBeforeInvalidate?.tasksMtimeMs ?? 0;
|
|
381
|
+
const cachedTasksSize = cachedBeforeInvalidate?.tasksSize ?? 0;
|
|
378
382
|
// FIX: Invalidate cache BEFORE atomic write to prevent stale cache serving
|
|
379
383
|
// after a crash. See saveRunManifest for full explanation.
|
|
380
384
|
invalidateRunCache(manifest.stateRoot);
|
|
@@ -384,11 +388,11 @@ export async function saveRunManifestAsync(manifest: TeamRunManifest): Promise<v
|
|
|
384
388
|
const manifestStat = await fs.promises.stat(manifestPath);
|
|
385
389
|
setManifestCache(manifest.stateRoot, {
|
|
386
390
|
manifest,
|
|
387
|
-
tasks:
|
|
391
|
+
tasks: cachedTasks,
|
|
388
392
|
manifestMtimeMs: manifestStat.mtimeMs,
|
|
389
393
|
manifestSize: manifestStat.size,
|
|
390
|
-
tasksMtimeMs:
|
|
391
|
-
tasksSize:
|
|
394
|
+
tasksMtimeMs: cachedTasksMtimeMs,
|
|
395
|
+
tasksSize: cachedTasksSize,
|
|
392
396
|
});
|
|
393
397
|
}
|
|
394
398
|
|