pi-crew 0.9.33 → 0.9.35

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.
@@ -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
- return {
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
- return {
54
- ...task,
55
- graph: task.graph ? { ...task.graph, queue: isReady ? "ready" : "blocked" } : task.graph,
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
- if (task.status === "running") {
59
- return {
60
- ...task,
61
- graph: task.graph ? { ...task.graph, queue: "running" } : task.graph,
62
- };
63
- }
64
- if (task.status === "completed" || task.status === "skipped" || task.status === "needs_attention") {
65
- return {
66
- ...task,
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: "blocked" } : task.graph,
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
- * Safe to call at any time; the next compute re-populates lazily.
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
- const latest = loadRunManifestById(manifest.cwd, manifest.runId)?.tasks ?? fallbackTasks;
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
@@ -4,10 +4,22 @@ import * as fs from "node:fs";
4
4
  * Read the tail of a file, capped at maxBytes.
5
5
  * If the file exceeds maxBytes, reads only the last maxBytes and snaps
6
6
  * to the nearest newline boundary to avoid partial JSONL lines.
7
+ *
8
+ * Falls back to `fallbackContent` when the file is missing OR empty. The
9
+ * empty-file case matters because `appendTranscript` is fire-and-forget
10
+ * async in the mock path (commit e316a36) — the file is created on disk
11
+ * (existsSync returns true) before the actual write completes, so a
12
+ * subsequent tail-read can observe size=0. Returning "" in that case would
13
+ * silently drop the mock's stdout, breaking downstream parsers that depend
14
+ * on a non-empty transcript (e.g. adaptive-plan JSON extraction in
15
+ * implementation-fanout.test.ts). Falling back to `fallbackContent` keeps
16
+ * the existing fallback semantics: "if the file isn't usable, use what
17
+ * the caller already has."
7
18
  */
8
19
  export function tailReadWithLineSnap(filePath: string, maxBytes: number, fallbackContent: string): string {
9
20
  if (!fs.existsSync(filePath)) return fallbackContent;
10
21
  const stat = fs.statSync(filePath);
22
+ if (stat.size === 0) return fallbackContent;
11
23
  if (stat.size <= maxBytes) return fs.readFileSync(filePath, "utf-8");
12
24
  const fd = fs.openSync(filePath, "r");
13
25
  try {
@@ -7,8 +7,8 @@ import { errors } from "../errors.ts";
7
7
  import { appendHookEvent, executeHook } from "../hooks/registry.ts";
8
8
  import { writeArtifact } from "../state/artifact-store.ts";
9
9
  import { appendEventAsync, appendEventBuffered, appendEventFireAndForget } from "../state/event-log.ts";
10
- import { withRunLockSync } from "../state/locks.ts";
11
- import { saveRunManifest } from "../state/state-store.ts";
10
+ import { withRunLock } from "../state/locks.ts";
11
+ import { saveRunManifestAsync } from "../state/state-store.ts";
12
12
  import { createTaskClaim } from "../state/task-claims.ts";
13
13
  import type {
14
14
  ArtifactDescriptor,
@@ -83,6 +83,36 @@ import {
83
83
  // Register the submit_result tool handler so subprocess events can extract yield data.
84
84
  registerYieldTool();
85
85
 
86
+ /** Async helper for writing steering events — fire-and-forget for non-blocking writes. */
87
+ async function appendSteeringAsync(steeringDir: string, taskId: string, steers: string[]): Promise<void> {
88
+ try {
89
+ await fs.promises.mkdir(steeringDir, { recursive: true });
90
+ const steeringPath = `${steeringDir}/${taskId}.jsonl`;
91
+ const lines = steers
92
+ .map(
93
+ (msg) =>
94
+ JSON.stringify({
95
+ type: "steer",
96
+ message: msg,
97
+ ts: new Date().toISOString(),
98
+ }) + "\n",
99
+ )
100
+ .join("");
101
+ await fs.promises.appendFile(steeringPath, lines, "utf-8");
102
+ } catch (error) {
103
+ logInternalError("task-runner.steering-write-failed", error as Error, `taskId=${taskId}`);
104
+ }
105
+ }
106
+
107
+ /** Async helper for writing background logs — fire-and-forget for non-blocking writes. */
108
+ async function appendBackgroundLogAsync(bgLogPath: string, eventLine: string): Promise<void> {
109
+ try {
110
+ await fs.promises.appendFile(bgLogPath, `${eventLine}\n`, "utf-8");
111
+ } catch (error) {
112
+ logInternalError("task-runner.background-log-write-failed", error as Error, `path=${bgLogPath}`);
113
+ }
114
+ }
115
+
86
116
  export interface TaskRunnerInput {
87
117
  manifest: TeamRunManifest;
88
118
  tasks: TeamTaskState[];
@@ -413,7 +443,7 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
413
443
  // Ensure transcripts/ subdirectory exists before child-pi appends
414
444
  // to it. appendTranscript uses O_APPEND (no mkdir) for security,
415
445
  // so the caller must create the directory.
416
- fs.mkdirSync(path.join(manifest.artifactsRoot, "transcripts"), {
446
+ await fs.promises.mkdir(path.join(manifest.artifactsRoot, "transcripts"), {
417
447
  recursive: true,
418
448
  });
419
449
  const model = attemptModels[i];
@@ -460,18 +490,8 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
460
490
  ({ task, tasks } = checkpointTask(manifest, tasks, task, "child-spawned", pid));
461
491
  if (task.pendingSteers?.length) {
462
492
  const steeringDir = `${manifest.artifactsRoot}/steering`;
463
- fs.mkdirSync(steeringDir, { recursive: true });
464
- const steeringPath = `${steeringDir}/${task.id}.jsonl`;
465
- for (const msg of task.pendingSteers) {
466
- fs.appendFileSync(
467
- steeringPath,
468
- JSON.stringify({
469
- type: "steer",
470
- message: msg,
471
- ts: new Date().toISOString(),
472
- }) + "\n",
473
- );
474
- }
493
+ // Fire-and-forget async write for steering events
494
+ void appendSteeringAsync(steeringDir, task.id, task.pendingSteers);
475
495
  task.pendingSteers = [];
476
496
  tasks = persistSingleTaskUpdate(manifest, tasks, task);
477
497
  }
@@ -533,7 +553,8 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
533
553
  const bgLogPath = `${manifest.stateRoot}/background.log`;
534
554
  const eventLine =
535
555
  typeof event === "object" && !Array.isArray(event) ? JSON.stringify(event) : String(event);
536
- fs.appendFileSync(bgLogPath, `${eventLine}\n`);
556
+ // Fire-and-forget async write for background log
557
+ void appendBackgroundLogAsync(bgLogPath, eventLine);
537
558
  }
538
559
  // Always keep in-memory agentProgress fresh (cheap) so the UI/events see
539
560
  // the latest progress, but THROTTLE the disk persist. Previously this
@@ -1201,9 +1222,9 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
1201
1222
  // stale manifest and overwrite this task's freshly-written artifacts, silently
1202
1223
  // losing them. persistSingleTaskUpdate is re-entrance-safe (runLockHeldByUs guard),
1203
1224
  // so nesting it inside this lock is a no-op re-acquire, not a deadlock.
1204
- withRunLockSync(manifest, () => {
1205
- saveRunManifest(manifest);
1206
- tasks = persistSingleTaskUpdate(manifest, tasks, task);
1225
+ tasks = await withRunLock(manifest, async () => {
1226
+ await saveRunManifestAsync(manifest);
1227
+ return persistSingleTaskUpdate(manifest, tasks, task);
1207
1228
  });
1208
1229
  upsertCrewAgent(manifest, recordFromTask(manifest, task, runtimeKind));
1209
1230
  // Execute task_result hook before emitting terminal event