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/CHANGELOG.md +163 -1
- package/docs/perf/performance-audit-report-2026-07.md +948 -0
- package/package.json +1 -1
- package/src/extension/run-maintenance.ts +29 -1
- package/src/extension/team-tool/lifecycle-actions.ts +22 -0
- package/src/extension/team-tool.ts +22 -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/skill-instructions.ts +7 -2
- package/src/runtime/task-graph-scheduler.ts +48 -17
- package/src/runtime/task-runner/prompt-builder.ts +120 -19
- package/src/runtime/task-runner/state-helpers.ts +18 -1
- package/src/runtime/task-runner.ts +20 -7
- package/src/runtime/team-runner.ts +196 -39
- package/src/state/artifact-store.ts +5 -0
- package/src/state/atomic-write.ts +87 -8
- package/src/state/event-log.ts +71 -25
- package/src/state/state-store.ts +32 -28
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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;
|
|
@@ -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:
|
|
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 =
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
1194
|
-
|
|
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", {
|