pi-crew 0.9.32 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.9.32",
3
+ "version": "0.9.34",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -7,6 +7,7 @@ import { logInternalError } from "../utils/internal-error.ts";
7
7
  import { projectCrewRoot, userCrewRoot } from "../utils/paths.ts";
8
8
  import { redactSecrets } from "../utils/redaction.ts";
9
9
  import { isSafePathId, resolveRealContainedPath } from "../utils/safe-paths.ts";
10
+ import { cleanupRunWorktrees } from "../worktree/cleanup.ts";
10
11
  import { listRuns } from "./run-index.ts";
11
12
 
12
13
  export interface PruneRunsResult {
@@ -21,7 +22,14 @@ export interface PruneRunsOptions {
21
22
  }
22
23
 
23
24
  function isFinished(run: TeamRunManifest): boolean {
24
- return run.status === "completed" || run.status === "failed" || run.status === "cancelled" || run.status === "blocked";
25
+ // P3: "blocked" is NOT a terminal status the run is waiting on something
26
+ // (plan approval, mailbox reply, scheduler stall) and can transition to
27
+ // running/cancelled/failed (per TEAM_RUN_STATUS_TRANSITIONS). Pruning a
28
+ // blocked run destroys recoverable state (e.g. a plan the user needs to
29
+ // approve, or a mailbox wait the user needs to answer). Truly stuck
30
+ // blocked runs are handled by the stale-reconciler/crash-recovery, not
31
+ // by prune.
32
+ return run.status === "completed" || run.status === "failed" || run.status === "cancelled";
25
33
  }
26
34
 
27
35
  function isSafeToPrune(cwd: string, run: TeamRunManifest): boolean {
@@ -66,6 +74,26 @@ export function pruneFinishedRuns(cwd: string, keep: number, options: PruneRunsO
66
74
  );
67
75
  continue;
68
76
  }
77
+ // P2: clean up git worktrees BEFORE deleting state. Worktrees live at
78
+ // <crewRoot>/state/worktrees/<runId>/ — a separate path from stateRoot
79
+ // (<crewRoot>/state/runs/<runId>/) and artifactsRoot, so fs.rmSync below
80
+ // does NOT touch them. Without this, every pruned worktree-using run
81
+ // leaks its worktree dir + git branch. cleanupRunWorktrees (no force)
82
+ // preserves dirty worktrees by writing a diff artifact and skipping
83
+ // the removal — in that case we skip the whole prune of this run so
84
+ // the user can recover (no silent data loss). Compare handleForget
85
+ // which calls cleanupRunWorktrees.
86
+ const worktreeCleanup = cleanupRunWorktrees(run, { signal: options.signal });
87
+ if (worktreeCleanup.preserved.length > 0) {
88
+ logInternalError(
89
+ "prune.worktree-preserved",
90
+ new Error(
91
+ `Skipping prune: ${worktreeCleanup.preserved.length} dirty worktree(s) preserved for recovery: ${worktreeCleanup.preserved.map((p) => p.path).join(", ")}`,
92
+ ),
93
+ `runId=${run.runId}`,
94
+ );
95
+ continue;
96
+ }
69
97
  fs.rmSync(run.stateRoot, { recursive: true, force: true });
70
98
  fs.rmSync(run.artifactsRoot, { recursive: true, force: true });
71
99
  removed.push(run.runId);
@@ -2,9 +2,12 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { removeGuidance } from "../../config/markers.ts";
4
4
  import { appendHookEvent, executeHook } from "../../hooks/registry.ts";
5
+ import { killProcessPid } from "../../runtime/child-pi.ts";
6
+ import { terminateLiveAgentsForRun } from "../../runtime/live-agent-manager.ts";
5
7
  import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
6
8
  import { appendEvent } from "../../state/event-log.ts";
7
9
  import { loadRunManifestById } from "../../state/state-store.ts";
10
+ import { logInternalError } from "../../utils/internal-error.ts";
8
11
  import { projectCrewRoot, userCrewRoot, userPiRoot } from "../../utils/paths.ts";
9
12
  import { resolveRealContainedPath } from "../../utils/safe-paths.ts";
10
13
  import { cleanupRunWorktrees } from "../../worktree/cleanup.ts";
@@ -217,6 +220,25 @@ export async function handleForget(params: TeamToolParamsValue, ctx: TeamContext
217
220
  intent,
218
221
  },
219
222
  });
223
+ // P1: terminate live agents + kill the background runner BEFORE deleting state.
224
+ // Without this, forgetting a running run orphans those processes (separate
225
+ // process groups via setsid) which keep executing and consuming tokens after
226
+ // their state directory is gone. Mirrors handleCancel's cleanup.
227
+ await terminateLiveAgentsForRun(loaded.manifest.runId, "cancelled", appendEvent, loaded.manifest.eventsPath);
228
+ const asyncPid = loaded.manifest.async?.pid;
229
+ if (asyncPid !== undefined && asyncPid > 0) {
230
+ try {
231
+ killProcessPid(asyncPid);
232
+ appendEvent(loaded.manifest.eventsPath, {
233
+ type: "async.kill_requested",
234
+ runId: loaded.manifest.runId,
235
+ message: "Sent SIGTERM to background runner process (forget).",
236
+ data: { pid: asyncPid },
237
+ });
238
+ } catch (error) {
239
+ logInternalError("team-tool.handleForget.killAsync", error, `runId=${loaded.manifest.runId},pid=${asyncPid}`);
240
+ }
241
+ }
220
242
  // Determine scope from manifest paths (project vs user-level runs)
221
243
  const crewRoot = loaded.manifest.stateRoot.startsWith(userCrewRoot() + path.sep)
222
244
  ? userCrewRoot()
@@ -314,6 +314,17 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
314
314
  if (!runCwd) return result(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "resume", status: "error" }, true);
315
315
  const loaded = loadRunManifestById(runCwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
316
316
  if (!loaded) return result(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "resume", status: "error" }, true);
317
+ // R1: foreign-ownership check — mirrors handleRetry/handleCancel. Without it,
318
+ // another session can resume (and re-execute) a run it doesn't own, racing
319
+ // the owning session.
320
+ const foreignRun = typeof loaded.manifest.ownerSessionId === "string" && loaded.manifest.ownerSessionId !== ctx.sessionId;
321
+ if (foreignRun && !params.force) {
322
+ return result(
323
+ `Run ${loaded.manifest.runId} belongs to another session. Use force: true to override.`,
324
+ { action: "resume", status: "error", runId: loaded.manifest.runId },
325
+ true,
326
+ );
327
+ }
317
328
  if (!loaded.manifest.workflow)
318
329
  return result(`Run '${params.runId}' has no workflow to resume.`, { action: "resume", status: "error" }, true);
319
330
  const agents = allAgents(discoverAgents(ctx.cwd));
@@ -324,8 +335,18 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
324
335
  direct?.workflow ?? allWorkflows(discoverWorkflows(ctx.cwd)).find((candidate) => candidate.name === loaded.manifest.workflow);
325
336
  if (!workflow) return result(`Workflow '${loaded.manifest.workflow}' not found.`, { action: "resume", status: "error" }, true);
326
337
  return await withRunLock(loaded.manifest, async () => {
338
+ // R2: re-read inside the lock so recovery + resetTasks reflect committed
339
+ // state, not the pre-lock snapshot. Between the pre-lock load and lock
340
+ // acquisition a task may have been cancelled (stale-reconciler) or
341
+ // completed (background runner); using the stale snapshot would reset
342
+ // running→queued and re-execute it (double execution: duplicate tokens +
343
+ // duplicate side effects). Sibling handlers (cancelOrphanedRuns,
344
+ // reconcileAllStaleRuns) re-read inside the lock for the same reason.
345
+ const fresh = loadRunManifestById(runCwd, loaded.manifest.runId);
346
+ const lockedManifest = fresh?.manifest ?? loaded.manifest;
347
+ const lockedTasks = fresh?.tasks ?? loaded.tasks;
327
348
  const loadedConfig = loadConfig(ctx.cwd);
328
- const recovered = recoverCheckpointedTasks(loaded.manifest, loaded.tasks);
349
+ const recovered = recoverCheckpointedTasks(lockedManifest, lockedTasks);
329
350
  const resumeManifest = recovered.manifest;
330
351
  const executedConfig = {
331
352
  ...effectiveRunConfig(loadedConfig.config, params.config),
@@ -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
- if (bare === "::1") {
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
- if (bare.toLowerCase().startsWith("fd") || bare.toLowerCase().startsWith("fc")) {
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
- appendEvent(input.manifest.eventsPath, {
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
- appendEvent(input.manifest.eventsPath, {
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
- appendEvent(input.manifest.eventsPath, {
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
- appendEvent(input.manifest.eventsPath, {
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
- appendEvent(input.manifest.eventsPath, {
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;
@@ -135,6 +135,8 @@ interface CachedSkillMarkdown {
135
135
  path: string;
136
136
  source: "project" | "package" | "project-pi" | "user-pi" | "project-agents" | "user-agents";
137
137
  content: string;
138
+ /** Pre-computed compacted body (frontmatter stripped + truncated). Cached to avoid re-compaction on every render. */
139
+ compacted: string;
138
140
  mtimeMs: number;
139
141
  size: number;
140
142
  }
@@ -173,6 +175,7 @@ function readSkillMarkdown(
173
175
  path: string;
174
176
  source: "project" | "package" | "project-pi" | "user-pi" | "project-agents" | "user-agents";
175
177
  content: string;
178
+ compacted: string;
176
179
  }
177
180
  | undefined {
178
181
  if (!isValidSkillName(name)) return undefined;
@@ -188,10 +191,12 @@ function readSkillMarkdown(
188
191
  if (fs.lstatSync(contained).isSymbolicLink()) continue;
189
192
  const filePath = resolveRealContainedPath(entry.root, relative);
190
193
  const stat = fs.statSync(filePath);
194
+ const rawContent = fs.readFileSync(filePath, "utf-8");
191
195
  return rememberSkill(cacheKey, {
192
196
  path: filePath,
193
197
  source: entry.source,
194
- content: fs.readFileSync(filePath, "utf-8"),
198
+ content: rawContent,
199
+ compacted: compactSkillContent(rawContent),
195
200
  mtimeMs: stat.mtimeMs,
196
201
  size: stat.size,
197
202
  });
@@ -296,7 +301,7 @@ export function renderSkillInstructions(
296
301
  ]
297
302
  .filter(Boolean)
298
303
  .join("\n");
299
- const rawContent = compactSkillContent(loaded.content);
304
+ const rawContent = loaded.compacted;
300
305
  // Wrap skill content with provenance markers to help LLMs distinguish skill instructions
301
306
  const wrappedContent = `<!-- skill: ${safeName} -->\n${rawContent}\n<!-- end-skill: ${safeName} -->`;
302
307
  const section = `${header}\n\n${wrappedContent}`;
@@ -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
 
@@ -75,6 +75,118 @@ export function renderOutputSchemaBlock(outputSchema: TaskOutputSchema): string
75
75
  return lines.join("\n");
76
76
  }
77
77
 
78
+ /**
79
+ * Expensive async sub-results that compose the stable prefix.
80
+ * These are cached per (cwd, step.task, runId) so parallel siblings
81
+ * in the same batch reuse them instead of recomputing.
82
+ */
83
+ export interface StableComponents {
84
+ treeBlock: string;
85
+ suggestedFilesBlock: string;
86
+ knowledgeFragment: string;
87
+ }
88
+
89
+ const stableComponentCache = new Map<string, StableComponents>();
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
+
115
+ function stablePrefixCacheKey(task: TeamTaskState, step: WorkflowStep, manifest: TeamRunManifest): string {
116
+ return `${task.cwd}|${step.task}|${manifest.runId}`;
117
+ }
118
+
119
+ /**
120
+ * Clear the stable prefix cache. Called at run end so the module-level cache
121
+ * (keyed by runId) does not grow unbounded across runs in a long-lived session.
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.
125
+ */
126
+ export function clearStablePrefixCache(): void {
127
+ stableComponentCache.clear();
128
+ stableIOCache.clear();
129
+ }
130
+
131
+ /**
132
+ * Compute (or return cached) expensive async sub-results that compose
133
+ * the stable prefix: workspace tree, file retrieval, knowledge fragment.
134
+ * Parallel siblings with the same cwd/step/run reuse the cached result.
135
+ */
136
+ export async function computeStablePrefixComponents(
137
+ manifest: TeamRunManifest,
138
+ step: WorkflowStep,
139
+ task: TeamTaskState,
140
+ _agent?: AgentConfig,
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.
144
+ const cacheKey = stablePrefixCacheKey(task, step, manifest);
145
+ const cached = stableComponentCache.get(cacheKey);
146
+ if (cached) return cached;
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
+
165
+ const tree = await buildWorkspaceTree(task.cwd);
166
+ const treeBlock = tree.rendered ? `# Workspace Structure\n${tree.rendered}` : "";
167
+
168
+ const retrieval = await runRetrievalCycle(step.task, manifest.goal, task.cwd);
169
+ const suggestedFilesBlock = renderSuggestedFilesSection(retrieval);
170
+
171
+ const knowledgeFragment = buildKnowledgeFragment(task.cwd, {
172
+ goal: manifest.goal,
173
+ taskText: step.task,
174
+ role: step.role,
175
+ });
176
+
177
+ const components: StableComponents = { treeBlock, suggestedFilesBlock, knowledgeFragment };
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
+ }
187
+ return components;
188
+ }
189
+
78
190
  export interface RenderedTaskPrompt {
79
191
  /** Stable sections that rarely change between tasks of the same role/cwd. */
80
192
  stablePrefix: string;
@@ -90,23 +202,16 @@ export async function renderTaskPrompt(
90
202
  task: TeamTaskState,
91
203
  agent?: AgentConfig,
92
204
  skillBlock = "",
205
+ precomputedStableComponents?: StableComponents,
93
206
  ): Promise<RenderedTaskPrompt> {
94
207
  const memoryBlock = agent?.memory
95
208
  ? buildMemoryBlock(agent.name, agent.memory, task.cwd, Boolean(agent.tools?.some((tool) => tool === "write" || tool === "edit")))
96
209
  : "";
97
210
 
98
- // Build workspace tree for stable context
99
- const tree = await buildWorkspaceTree(task.cwd);
100
- const treeBlock = tree.rendered ? `# Workspace Structure\n${tree.rendered}` : "";
101
-
102
- // M3: run iterative file-retrieval AFTER the workspace tree is
103
- // assembled and BEFORE the dynamic suffix is built, so the suggested
104
- // files section lands in the stable prefix (next to the tree) and
105
- // is visible to the worker before they start on the task. Never
106
- // throws — the orchestrator falls back to in-memory heuristic when
107
- // ripgrep is not installed.
108
- const retrieval = await runRetrievalCycle(step.task, manifest.goal, task.cwd);
109
- const suggestedFilesBlock = renderSuggestedFilesSection(retrieval);
211
+ // Use precomputed or cached stable components when available, avoiding
212
+ // redundant workspace tree, file retrieval, and knowledge fragment
213
+ // computation for parallel siblings in the same batch.
214
+ const stableComponents = precomputedStableComponents ?? (await computeStablePrefixComponents(manifest, step, task, agent));
110
215
 
111
216
  // Stable prefix: role instructions, coordination, workspace tree — rarely changes
112
217
  const stablePrefix = [
@@ -131,20 +236,16 @@ export async function renderTaskPrompt(
131
236
  "",
132
237
  coordinationBridgeInstructions(task),
133
238
  "",
134
- treeBlock,
239
+ stableComponents.treeBlock,
135
240
  "",
136
- suggestedFilesBlock,
241
+ stableComponents.suggestedFilesBlock,
137
242
  "",
138
243
  toolGuidanceBlock(agent),
139
244
  "",
140
245
  // O4: project knowledge (.crew/knowledge.md) — workers don't load the
141
246
  // pi-crew extension (spawned with --no-extensions), so before_agent_start
142
247
  // never fires for them. Inject here so every worker sees project knowledge.
143
- buildKnowledgeFragment(task.cwd, {
144
- goal: manifest.goal,
145
- taskText: step.task,
146
- role: step.role,
147
- }),
248
+ stableComponents.knowledgeFragment,
148
249
  ]
149
250
  .filter(Boolean)
150
251
  .join("\n");
@@ -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
@@ -7,6 +7,7 @@ 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";
10
11
  import { saveRunManifest } from "../state/state-store.ts";
11
12
  import { createTaskClaim } from "../state/task-claims.ts";
12
13
  import type {
@@ -158,6 +159,10 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
158
159
  } as TeamTaskState;
159
160
  let tasks = updateTask(input.tasks, task);
160
161
  const runtimeKind = input.taskRuntimeOverride ?? input.runtimeKind ?? (input.executeWorkers ? "child-process" : "scaffold");
162
+ // A1-F7: Pre-compute whether yield-event collection is needed. For child-process
163
+ // workers (the common case) this is always false, so we skip allocating/accumulating
164
+ // collectedJsonEvents entirely — eliminating ~10KB memory waste per task.
165
+ const collectYieldEvents = runtimeKind !== "child-process" && (input.runtimeConfig?.yield?.enabled ?? DEFAULT_YIELD_CONFIG.enabled);
161
166
  // FIX: Check signal before persisting state — if cancelled, skip the write.
162
167
  if (input.signal?.aborted) {
163
168
  const cancelReason = cancellationReasonFromSignal(input.signal);
@@ -293,7 +298,7 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
293
298
  let finalStdout = "";
294
299
  let transcriptPath: string | undefined;
295
300
  let terminalEvidence: OperationTerminalEvidence[] = [];
296
- const collectedJsonEvents: Record<string, unknown>[] = [];
301
+ const collectedJsonEvents: Record<string, unknown>[] | undefined = collectYieldEvents ? [] : undefined;
297
302
 
298
303
  let startupEvidence = createStartupEvidence({
299
304
  command: runtimeKind === "child-process" ? "pi" : runtimeKind === "live-session" ? "live-session" : "safe-scaffold",
@@ -502,9 +507,9 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
502
507
  // Errors are logged but processing continues so subsequent events still update state.
503
508
  try {
504
509
  appendCrewAgentEvent(manifest, task.id, event);
505
- if (event && typeof event === "object" && !Array.isArray(event))
510
+ if (collectedJsonEvents && event && typeof event === "object" && !Array.isArray(event))
506
511
  collectedJsonEvents.push(event as Record<string, unknown>);
507
- if (collectedJsonEvents.length > 1000) {
512
+ if (collectedJsonEvents && collectedJsonEvents.length > 1000) {
508
513
  collectedJsonEvents.splice(0, collectedJsonEvents.length - 1000);
509
514
  }
510
515
  // Accumulate lifetime usage via message_end events (survives compaction)
@@ -878,8 +883,8 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
878
883
  // only applies to live-session workers where submit_result is injected by the
879
884
  // runtime. Skipping yield detection for child-process prevents every child
880
885
  // worker from incorrectly being marked needs_attention.
881
- const yieldEnabled = runtimeKind !== "child-process" && (input.runtimeConfig?.yield?.enabled ?? DEFAULT_YIELD_CONFIG.enabled);
882
- if (yieldEnabled && collectedJsonEvents.length > 0) {
886
+ const yieldEnabled = collectYieldEvents;
887
+ if (yieldEnabled && collectedJsonEvents && collectedJsonEvents.length > 0) {
883
888
  if (hasYieldInOutput(collectedJsonEvents)) {
884
889
  const yieldEvent = collectedJsonEvents.find((e) => isYieldEvent(e));
885
890
  if (yieldEvent) {
@@ -1190,8 +1195,16 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
1190
1195
  ...(patchArtifact ? [patchArtifact] : []),
1191
1196
  ],
1192
1197
  };
1193
- saveRunManifest(manifest);
1194
- tasks = persistSingleTaskUpdate(manifest, tasks, task);
1198
+ // NEW-C3: persist manifest + tasks atomically under the run lock. Without this,
1199
+ // the unlocked saveRunManifest here races with the team-runner batch merge path
1200
+ // (which writes the manifest under withRunLock) — a parallel batch could read a
1201
+ // stale manifest and overwrite this task's freshly-written artifacts, silently
1202
+ // losing them. persistSingleTaskUpdate is re-entrance-safe (runLockHeldByUs guard),
1203
+ // 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);
1207
+ });
1195
1208
  upsertCrewAgent(manifest, recordFromTask(manifest, task, runtimeKind));
1196
1209
  // Execute task_result hook before emitting terminal event
1197
1210
  const hookReport = await executeHook("task_result", {