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.
- package/CHANGELOG.md +211 -0
- package/dist/build-meta.json +257 -261
- package/dist/index.mjs +2127 -1937
- package/dist/index.mjs.map +4 -4
- package/docs/performance-optimization-execution-plan.md +520 -0
- package/package.json +1 -1
- package/src/extension/team-tool/api.ts +8 -8
- package/src/extension/team-tool/goal-wrap.ts +2 -2
- package/src/extension/team-tool/goal.ts +2 -2
- package/src/extension/team-tool/handle-schedule.ts +102 -0
- package/src/extension/team-tool/run.ts +0 -42
- package/src/extension/team-tool.ts +13 -8
- package/src/observability/exporters/otlp-exporter.ts +24 -2
- package/src/runtime/adaptive-plan.ts +3 -3
- package/src/runtime/background-runner.ts +4 -4
- package/src/runtime/child-pi.ts +191 -56
- package/src/runtime/live-session-runtime.ts +39 -18
- package/src/runtime/parallel-utils.ts +5 -0
- package/src/runtime/skill-instructions.ts +61 -2
- package/src/runtime/stale-reconciler.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/task-runner/tail-read.ts +12 -0
- package/src/runtime/task-runner.ts +40 -19
- package/src/runtime/team-runner.ts +204 -84
- package/src/schema/team-tool-schema.ts +4 -0
- package/src/state/artifact-store.ts +5 -0
- package/src/state/atomic-write.ts +69 -9
- package/src/state/state-store.ts +107 -30
- package/src/utils/token-counter.ts +168 -35
- package/workflows/pipeline.workflow.md +13 -12
|
@@ -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
|
|
@@ -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 {
|
|
11
|
-
import {
|
|
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.
|
|
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
|
-
|
|
464
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
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
|