pi-crew 0.11.57 → 0.11.59
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 +31 -0
- package/agents/designer.md +18 -13
- package/agents/oracle.md +20 -1
- package/agents/orchestrator.md +24 -11
- package/agents/reviewer.md +13 -1
- package/dist/index.mjs +519 -517
- package/docs/architecture.md +1 -0
- package/package.json +1 -1
- package/src/config/config-validation.ts +7 -4
- package/src/config/env-allowlist-registry.ts +5 -0
- package/src/config/env-vars.ts +12 -1
- package/src/config/role-tools.ts +3 -2
- package/src/config/types.ts +5 -3
- package/src/extension/knowledge-injection.ts +110 -11
- package/src/extension/registration/lifecycle-handlers.ts +54 -0
- package/src/extension/run-maintenance.ts +25 -0
- package/src/extension/team-tool/lifecycle-actions.ts +8 -0
- package/src/observability/metric-registry.ts +14 -1
- package/src/observability/metrics-primitives.ts +40 -0
- package/src/runtime/heartbeat/heartbeat-watcher.ts +76 -0
- package/src/runtime/subagent-manager.ts +123 -0
- package/src/runtime/task-packet.ts +18 -2
- package/src/runtime/task-runner/pre-execution.ts +4 -3
- package/src/runtime/task-runner/prompt-builder.ts +103 -26
- package/src/runtime/workspace-tree.ts +132 -0
- package/src/schema/config-schema.ts +4 -3
- package/src/schema/team-tool-schema.ts +2 -2
- package/src/state/stores/health-store.ts +75 -0
- package/src/ui/heartbeat-aggregator.ts +11 -0
|
@@ -46,6 +46,14 @@ export class HeartbeatWatcher {
|
|
|
46
46
|
private consecutiveDead = new Map<string, number>();
|
|
47
47
|
private lastSeen = new Map<string, number>(); // key → last time it was active
|
|
48
48
|
private lastDeadletterTriggerAt = new Map<string, number>(); // 3.6 cooldown gate
|
|
49
|
+
/**
|
|
50
|
+
* Run-scoped metric label GC (FIX 2, 2026-11): runIds whose label values are
|
|
51
|
+
* still live — observed running in a previous tick and not yet released.
|
|
52
|
+
* Per-run label values are live only while the run is active; durable
|
|
53
|
+
* per-run detail lives in the run's own events.jsonl + health snapshot.
|
|
54
|
+
* See the release loop at the end of tickUnsafe().
|
|
55
|
+
*/
|
|
56
|
+
private activeRunIds = new Set<string>();
|
|
49
57
|
/** Max age (ms) to retain a stale key before garbage-collecting it. */
|
|
50
58
|
private readonly maxKeyAgeMs = 600_000; // 10 minutes
|
|
51
59
|
private readonly opts: HeartbeatWatcherOptions;
|
|
@@ -83,6 +91,8 @@ export class HeartbeatWatcher {
|
|
|
83
91
|
const thresholds = this.opts.thresholds ?? DEFAULT_GRADIENT_THRESHOLDS;
|
|
84
92
|
const tickThreshold = this.opts.deadletterTickThreshold ?? 3;
|
|
85
93
|
const activeKeys = new Set<string>();
|
|
94
|
+
// runIds confirmed running on disk this tick (see activeRunIds above).
|
|
95
|
+
const runningRunIds = new Set<string>();
|
|
86
96
|
|
|
87
97
|
for (const run of this.opts.manifestCache.list(50)) {
|
|
88
98
|
if (run.status !== "running") continue;
|
|
@@ -96,6 +106,8 @@ export class HeartbeatWatcher {
|
|
|
96
106
|
// This closes the false-positive alert loop when a run completes but the
|
|
97
107
|
// manifestCache.list(50) still serves a pre-completion snapshot.
|
|
98
108
|
if (loaded.manifest.status !== "running") continue;
|
|
109
|
+
// Confirmed running on disk → its run-scoped label values stay live.
|
|
110
|
+
runningRunIds.add(run.runId);
|
|
99
111
|
for (const task of loaded.tasks) {
|
|
100
112
|
if (task.status !== "running") continue;
|
|
101
113
|
// Guest-child tasks (delegate subagents, agent === "delegate") have NO
|
|
@@ -244,6 +256,42 @@ export class HeartbeatWatcher {
|
|
|
244
256
|
}
|
|
245
257
|
}
|
|
246
258
|
|
|
259
|
+
// Run-scoped metric label GC. `crew.heartbeat.staleness_ms`
|
|
260
|
+
// ({runId,taskId}) and `crew.heartbeat.level_total` ({runId,level}) are the
|
|
261
|
+
// only run-scoped series; without this the registry's label Maps grow
|
|
262
|
+
// unbounded (the MAX_LABEL_COMBINATIONS=10_000 FIFO cap is never reached at
|
|
263
|
+
// realistic volumes — measured: 101 label values/day across 33 runs, the
|
|
264
|
+
// heartbeat pair = 58% of a 26.9 KB metrics snapshot). Release a run's
|
|
265
|
+
// label values once it stops being active: durable per-run detail lives in
|
|
266
|
+
// the run's own events.jsonl + health snapshot — the same tradeoff OBS-1
|
|
267
|
+
// already applied to event metrics (src/observability/event-to-metric.ts:
|
|
268
|
+
// "Using runId/taskId as METRIC labels would blow up cardinality").
|
|
269
|
+
// Re-check the manifest cache first: a run may have fallen out of the
|
|
270
|
+
// top-50 scan window while still active, and a BLOCKED run (parked on
|
|
271
|
+
// `ask`, resumable — blocked is terminal-for-UI but never released) must
|
|
272
|
+
// keep its labels. Only a run that is neither running nor blocked, or that
|
|
273
|
+
// has vanished from the cache entirely, is released. Still-active runs stay
|
|
274
|
+
// in the tracking set so their release fires once they truly stop.
|
|
275
|
+
//
|
|
276
|
+
// Robustness: this pass must NEVER abort the tick — the stale-key cleanup
|
|
277
|
+
// below is load-bearing (it drops the deadletter cooldown marker, M-8) — so
|
|
278
|
+
// it is guarded and tolerates caches/registries that omit the reader
|
|
279
|
+
// (partial test seams).
|
|
280
|
+
try {
|
|
281
|
+
const nextActiveRunIds = new Set(runningRunIds);
|
|
282
|
+
for (const runId of this.activeRunIds) {
|
|
283
|
+
if (runningRunIds.has(runId)) continue;
|
|
284
|
+
if (this.isRunStillActive(runId)) {
|
|
285
|
+
nextActiveRunIds.add(runId);
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
this.opts.registry.releaseLabels?.((labels) => labels.runId === runId);
|
|
289
|
+
}
|
|
290
|
+
this.activeRunIds = nextActiveRunIds;
|
|
291
|
+
} catch (error) {
|
|
292
|
+
logInternalError("heartbeat-watcher.label-gc", error);
|
|
293
|
+
}
|
|
294
|
+
|
|
247
295
|
// Cleanup: drop keys that were NOT in this tick's active set AND
|
|
248
296
|
// haven't been seen for > maxKeyAgeMs. This covers runs that
|
|
249
297
|
// completed or fell out of the manifest cache's top-50 window.
|
|
@@ -262,6 +310,31 @@ export class HeartbeatWatcher {
|
|
|
262
310
|
}
|
|
263
311
|
}
|
|
264
312
|
|
|
313
|
+
/**
|
|
314
|
+
* Is the run still active per the manifest cache?
|
|
315
|
+
*
|
|
316
|
+
* `true` for "running" (it may simply have fallen out of the top-50 scan
|
|
317
|
+
* window) and for "blocked" (parked on `ask`, resumable — blocked is
|
|
318
|
+
* terminal-for-UI but must keep its labels). `false` when the run is gone or
|
|
319
|
+
* carries a non-active status — that is the release case.
|
|
320
|
+
*
|
|
321
|
+
* A cache that cannot answer (missing `get`, or a throw) resolves to `true`
|
|
322
|
+
* (keep): a wrongly released series belonging to a still-active run is
|
|
323
|
+
* re-added on the next tick, but a series kept for a vanished run is only a
|
|
324
|
+
* bounded leak — and the definitive release happens on the run-prune path.
|
|
325
|
+
*/
|
|
326
|
+
private isRunStillActive(runId: string): boolean {
|
|
327
|
+
const getStatus = this.opts.manifestCache.get;
|
|
328
|
+
if (typeof getStatus !== "function") return true;
|
|
329
|
+
try {
|
|
330
|
+
const manifest = getStatus.call(this.opts.manifestCache, runId);
|
|
331
|
+
return manifest?.status === "running" || manifest?.status === "blocked";
|
|
332
|
+
} catch (error) {
|
|
333
|
+
logInternalError("heartbeat-watcher.label-gc.status", error, `runId=${runId}`);
|
|
334
|
+
return true;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
265
338
|
dispose(): void {
|
|
266
339
|
if (this.timer) clearTimeout(this.timer);
|
|
267
340
|
this.timer = undefined;
|
|
@@ -270,5 +343,8 @@ export class HeartbeatWatcher {
|
|
|
270
343
|
this.lastSeen.clear();
|
|
271
344
|
// M-8: clear the deadletter cooldown map too (was leaked before).
|
|
272
345
|
this.lastDeadletterTriggerAt.clear();
|
|
346
|
+
// Label-GC bookkeeping only (the registry outlives the watcher; labels
|
|
347
|
+
// are released on run inactivity, not on watcher teardown).
|
|
348
|
+
this.activeRunIds.clear();
|
|
273
349
|
}
|
|
274
350
|
}
|
|
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { DEFAULT_PATHS, DEFAULT_SUBAGENT } from "../config/defaults.ts";
|
|
5
|
+
import { getCrewEnv } from "../config/env-vars.ts";
|
|
5
6
|
import type { PiTeamsToolResult } from "../extension/tool-result.ts";
|
|
6
7
|
import { atomicWriteFile } from "../state/atomic-write.ts";
|
|
7
8
|
import { loadRunManifestById } from "../state/stores/state-store.ts";
|
|
@@ -139,6 +140,128 @@ export function removePersistedSubagentRecord(cwd: string, id: string): boolean
|
|
|
139
140
|
*/
|
|
140
141
|
export const MAX_TERMINAL_SUBAGENT_RECORDS = 50;
|
|
141
142
|
|
|
143
|
+
/** FIX 3: statuses whose persisted record may be TTL-pruned. Anything else
|
|
144
|
+
* (queued | running | blocked) is NEVER deleted — `blocked` especially, since
|
|
145
|
+
* blockedSignalFromSubagentDir (terminal-status-wiring.ts) derives terminal
|
|
146
|
+
* blocked-state from blocked records on disk; deleting one breaks detection. */
|
|
147
|
+
const TTL_PRUNABLE_STATUSES = new Set<SubagentStatus>(["completed", "failed", "cancelled", "error", "stopped"]);
|
|
148
|
+
|
|
149
|
+
/** Default bound for the TTL prune scan: files examined per call (1,229 files
|
|
150
|
+
* had accumulated on the dev box at discovery; 2,000 leaves headroom while
|
|
151
|
+
* keeping session-start I/O bounded). */
|
|
152
|
+
export const PRUNE_SUBAGENT_RECORDS_MAX_FILES = 2000;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* FIX 3: resolve the subagent record TTL (days) from
|
|
156
|
+
* PI_CREW_SUBAGENT_RECORD_TTL_DAYS (default 7; 0 disables). Invalid/negative
|
|
157
|
+
* values fall back to 7 with a logged warning — mirrors resolveAutoPruneKeep.
|
|
158
|
+
*/
|
|
159
|
+
export function resolveSubagentRecordTtlDays(env: (name: string) => string | undefined = getCrewEnv): number {
|
|
160
|
+
const raw = env("PI_CREW_SUBAGENT_RECORD_TTL_DAYS")?.trim();
|
|
161
|
+
if (raw === undefined || raw === "") return 7;
|
|
162
|
+
const parsed = Number.parseInt(raw, 10);
|
|
163
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
164
|
+
logInternalError(
|
|
165
|
+
"subagent-manager.ttl-invalid",
|
|
166
|
+
new Error(`PI_CREW_SUBAGENT_RECORD_TTL_DAYS=${JSON.stringify(raw)} is not a non-negative integer; using 7`),
|
|
167
|
+
);
|
|
168
|
+
return 7;
|
|
169
|
+
}
|
|
170
|
+
return parsed;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface PrunePersistedSubagentRecordsOptions {
|
|
174
|
+
/** TTL in days. Defaults to resolveSubagentRecordTtlDays() (env-driven, 7).
|
|
175
|
+
* 0 disables the prune entirely. */
|
|
176
|
+
ttlDays?: number;
|
|
177
|
+
/** Always keep this many most-recent records regardless of age/status.
|
|
178
|
+
* Default 50 (matches MAX_TERMINAL_SUBAGENT_RECORDS). */
|
|
179
|
+
keepRecent?: number;
|
|
180
|
+
/** Bound on files scanned per call. Default PRUNE_SUBAGENT_RECORDS_MAX_FILES. */
|
|
181
|
+
maxFiles?: number;
|
|
182
|
+
/** Clock injection for tests. */
|
|
183
|
+
now?: number;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* FIX 3: TTL-prune persisted TERMINAL subagent records under
|
|
188
|
+
* `<projectCrewRoot(cwd)>/state/subagents/*.json`. removePersistedSubagentRecord
|
|
189
|
+
* only fires on explicit cancel/stop, so terminal records accumulated
|
|
190
|
+
* indefinitely (1,229 files / 6.7 MB over Jun–Oct).
|
|
191
|
+
*
|
|
192
|
+
* Safety contract:
|
|
193
|
+
* - ONLY terminal statuses (see TTL_PRUNABLE_STATUSES) are eligible.
|
|
194
|
+
* queued/running/blocked are never deleted (blocked drives
|
|
195
|
+
* blockedSignalFromSubagentDir terminal-status derivation).
|
|
196
|
+
* - The `keepRecent` most-recent records (by completedAt ?? startedAt) are
|
|
197
|
+
* retained regardless of age or status (recent history stays inspectable).
|
|
198
|
+
* - Malformed JSON is skipped (kept) — a corrupt file is never a prune target.
|
|
199
|
+
* - Bounded scan + best-effort: never throws; per-file failures are kept.
|
|
200
|
+
*/
|
|
201
|
+
export function prunePersistedSubagentRecords(
|
|
202
|
+
cwd: string,
|
|
203
|
+
opts: PrunePersistedSubagentRecordsOptions = {},
|
|
204
|
+
): { removed: string[]; kept: number } {
|
|
205
|
+
const ttlDays = opts.ttlDays ?? resolveSubagentRecordTtlDays();
|
|
206
|
+
if (ttlDays === 0) return { removed: [], kept: 0 }; // disabled — no scan, no delete
|
|
207
|
+
const removed: string[] = [];
|
|
208
|
+
let kept = 0;
|
|
209
|
+
try {
|
|
210
|
+
const dir = path.join(projectCrewRoot(cwd), DEFAULT_PATHS.state.subagentsSubdir);
|
|
211
|
+
let files: string[];
|
|
212
|
+
try {
|
|
213
|
+
files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
|
|
214
|
+
} catch {
|
|
215
|
+
return { removed, kept }; // no dir / unreadable → nothing to do
|
|
216
|
+
}
|
|
217
|
+
const maxFiles = opts.maxFiles ?? PRUNE_SUBAGENT_RECORDS_MAX_FILES;
|
|
218
|
+
const now = opts.now ?? Date.now();
|
|
219
|
+
const ttlMs = ttlDays * 24 * 60 * 60 * 1000;
|
|
220
|
+
// Parse every (bounded) file; malformed → keep, tracked as kept so the
|
|
221
|
+
// caller can see the sweep saw it.
|
|
222
|
+
const parsed: Array<{ id: string; ageMs: number; terminal: boolean }> = [];
|
|
223
|
+
for (const file of files.slice(0, maxFiles)) {
|
|
224
|
+
try {
|
|
225
|
+
const raw = JSON.parse(fs.readFileSync(path.join(dir, file), "utf-8")) as unknown;
|
|
226
|
+
const record = sanitizePersistedRecord(raw);
|
|
227
|
+
if (!record) {
|
|
228
|
+
kept++;
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const ageMs = now - (record.completedAt ?? record.startedAt);
|
|
232
|
+
parsed.push({ id: record.id, ageMs, terminal: TTL_PRUNABLE_STATUSES.has(record.status) });
|
|
233
|
+
} catch {
|
|
234
|
+
kept++; // unreadable/parse error → skip (keep)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// Keep-N shield: the keepRecent most-recent records survive regardless
|
|
238
|
+
// of age or status.
|
|
239
|
+
const keepRecent = opts.keepRecent ?? MAX_TERMINAL_SUBAGENT_RECORDS;
|
|
240
|
+
const shielded = new Set(
|
|
241
|
+
parsed
|
|
242
|
+
.slice()
|
|
243
|
+
.sort((a, b) => a.ageMs - b.ageMs) // smallest age = most recent first
|
|
244
|
+
.slice(0, Math.max(0, keepRecent))
|
|
245
|
+
.map((entry) => entry.id),
|
|
246
|
+
);
|
|
247
|
+
for (const entry of parsed) {
|
|
248
|
+
if (!entry.terminal || entry.ageMs <= ttlMs || shielded.has(entry.id)) {
|
|
249
|
+
kept++;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
fs.unlinkSync(path.join(dir, `${entry.id}.json`));
|
|
254
|
+
removed.push(entry.id);
|
|
255
|
+
} catch {
|
|
256
|
+
kept++; // unlink failed → still on disk
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
} catch (error) {
|
|
260
|
+
logInternalError("subagent-manager.prune-persisted", error, `cwd=${cwd}`);
|
|
261
|
+
}
|
|
262
|
+
return { removed, kept };
|
|
263
|
+
}
|
|
264
|
+
|
|
142
265
|
/**
|
|
143
266
|
* Predicate: should this record's persisted file be deleted on terminal status?
|
|
144
267
|
* Only abnormal terminations (cancelled / stopped / terminated-flag) are wiped.
|
|
@@ -384,6 +384,22 @@ export function parseHandoffFromOutput(output: string): ParsedHandoff {
|
|
|
384
384
|
};
|
|
385
385
|
}
|
|
386
386
|
|
|
387
|
-
|
|
388
|
-
|
|
387
|
+
/** R3 (adaptive-04, 2026-11-04): render options for the prompt seam.
|
|
388
|
+
* `omitObjective` swaps the packet's `objective` field for a POINTER line —
|
|
389
|
+
* the worker prompt already carries the task body as the canonical `Task:`
|
|
390
|
+
* narrative (prompt-builder.ts taskAndHandoff), and the JSON re-embed
|
|
391
|
+
* duplicated it verbatim (measured ~0.5-1K est-tok/worker, the "task body
|
|
392
|
+
* ×3" finding). Default (no options) keeps the full objective so non-prompt
|
|
393
|
+
* consumers and the direct unit contract (task-handoff-template.test.ts)
|
|
394
|
+
* are unchanged. The substitution is render-only: `TaskPacket` state and
|
|
395
|
+
* `validateTaskPacket` still see the real objective. */
|
|
396
|
+
export interface RenderTaskPacketOptions {
|
|
397
|
+
omitObjective?: boolean;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const OMITTED_OBJECTIVE_POINTER = "(see the 'Task:' section below — task body rendered once, not re-embedded here)";
|
|
401
|
+
|
|
402
|
+
export function renderTaskPacket(packet: TaskPacket, options?: RenderTaskPacketOptions): string {
|
|
403
|
+
const body = options?.omitObjective ? { ...packet, objective: OMITTED_OBJECTIVE_POINTER } : packet;
|
|
404
|
+
return ["# Task Packet", "", "```json", JSON.stringify(body, null, 2), "```", ""].join("\n");
|
|
389
405
|
}
|
|
@@ -152,9 +152,10 @@ export async function prepareTaskExecutionContext(
|
|
|
152
152
|
// caller provided one) into the dep-context collection — cache hits reuse
|
|
153
153
|
// the closeout's reads byte-identically; undefined keeps uncached behavior.
|
|
154
154
|
const dependencyContext = collectDependencyOutputContext(manifest, input.tasks, input.task, input.step, input.resultReadCache);
|
|
155
|
-
// Handoff budget: caps the dynamic.dependencyContext layer at an
|
|
156
|
-
//
|
|
157
|
-
//
|
|
155
|
+
// Handoff budget: caps the dynamic.dependencyContext layer at an
|
|
156
|
+
// effective-token budget (CJK-aware countTokens, utils/token-counter.ts)
|
|
157
|
+
// (env PI_CREW_HANDOFF_BUDGET_TOKENS > runtime config > default 1800).
|
|
158
|
+
// IMPORTANT: only the RENDER path is budgeted — an explicit
|
|
158
159
|
// input.dependencyContextText override (leader-provided text) passes through
|
|
159
160
|
// UNCHANGED.
|
|
160
161
|
const dependencyContextText =
|
|
@@ -8,7 +8,7 @@ import { buildMemoryBlock } from "../agent-memory.ts";
|
|
|
8
8
|
import { permissionForRole } from "../role-permission.ts";
|
|
9
9
|
import { scrubDependencyContextBody } from "../task-output-context.ts";
|
|
10
10
|
import { HANDOFF_TEMPLATE, renderTaskPacket, sanitizeTaskText } from "../task-packet.ts";
|
|
11
|
-
import {
|
|
11
|
+
import { buildTaskWorkspaceTree } from "../workspace-tree.ts";
|
|
12
12
|
import { renderSuggestedFilesSection, runRetrievalCycle } from "./retrieval-orchestrator.ts";
|
|
13
13
|
|
|
14
14
|
/**
|
|
@@ -159,23 +159,40 @@ export function renderOutputSchemaBlock(outputSchema: TaskOutputSchema): string
|
|
|
159
159
|
* Expensive async sub-results that compose the stable prefix.
|
|
160
160
|
* These are cached per (cwd, step.task, runId) so parallel siblings
|
|
161
161
|
* in the same batch reuse them instead of recomputing.
|
|
162
|
+
*
|
|
163
|
+
* R2 (adaptive-04): the workspace tree is NO LONGER part of these
|
|
164
|
+
* components — it is now per-task (subtree around the task's working
|
|
165
|
+
* files), so caching it under the shared keys below would leak one task's
|
|
166
|
+
* subtree into its siblings. renderTaskPrompt builds the tree itself via
|
|
167
|
+
* buildTaskWorkspaceTree, which has its own per-root TTL cache.
|
|
162
168
|
*/
|
|
163
169
|
export interface StableComponents {
|
|
164
|
-
treeBlock: string;
|
|
165
170
|
suggestedFilesBlock: string;
|
|
171
|
+
/**
|
|
172
|
+
* R2.2 (adaptive-03): the retrieval-suggested file paths (relative to
|
|
173
|
+
* the task cwd) behind `suggestedFilesBlock`. Carried alongside the
|
|
174
|
+
* rendered block so the per-task workspace-tree scoping in
|
|
175
|
+
* renderTaskPrompt can fall back to them when a step declares no
|
|
176
|
+
* `reads` — no second retrieval pass. Both cache read sites must return
|
|
177
|
+
* the SAME array the fresh compute produced: the keys already cover the
|
|
178
|
+
* retrieval inputs (per-run key = cwd+step+runId with a run-fixed goal;
|
|
179
|
+
* the cross-run io key includes the goal), so no key change is needed.
|
|
180
|
+
*/
|
|
181
|
+
suggestedFiles: string[];
|
|
166
182
|
knowledgeFragment: string;
|
|
167
183
|
}
|
|
168
184
|
|
|
169
185
|
const stableComponentCache = new Map<string, StableComponents>();
|
|
170
186
|
|
|
171
|
-
// P9 (perf): cross-run cache for the I/O-heavy sub-results (
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
187
|
+
// P9 (perf): cross-run cache for the I/O-heavy sub-results (retrieval +
|
|
188
|
+
// knowledge; R2 moved the workspace tree out to a per-task scope, so it no
|
|
189
|
+
// longer rides these goal-scoped caches). Retrieval and knowledge don't
|
|
190
|
+
// depend on runId, but they DO depend on the run GOAL:
|
|
191
|
+
// `runRetrievalCycle(step.task, goal, cwd)` and `buildKnowledgeFragment(cwd,
|
|
192
|
+
// { goal, taskText, role })` both take the goal as a query signal. A
|
|
193
|
+
// short-lived (TTL-bounded) cross-run cache lets sequential runs in the same
|
|
194
|
+
// session amortize the cost: run #2 in cwd X with the same step text AND the
|
|
195
|
+
// same goal gets a cache hit instead of redoing `runRetrievalCycle`. The TTL
|
|
179
196
|
// bounds staleness in long-lived sessions (e.g., the workspace may have
|
|
180
197
|
// changed between runs); a mtime check on the .git/HEAD or workspace marker
|
|
181
198
|
// would be overkill for an already-bounded perf win. The full per-run cache
|
|
@@ -195,8 +212,9 @@ const stableComponentCache = new Map<string, StableComponents>();
|
|
|
195
212
|
// (src/extension/knowledge-injection.ts:61-68); if it ever starts scoring, the
|
|
196
213
|
// key must gain it too.
|
|
197
214
|
interface CachedStableIO {
|
|
198
|
-
treeBlock: string;
|
|
199
215
|
suggestedFilesBlock: string;
|
|
216
|
+
/** R2.2: retrieval file paths ride the cross-run cache with the block. */
|
|
217
|
+
suggestedFiles: string[];
|
|
200
218
|
knowledgeFragment: string;
|
|
201
219
|
at: number;
|
|
202
220
|
}
|
|
@@ -229,7 +247,8 @@ export function clearStablePrefixCache(): void {
|
|
|
229
247
|
|
|
230
248
|
/**
|
|
231
249
|
* Compute (or return cached) expensive async sub-results that compose
|
|
232
|
-
* the stable prefix:
|
|
250
|
+
* the stable prefix: file retrieval, knowledge fragment. (R2: the workspace
|
|
251
|
+
* tree left this cache — it is per-task now; see StableComponents.)
|
|
233
252
|
* Parallel siblings with the same cwd/step/run reuse the cached result.
|
|
234
253
|
*/
|
|
235
254
|
export async function computeStablePrefixComponents(
|
|
@@ -255,19 +274,22 @@ export async function computeStablePrefixComponents(
|
|
|
255
274
|
const ioFresh = ioCached && now - ioCached.at < STABLE_IO_TTL_MS;
|
|
256
275
|
if (ioFresh) {
|
|
257
276
|
const components: StableComponents = {
|
|
258
|
-
treeBlock: ioCached!.treeBlock,
|
|
259
277
|
suggestedFilesBlock: ioCached!.suggestedFilesBlock,
|
|
278
|
+
// R2.2: reconstruct the retrieval paths too — a cross-run hit that
|
|
279
|
+
// dropped them would silently de-scope the caller's task tree back
|
|
280
|
+
// to the whole workspace.
|
|
281
|
+
suggestedFiles: ioCached!.suggestedFiles,
|
|
260
282
|
knowledgeFragment: ioCached!.knowledgeFragment,
|
|
261
283
|
};
|
|
262
284
|
stableComponentCache.set(cacheKey, components);
|
|
263
285
|
return components;
|
|
264
286
|
}
|
|
265
287
|
|
|
266
|
-
const tree = await buildWorkspaceTree(task.cwd);
|
|
267
|
-
const treeBlock = tree.rendered ? `# Workspace Structure\n${tree.rendered}` : "";
|
|
268
|
-
|
|
269
288
|
const retrieval = await runRetrievalCycle(step.task, manifest.goal, task.cwd);
|
|
270
289
|
const suggestedFilesBlock = renderSuggestedFilesSection(retrieval);
|
|
290
|
+
// R2.2: keep the raw paths (relative to task.cwd) next to the rendered
|
|
291
|
+
// block so the tree scoping below can reuse the retrieval result.
|
|
292
|
+
const suggestedFiles = retrieval.files.map((file) => file.path);
|
|
271
293
|
|
|
272
294
|
const knowledgeFragment = buildKnowledgeFragment(task.cwd, {
|
|
273
295
|
goal: manifest.goal,
|
|
@@ -275,7 +297,7 @@ export async function computeStablePrefixComponents(
|
|
|
275
297
|
role: step.role,
|
|
276
298
|
});
|
|
277
299
|
|
|
278
|
-
const components: StableComponents = {
|
|
300
|
+
const components: StableComponents = { suggestedFilesBlock, suggestedFiles, knowledgeFragment };
|
|
279
301
|
stableComponentCache.set(cacheKey, components);
|
|
280
302
|
// RR-021 WI-4.3f: same insertion-order eviction cap as the stableIOCache
|
|
281
303
|
// sibling below — stableComponentCache had NO cap (unbounded across a
|
|
@@ -321,6 +343,28 @@ export function promptBreakdownEnabled(): boolean {
|
|
|
321
343
|
return getCrewEnv("PI_CREW_PROMPT_BREAKDOWN") === "1";
|
|
322
344
|
}
|
|
323
345
|
|
|
346
|
+
/**
|
|
347
|
+
* R2.2 (adaptive-03): pick the focus paths for the task-scoped workspace
|
|
348
|
+
* tree. `step.reads` (declared inputs) is the strongest signal and wins
|
|
349
|
+
* whenever it yields at least one non-empty string (`reads: false` means
|
|
350
|
+
* "no declared reads" — the same normalization used by task-packet.ts and
|
|
351
|
+
* task-output-context.ts — so it falls through, it does not opt out). When
|
|
352
|
+
* the step declares none, fall back to the retrieval-suggested files that
|
|
353
|
+
* already ride the (cached) StableComponents — zero extra I/O. Anything
|
|
354
|
+
* else (no reads AND no suggestions, junk-only entries) yields `undefined`
|
|
355
|
+
* so buildTaskWorkspaceTree keeps the whole-workspace fallback, the
|
|
356
|
+
* pre-R2.2 fail-safe. Out-of-workspace/nonexistent paths keep their
|
|
357
|
+
* existing NIT-2/dir-refinement semantics inside the resolver.
|
|
358
|
+
*/
|
|
359
|
+
function treeFocusPaths(reads: WorkflowStep["reads"], suggestedFiles: readonly string[] | undefined): string[] | undefined {
|
|
360
|
+
const declared = Array.isArray(reads) ? reads.filter((entry): entry is string => typeof entry === "string" && entry.trim() !== "") : [];
|
|
361
|
+
if (declared.length > 0) return declared;
|
|
362
|
+
const retrieved = Array.isArray(suggestedFiles)
|
|
363
|
+
? suggestedFiles.filter((entry): entry is string => typeof entry === "string" && entry.trim() !== "")
|
|
364
|
+
: [];
|
|
365
|
+
return retrieved.length > 0 ? retrieved : undefined;
|
|
366
|
+
}
|
|
367
|
+
|
|
324
368
|
/**
|
|
325
369
|
* SR-02 phase 2: worker-prompt skill injection mode. Default "index" injects
|
|
326
370
|
* compact per-skill entries (name + description + Path pointer) and the worker
|
|
@@ -413,7 +457,13 @@ export async function renderTaskPrompt(
|
|
|
413
457
|
`Step: ${step.id}`,
|
|
414
458
|
`Role: ${step.role}`,
|
|
415
459
|
].join("\n");
|
|
416
|
-
|
|
460
|
+
// R3 (adaptive-04): single-canonical task body. The packet JSON used to
|
|
461
|
+
// re-embed the full objective — the SAME text as the `Task:` narrative
|
|
462
|
+
// below — a second time ("task body ×3" finding; the third copy is the
|
|
463
|
+
// parent-context embed in child-pi.ts, outside this seam). The JSON block
|
|
464
|
+
// now carries a pointer; renderTaskPacket's default still embeds the
|
|
465
|
+
// objective for non-prompt consumers and the direct unit contract.
|
|
466
|
+
const taskPacketBlock = task.taskPacket ? renderTaskPacket(task.taskPacket, { omitObjective: true }) : "";
|
|
417
467
|
const specContractBlock = task.taskPacket?.specSnapshots?.length
|
|
418
468
|
? renderSpecContractBlock(task.taskPacket, { verifier: step.role === "verifier" })
|
|
419
469
|
: "";
|
|
@@ -427,16 +477,35 @@ export async function renderTaskPrompt(
|
|
|
427
477
|
HANDOFF_TEMPLATE,
|
|
428
478
|
].join("\n");
|
|
429
479
|
|
|
430
|
-
//
|
|
480
|
+
// R2 (adaptive-04): the workspace tree is scoped to a subtree AROUND this
|
|
481
|
+
// task's working files instead of the identical whole-workspace walk that
|
|
482
|
+
// every sibling worker used to carry. Per-task content must NOT ride the
|
|
483
|
+
// byte-stable shared prefix (ARCH-3), so the block lives at the END of the
|
|
484
|
+
// dynamic suffix; buildWorkspaceTree's per-root TTL cache keeps parallel
|
|
485
|
+
// siblings sharing a scope on one walk.
|
|
486
|
+
// R2.2 (adaptive-03): the focus signal is reads-first — step.reads when the
|
|
487
|
+
// step declares inputs, else the retrieval-suggested files from the stable
|
|
488
|
+
// components (the smoke-run steps declare no reads, so pre-R2.2 they always
|
|
489
|
+
// carried the whole tree). No signal / out-of-workspace / non-existent
|
|
490
|
+
// scope ⇒ legacy full tree (fail-safe unchanged).
|
|
491
|
+
const { tree: taskTree, scope: treeScope } = await buildTaskWorkspaceTree(
|
|
492
|
+
task.cwd,
|
|
493
|
+
treeFocusPaths(step.reads, stableComponents.suggestedFiles),
|
|
494
|
+
);
|
|
495
|
+
const treeBlock = taskTree.rendered
|
|
496
|
+
? `# Workspace Structure${treeScope.relativePath === "." ? "" : ` (subtree: ${treeScope.relativePath})`}\n${taskTree.rendered}`
|
|
497
|
+
: "";
|
|
498
|
+
|
|
499
|
+
// Stable prefix: role instructions, coordination — rarely changes.
|
|
431
500
|
// ARCH-3 (byte-stable worker prefix): per-task values (Task ID, Task cwd, mailbox
|
|
432
|
-
// target) live in dynamicSuffix so siblings
|
|
433
|
-
// byte-identical prefix and hit provider KV-cache
|
|
501
|
+
// target, the R2 task-scoped workspace tree) live in dynamicSuffix so siblings
|
|
502
|
+
// sharing a run+role produce a byte-identical prefix and hit provider KV-cache
|
|
503
|
+
// across the batch.
|
|
434
504
|
const stablePrefix = [
|
|
435
505
|
headerBlock,
|
|
436
506
|
protocolBlock,
|
|
437
507
|
roleInstructions,
|
|
438
508
|
coordination,
|
|
439
|
-
stableComponents.treeBlock,
|
|
440
509
|
stableComponents.suggestedFilesBlock,
|
|
441
510
|
toolGuidance,
|
|
442
511
|
// O4 (ARCH-2 corrected): project knowledge (.crew/knowledge.md). Builtin
|
|
@@ -451,8 +520,10 @@ export async function renderTaskPrompt(
|
|
|
451
520
|
.filter(Boolean)
|
|
452
521
|
.join("\n");
|
|
453
522
|
|
|
454
|
-
// Dynamic suffix: goal, step, skills, task packet, dependency context, memory — changes per task
|
|
455
|
-
|
|
523
|
+
// Dynamic suffix: goal, step, skills, task packet, dependency context, memory — changes per task.
|
|
524
|
+
// R2: the task-scoped workspace tree is pinned LAST (the most task-variable
|
|
525
|
+
// block) so everything before it keeps the per-task layout stable.
|
|
526
|
+
const dynamicParts = [
|
|
456
527
|
taskHeader,
|
|
457
528
|
"",
|
|
458
529
|
skillBlock,
|
|
@@ -465,7 +536,9 @@ export async function renderTaskPrompt(
|
|
|
465
536
|
memoryBlock,
|
|
466
537
|
outputSchemaBlock,
|
|
467
538
|
taskAndHandoff,
|
|
468
|
-
]
|
|
539
|
+
];
|
|
540
|
+
if (treeBlock) dynamicParts.push("", treeBlock);
|
|
541
|
+
const dynamicSuffix = dynamicParts.join("\n");
|
|
469
542
|
|
|
470
543
|
const full = [stablePrefix, "", dynamicSuffix].join("\n");
|
|
471
544
|
const sections: Record<string, number> | undefined = promptBreakdownEnabled()
|
|
@@ -476,7 +549,11 @@ export async function renderTaskPrompt(
|
|
|
476
549
|
"stable.protocol": protocolBlock.length,
|
|
477
550
|
"stable.roleInstructions": roleInstructions.length,
|
|
478
551
|
"stable.coordination": coordination.length,
|
|
479
|
-
|
|
552
|
+
// R2 mapping for before/after comparison: pre-R2 key was
|
|
553
|
+
// "stable.workspaceTree" (identical whole-workspace walk ×N,
|
|
554
|
+
// riding the system-append channel); post-R2 the tree is a
|
|
555
|
+
// per-task subtree in the user message → "dynamic.workspaceTree".
|
|
556
|
+
"dynamic.workspaceTree": treeBlock.length,
|
|
480
557
|
"stable.suggestedFiles": stableComponents.suggestedFilesBlock.length,
|
|
481
558
|
"stable.toolGuidance": toolGuidance.length,
|
|
482
559
|
"stable.knowledge": stableComponents.knowledgeFragment.length,
|
|
@@ -245,6 +245,138 @@ function applyLineCap(lines: RenderLine[], cap: number): { lines: RenderLine[];
|
|
|
245
245
|
return { lines: kept, elided: removable.length };
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
+
// ── Task-scoped subtree (R2, adaptive-04 2026-11-04) ───────────────────
|
|
249
|
+
|
|
250
|
+
export interface TaskTreeScope {
|
|
251
|
+
/** Absolute directory the walk roots at. */
|
|
252
|
+
rootPath: string;
|
|
253
|
+
/** Scope relative to the workspace root; "." when the walk covers the whole workspace. */
|
|
254
|
+
relativePath: string;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Normalize a native-separator relative path to forward slashes (no-op on POSIX). */
|
|
258
|
+
function toPosixRel(p: string): string {
|
|
259
|
+
return path.sep === "/" ? p : p.split(path.sep).join("/");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Longest shared directory prefix across relative dir paths ("." when none). */
|
|
263
|
+
function commonDirPrefix(dirs: string[]): string {
|
|
264
|
+
const split = dirs.map((d) => d.split(path.sep).filter((p) => p !== "" && p !== "."));
|
|
265
|
+
let prefix = split[0] ?? [];
|
|
266
|
+
for (let i = 1; i < split.length && prefix.length > 0; i++) {
|
|
267
|
+
const other = split[i]!;
|
|
268
|
+
const keep: string[] = [];
|
|
269
|
+
for (let j = 0; j < Math.min(prefix.length, other.length); j++) {
|
|
270
|
+
if (prefix[j] === other[j]) keep.push(prefix[j]!);
|
|
271
|
+
else break;
|
|
272
|
+
}
|
|
273
|
+
prefix = keep;
|
|
274
|
+
}
|
|
275
|
+
return prefix.length > 0 ? prefix.join(path.sep) : ".";
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* R2 (adaptive-04): resolve the walk root for a task-scoped workspace tree.
|
|
280
|
+
*
|
|
281
|
+
* The legacy prompt seam walked the ENTIRE workspace from `task.cwd` — in
|
|
282
|
+
* shared-workspace mode that is the same root for every sibling worker, so
|
|
283
|
+
* each prompt carried an identical whole-workspace tree (measured 26% of the
|
|
284
|
+
* 01_discover artifact, ~0.9K est-tok/worker). This resolves a subtree
|
|
285
|
+
* AROUND the task's working files instead: the deepest directory that is an
|
|
286
|
+
* ancestor of every in-workspace focus path (focus paths are the step's
|
|
287
|
+
* `reads`, which follow file-path semantics — a directory-valued read
|
|
288
|
+
* contributes its parent and therefore narrows nothing).
|
|
289
|
+
*
|
|
290
|
+
* Deterministic and pure (no FS access) so unit tests pin the scoping rule
|
|
291
|
+
* directly. Falls back to "." (whole workspace, legacy behavior) when there
|
|
292
|
+
* are no focus paths, when they all live outside the workspace, or when they
|
|
293
|
+
* share no directory below the root.
|
|
294
|
+
*/
|
|
295
|
+
export function resolveTaskTreeScope(workspaceCwd: string, focusPaths?: readonly string[] | false | null): TaskTreeScope {
|
|
296
|
+
const rootPath = path.resolve(workspaceCwd);
|
|
297
|
+
if (!focusPaths || focusPaths.length === 0) return { rootPath, relativePath: "." };
|
|
298
|
+
|
|
299
|
+
const insideDirs: string[] = [];
|
|
300
|
+
for (const entry of focusPaths) {
|
|
301
|
+
if (typeof entry !== "string" || entry.trim() === "") continue;
|
|
302
|
+
const rel = path.relative(rootPath, path.resolve(rootPath, entry));
|
|
303
|
+
// Outside the workspace ("../x", absolute elsewhere) cannot scope it.
|
|
304
|
+
// Exact `..` or `..`+sep only — a literal "..foo" sibling stays inside.
|
|
305
|
+
if (!rel || rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) continue;
|
|
306
|
+
insideDirs.push(path.dirname(rel));
|
|
307
|
+
}
|
|
308
|
+
if (insideDirs.length === 0) return { rootPath, relativePath: "." };
|
|
309
|
+
|
|
310
|
+
const common = commonDirPrefix(insideDirs);
|
|
311
|
+
if (common === ".") return { rootPath, relativePath: "." };
|
|
312
|
+
// relativePath is a POSIX-style contract (prompt header, breakdown, tests) —
|
|
313
|
+
// normalize Windows separators at the boundary; rootPath stays native for FS.
|
|
314
|
+
return { rootPath: path.join(rootPath, common), relativePath: toPosixRel(common) };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export interface TaskWorkspaceTree {
|
|
318
|
+
tree: WorkspaceTree;
|
|
319
|
+
scope: TaskTreeScope;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* R2 (adaptive-04): build the workspace tree scoped to a subtree around the
|
|
324
|
+
* task's working files. Options/defaults are the legacy `buildWorkspaceTree`
|
|
325
|
+
* ones (depth 3 / dirLimit 12 / lineCap 120) — only the walk ROOT narrows.
|
|
326
|
+
*
|
|
327
|
+
* Fail-safe: a focus path pointing at a directory that does not exist (typo,
|
|
328
|
+
* glob pattern, stale path) must not strip the worker's tree — the walk falls back to the whole workspace, byte-identical to pre-R2.
|
|
329
|
+
* Per-root walks reuse `buildWorkspaceTree`'s TTL cache, so parallel
|
|
330
|
+
* siblings that share a scope do one FS walk.
|
|
331
|
+
*/
|
|
332
|
+
export async function buildTaskWorkspaceTree(
|
|
333
|
+
workspaceCwd: string,
|
|
334
|
+
focusPaths?: readonly string[] | false | null,
|
|
335
|
+
options?: WorkspaceTreeOptions,
|
|
336
|
+
): Promise<TaskWorkspaceTree> {
|
|
337
|
+
const scope = resolveTaskTreeScope(workspaceCwd, focusPaths);
|
|
338
|
+
// NIT-2 (review follow-up): a top-level directory-valued focus path
|
|
339
|
+
// (e.g. reads: ["pi-crew"]) contributes dirname "." in the PURE resolver —
|
|
340
|
+
// file-path semantics — and de-scopes to the whole workspace even though
|
|
341
|
+
// the task plainly scopes to that directory. Refine HERE (the FS layer;
|
|
342
|
+
// resolveTaskTreeScope stays FS-free): on the fallback path only, stat each
|
|
343
|
+
// inside-workspace focus path and treat an EXISTING directory as its own
|
|
344
|
+
// scope root. Nonexistent paths keep file-path semantics (dirname), so a
|
|
345
|
+
// typo/glob still falls back fail-safe; a directory mixed with a root-level
|
|
346
|
+
// file still resolves to the whole workspace (conservative, no narrowing).
|
|
347
|
+
let effective = scope;
|
|
348
|
+
if (scope.relativePath === "." && Array.isArray(focusPaths) && focusPaths.length > 0) {
|
|
349
|
+
const rootPath = path.resolve(workspaceCwd);
|
|
350
|
+
const candidates: string[] = [];
|
|
351
|
+
for (const entry of focusPaths) {
|
|
352
|
+
if (typeof entry !== "string" || entry.trim() === "") continue;
|
|
353
|
+
const abs = path.resolve(rootPath, entry);
|
|
354
|
+
const rel = path.relative(rootPath, abs);
|
|
355
|
+
if (!rel || rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) continue;
|
|
356
|
+
let isDir = false;
|
|
357
|
+
try {
|
|
358
|
+
isDir = (await fs.stat(abs)).isDirectory();
|
|
359
|
+
} catch {
|
|
360
|
+
isDir = false;
|
|
361
|
+
}
|
|
362
|
+
candidates.push(isDir ? rel : path.dirname(rel));
|
|
363
|
+
}
|
|
364
|
+
if (candidates.length > 0) {
|
|
365
|
+
const common = commonDirPrefix(candidates);
|
|
366
|
+
if (common !== ".") effective = { rootPath: path.join(rootPath, common), relativePath: toPosixRel(common) };
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (effective.relativePath !== ".") {
|
|
370
|
+
try {
|
|
371
|
+
await fs.stat(effective.rootPath);
|
|
372
|
+
} catch {
|
|
373
|
+
const rootPath = path.resolve(workspaceCwd);
|
|
374
|
+
return { tree: await buildWorkspaceTree(rootPath, options), scope: { rootPath, relativePath: "." } };
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return { tree: await buildWorkspaceTree(effective.rootPath, options), scope: effective };
|
|
378
|
+
}
|
|
379
|
+
|
|
248
380
|
// ── Public API ────────────────────────────────────────────────────────
|
|
249
381
|
|
|
250
382
|
const emptyResult = (rootPath: string): WorkspaceTree => ({
|