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
package/docs/architecture.md
CHANGED
|
@@ -102,6 +102,7 @@ lives there. The ones most likely to be set by hand:
|
|
|
102
102
|
| `PI_CREW_ASYNC_EARLY_EXIT_GUARD=0` | Disable the async-run early-exit guard. |
|
|
103
103
|
| `PI_TEAMS_MOCK_CHILD_PI` / `PI_CREW_MOCK_LIVE_SESSION` | Test hooks for mocked worker execution. |
|
|
104
104
|
| `PI_CREW_MAX_WORKERS` | Cap concurrent workers (default `max(2, cpus−2)`). |
|
|
105
|
+
| `PI_CREW_KNOWLEDGE_MAX_BYTES` | Cap knowledge.md injection (bytes) across every path — worker prompts and the main session alike; `0`/negative disables knowledge injection entirely (unset keeps per-path defaults 5000/2048/2000). |
|
|
105
106
|
| `PI_TEAMS_CHILD_RESPONSE_TIMEOUT_MS` | Child Pi response timeout. |
|
|
106
107
|
|
|
107
108
|
### Debugging a run
|
package/package.json
CHANGED
|
@@ -79,8 +79,9 @@ export const LIMIT_CEILINGS = {
|
|
|
79
79
|
// (10_000 turns), which capped the effective timeout at 10s and silently disabled
|
|
80
80
|
// any larger value (e.g. 300_000 = 5min) via parsePositiveInteger returning undefined.
|
|
81
81
|
runtimeTaskTimeoutMs: 24 * 60 * 60 * 1000,
|
|
82
|
-
// Handoff budget (
|
|
83
|
-
//
|
|
82
|
+
// Handoff budget (effective tokens — CJK-aware countTokens,
|
|
83
|
+
// utils/token-counter.ts) for the dynamic.dependencyContext prompt layer.
|
|
84
|
+
// Above the ceiling the budget is treated as the OFF-switch
|
|
84
85
|
// (untrimmed render), mirroring the render-site semantics in
|
|
85
86
|
// task-output-context.ts.
|
|
86
87
|
runtimeHandoffBudgetTokens: 1_000_000,
|
|
@@ -413,8 +414,10 @@ function parseModelFallbackConfig(value: unknown): CrewRuntimeConfig["modelFallb
|
|
|
413
414
|
}
|
|
414
415
|
|
|
415
416
|
/**
|
|
416
|
-
* Handoff budget for the dynamic.dependencyContext prompt layer (
|
|
417
|
-
*
|
|
417
|
+
* Handoff budget for the dynamic.dependencyContext prompt layer (effective
|
|
418
|
+
* tokens — CJK-aware countTokens from utils/token-counter.ts, floored by the
|
|
419
|
+
* measured-output anchor at the render site; the old chars/4 wording was
|
|
420
|
+
* drift). Deliberately NOT parsePositiveInteger: out-of-range values are
|
|
418
421
|
* dropped silently (undefined → render site falls back to default 1800)
|
|
419
422
|
* rather than rejecting the whole runtime block. The ≤0 / >1_000_000
|
|
420
423
|
* OFF-switch is expressible only via env PI_CREW_HANDOFF_BUDGET_TOKENS
|
|
@@ -279,12 +279,17 @@ const CREW_ENV_FORWARDING_DECL: Record<string, CrewEnvForwardDecision> = {
|
|
|
279
279
|
PI_CREW_KEYBINDINGS: { kind: "host-only", reason: "host keybinding map" },
|
|
280
280
|
PI_CREW_AUTO_PRUNE_KEEP: { kind: "host-only", reason: "session-start auto-prune (run-maintenance, host)" },
|
|
281
281
|
PI_CREW_AUTO_PRUNE_AGE_FLOOR_HOURS: { kind: "host-only", reason: "session-start auto-prune (host)" },
|
|
282
|
+
PI_CREW_SUBAGENT_RECORD_TTL_DAYS: { kind: "host-only", reason: "session-start subagent-record TTL prune (subagent-manager, host)" },
|
|
282
283
|
PI_CREW_USE_BUNDLE: { kind: "host-only", reason: "root index.ts loader choice (outside src/ gate scope)" },
|
|
283
284
|
|
|
284
285
|
// — gap: read by code that ALSO runs in the detached runner, deliberately
|
|
285
286
|
// NOT forwarded (behavior preservation 0.11.17) — follow-up candidates.
|
|
286
287
|
PI_CREW_PROMPT_BREAKDOWN: { kind: "gap", reason: "prompt-builder runs in the runner for async runs" },
|
|
287
288
|
PI_CREW_PROMPT_SKILLS: { kind: "gap", reason: "prompt-builder runs in the runner for async runs" },
|
|
289
|
+
PI_CREW_KNOWLEDGE_MAX_BYTES: {
|
|
290
|
+
kind: "gap",
|
|
291
|
+
reason: "knowledge-injection read (prompt-builder stable prefix) runs in the runner for async runs; deliberately not forwarded — workers receive knowledge via prompt text, not env (same class as PI_CREW_PROMPT_SKILLS)",
|
|
292
|
+
},
|
|
288
293
|
PI_CREW_BACKGROUND_MODE: { kind: "gap", reason: "child-executor read inside the runner" },
|
|
289
294
|
PI_CREW_DEBUG: { kind: "gap", reason: "background-runner debug logging" },
|
|
290
295
|
PI_TEAMS_DEBUG: { kind: "gap", reason: "internal-error severity gate — every process incl. the runner" },
|
package/src/config/env-vars.ts
CHANGED
|
@@ -263,6 +263,11 @@ export const CREW_ENV_VARS: Record<string, CrewEnvVarSpec> = {
|
|
|
263
263
|
name: "PI_CREW_PROMPT_SKILLS",
|
|
264
264
|
doc: "'full' inlines complete skill bodies in worker prompts (SR-02 escape hatch); default injects compact index entries (name+description+path, read-on-demand)",
|
|
265
265
|
},
|
|
266
|
+
PI_CREW_KNOWLEDGE_MAX_BYTES: {
|
|
267
|
+
name: "PI_CREW_KNOWLEDGE_MAX_BYTES",
|
|
268
|
+
parser: "int",
|
|
269
|
+
doc: "R1 (adaptive context diet): byte cap for knowledge.md injection — overrides EVERY knowledge-injection cap at once (worker/query path MAX_SESSION_LOG_BYTES 5000 + MAX_CONVENTIONS_BYTES 2048, no-query main-session path MAX_KNOWLEDGE_HEAD_BYTES 2000; knowledge-injection.ts readKnowledge). Unset/non-integer → per-path defaults (byte-identical to pre-R1 behavior, pinned by unit test); ≥1 → each cap becomes N; ≤0 → off-switch, injects NO knowledge",
|
|
270
|
+
},
|
|
266
271
|
PI_CREW_ASYNC_EARLY_EXIT_GUARD: {
|
|
267
272
|
name: "PI_CREW_ASYNC_EARLY_EXIT_GUARD",
|
|
268
273
|
doc: "'0' skips the async-run early-exit guard (team-tool/run.ts:103)",
|
|
@@ -345,7 +350,7 @@ export const CREW_ENV_VARS: Record<string, CrewEnvVarSpec> = {
|
|
|
345
350
|
PI_CREW_HANDOFF_BUDGET_TOKENS: {
|
|
346
351
|
name: "PI_CREW_HANDOFF_BUDGET_TOKENS",
|
|
347
352
|
parser: "int",
|
|
348
|
-
doc: "
|
|
353
|
+
doc: "effective-token budget for the dynamic.dependencyContext prompt layer (CJK-aware countTokens, utils/token-counter.ts — NOT chars/4, that wording was drift); beats runtime.handoffBudgetTokens, default 1800; resolved ≤0 or >1_000_000 disables the trim (task-output-context.ts renderDependencyOutputContext)",
|
|
349
354
|
},
|
|
350
355
|
PI_CREW_MOCK_LIVE_SESSION: {
|
|
351
356
|
name: "PI_CREW_MOCK_LIVE_SESSION",
|
|
@@ -606,6 +611,12 @@ export const CREW_ENV_VARS: Record<string, CrewEnvVarSpec> = {
|
|
|
606
611
|
default: 24, // wave-2b: numeric default (was string "24" — fallback bypassed the parser)
|
|
607
612
|
doc: "DP-01: auto-prune never deletes a finished run younger than this many hours, even beyond top-keep — evidence/incident runs survive a session restart (0 disables; run-maintenance.ts:resolveAutoPruneAgeFloorMs)",
|
|
608
613
|
},
|
|
614
|
+
PI_CREW_SUBAGENT_RECORD_TTL_DAYS: {
|
|
615
|
+
name: "PI_CREW_SUBAGENT_RECORD_TTL_DAYS",
|
|
616
|
+
parser: "int",
|
|
617
|
+
default: 7,
|
|
618
|
+
doc: "FIX 3: days a TERMINAL subagent record (<crewRoot>/state/subagents/*.json) survives before the session-start prune deletes it (0 disables; running/blocked/queued records are NEVER deleted — blocked drives blockedSignalFromSubagentDir; subagent-manager.ts prunePersistedSubagentRecords)",
|
|
619
|
+
},
|
|
609
620
|
PI_CREW_SURFACE: {
|
|
610
621
|
name: "PI_CREW_SURFACE",
|
|
611
622
|
doc: "surface provider kind for this worker ('tmux'|'herdr') — arms the worker-side recorder/parent-guard (written by prepareSurfaceSpawn.ts:214, read by surface-worker.ts)",
|
package/src/config/role-tools.ts
CHANGED
|
@@ -60,9 +60,10 @@ export const ROLE_TOOL_CONFIGS: Record<string, RoleToolConfig> = {
|
|
|
60
60
|
scratchpad: true,
|
|
61
61
|
},
|
|
62
62
|
|
|
63
|
-
// Reviewer - Read and review, no write
|
|
63
|
+
// Reviewer - Read and review, no write (bash dropped 2026-11-05: read_only
|
|
64
|
+
// tier strips it at runtime anyway — R6 drift fix, agents/reviewer.md)
|
|
64
65
|
reviewer: {
|
|
65
|
-
tools: ["read", "grep", "find", "ls", "glob", "
|
|
66
|
+
tools: ["read", "grep", "find", "ls", "glob", "ask"],
|
|
66
67
|
excludeTools: ["edit", "write"],
|
|
67
68
|
},
|
|
68
69
|
|
package/src/config/types.ts
CHANGED
|
@@ -180,10 +180,12 @@ export interface CrewRuntimeConfig {
|
|
|
180
180
|
/** Mark certain bash commands as excludeFromContext to reduce context tokens. Default: false */
|
|
181
181
|
excludeContextBash?: boolean;
|
|
182
182
|
/**
|
|
183
|
-
*
|
|
183
|
+
* Effective-token budget for the dynamic.dependencyContext prompt layer
|
|
184
184
|
* (handoff budget). When the rendered dependency output exceeds this many
|
|
185
|
-
*
|
|
186
|
-
*
|
|
185
|
+
* effective tokens (countTokens — the CJK-aware estimator in
|
|
186
|
+
* utils/token-counter.ts, floored by the measured-output anchor; the old
|
|
187
|
+
* chars/4 wording was drift), dependencies are trimmed IN DECLARATION
|
|
188
|
+
* ORDER: earlier deps keep
|
|
187
189
|
* their full output, later ones are downgraded to taskId/role/status + a
|
|
188
190
|
* ≤240-char summary head + an artifact pointer
|
|
189
191
|
* (`artifacts/<runId>/results/<taskId>.txt`) the worker can `read` back.
|
|
@@ -52,6 +52,64 @@ const MAX_SESSION_LOG_BYTES = 5_000;
|
|
|
52
52
|
*/
|
|
53
53
|
export const MAX_CONVENTIONS_BYTES = 2_048;
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* R1 (adaptive context diet, 2026-11-04): single env knob capping how much
|
|
57
|
+
* knowledge.md content is injected, read via getCrewEnv so the registry +
|
|
58
|
+
* env gate stay the single read path. PI_CREW_KNOWLEDGE_MAX_BYTES overrides
|
|
59
|
+
* EVERY injection cap in this file — the worker/query-path caps
|
|
60
|
+
* (MAX_SESSION_LOG_BYTES + MAX_CONVENTIONS_BYTES) AND the no-query
|
|
61
|
+
* main-session head cap (MAX_KNOWLEDGE_HEAD_BYTES).
|
|
62
|
+
*
|
|
63
|
+
* WHY one knob over all three caps (decision recorded per task packet):
|
|
64
|
+
* the three caps are per-path bounds of the SAME injection surface — one
|
|
65
|
+
* dial means one mental model ("max bytes of knowledge per agent") for
|
|
66
|
+
* both the host session and worker prompts; a per-path split would need
|
|
67
|
+
* three env names to move one budget, and the worker path (the context-diet
|
|
68
|
+
* target) spans TWO caps that must move together anyway.
|
|
69
|
+
*
|
|
70
|
+
* Semantics (mirrors PI_CREW_HANDOFF_BUDGET_TOKENS):
|
|
71
|
+
* - unset / non-integer / empty → per-path defaults above — output is
|
|
72
|
+
* BYTE-IDENTICAL to pre-R1 behavior (HARD RULE, pinned by unit test);
|
|
73
|
+
* - N ≥ 1 → every cap becomes N (raising above a default is allowed);
|
|
74
|
+
* - N ≤ 0 → off-switch: NO knowledge injected (readKnowledge → "").
|
|
75
|
+
*/
|
|
76
|
+
const KNOWLEDGE_MAX_BYTES_ENV_RE = /^[+-]?\d+$/;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* R1.1 (adaptive context diet, 2026-11-05): bound on the session-log
|
|
80
|
+
* section INDEX rendered into every worker prompt. The index used to list
|
|
81
|
+
* EVERY session-log header — a recovery safety net that grew linearly with
|
|
82
|
+
* knowledge.md history (measured 2026-11-05 on this repo: 130 entries ≈
|
|
83
|
+
* 10.4K chars = 82% of the whole injected block, dwarfing the ~2K of matched
|
|
84
|
+
* content the knob actually caps). The index is now bounded to the most
|
|
85
|
+
* recent MAX_INDEX_ENTRIES entries (file order — the session-log is
|
|
86
|
+
* chronological, newest appended last) plus a one-line counter pointing
|
|
87
|
+
* `read` at the full file for the omitted earlier ones. Unlike the byte caps
|
|
88
|
+
* this bound applies BY DEFAULT (knob unset) — the unbounded index was the
|
|
89
|
+
* real growth bug. Unset output changes ONLY inside this index comment, and
|
|
90
|
+
* only when a project has more than MAX_INDEX_ENTRIES session-log sections;
|
|
91
|
+
* injected CONTENT (conventions + matched session-log bodies + their
|
|
92
|
+
* truncation notices) is byte-identical when unset.
|
|
93
|
+
*/
|
|
94
|
+
const MAX_INDEX_ENTRIES = 15;
|
|
95
|
+
|
|
96
|
+
/** Resolve the R1 knob: undefined = unset/invalid (keep per-path defaults),
|
|
97
|
+
* otherwise the parsed integer (≤0 = off-switch). Never throws. */
|
|
98
|
+
function resolveKnowledgeMaxBytes(): number | undefined {
|
|
99
|
+
const raw = getCrewEnv("PI_CREW_KNOWLEDGE_MAX_BYTES");
|
|
100
|
+
if (raw === undefined) return undefined;
|
|
101
|
+
const trimmed = raw.trim();
|
|
102
|
+
if (!KNOWLEDGE_MAX_BYTES_ENV_RE.test(trimmed)) return undefined;
|
|
103
|
+
const parsed = Number.parseInt(trimmed, 10);
|
|
104
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Effective cap for one path: knob override (guaranteed ≥1 — the ≤0
|
|
108
|
+
* off-switch returns early in readKnowledge), else the path default. */
|
|
109
|
+
function knowledgeCap(defaultBytes: number, knob: number | undefined): number {
|
|
110
|
+
return knob === undefined ? defaultBytes : knob;
|
|
111
|
+
}
|
|
112
|
+
|
|
55
113
|
/**
|
|
56
114
|
* Relevance context for section-aware injection. When omitted (or when goal/
|
|
57
115
|
* taskText are both absent), injection falls back to conventions-only — no
|
|
@@ -268,6 +326,10 @@ function selectSessionLog(query: string, sessionLog: KnowledgeSection[], budgetB
|
|
|
268
326
|
/** Read knowledge content. "" if absent/empty. */
|
|
269
327
|
export function readKnowledge(cwd: string, query?: KnowledgeQuery): string {
|
|
270
328
|
try {
|
|
329
|
+
// R1 knob: resolved once per call; ≤0 short-circuits BOTH paths to ""
|
|
330
|
+
// (off-switch) BEFORE any file I/O or cache interaction.
|
|
331
|
+
const maxBytes = resolveKnowledgeMaxBytes();
|
|
332
|
+
if (maxBytes !== undefined && maxBytes <= 0) return "";
|
|
271
333
|
const p = knowledgePath(cwd);
|
|
272
334
|
const stat = tryStat(p);
|
|
273
335
|
if (!stat) {
|
|
@@ -286,7 +348,15 @@ export function readKnowledge(cwd: string, query?: KnowledgeQuery): string {
|
|
|
286
348
|
// start (main session + every worker), re-reading the file each time.
|
|
287
349
|
// For a run with N workers this is N redundant readFileSync of the same
|
|
288
350
|
// file. Cache by (mtimeMs, size) and only re-read when the file changes.
|
|
289
|
-
|
|
351
|
+
// R1: the no-query CONTENT depends on the effective head cap, so the cap
|
|
352
|
+
// rides the content-cache key (a same-process env change between reads
|
|
353
|
+
// must not serve a stale truncation). Parsed sections stay cap-independent.
|
|
354
|
+
const headCap = knowledgeCap(MAX_KNOWLEDGE_HEAD_BYTES, maxBytes);
|
|
355
|
+
const cacheKey = `${stat.mtimeMs}:${stat.size}:${headCap}`;
|
|
356
|
+
// NIT-3 (review follow-up): parsed sections are cap-INDEPENDENT — key
|
|
357
|
+
// them by file identity only, so a mid-process knob change re-truncates
|
|
358
|
+
// (knowledgeCache above) but never re-parses the file.
|
|
359
|
+
const sectionKey = `${stat.mtimeMs}:${stat.size}`;
|
|
290
360
|
|
|
291
361
|
// B2: when no query context is provided (main-session hook, legacy
|
|
292
362
|
// callers, tests), keep the simple head-only path. This preserves the
|
|
@@ -298,8 +368,8 @@ export function readKnowledge(cwd: string, query?: KnowledgeQuery): string {
|
|
|
298
368
|
// markers are added, so system-generated HTML comments (truncation
|
|
299
369
|
// notices) survive — sanitizeAgentSystemPrompt strips ALL HTML comments.
|
|
300
370
|
let content = sanitizeAgentSystemPrompt(fs.readFileSync(p, "utf8").trim(), "project");
|
|
301
|
-
if (content.length >
|
|
302
|
-
content = `${content.slice(0,
|
|
371
|
+
if (content.length > headCap) {
|
|
372
|
+
content = `${content.slice(0, headCap)}\n\n<!-- knowledge.md truncated at ${headCap} bytes (head shown). Full file: ${p} — use the \`read\` tool if you need sections beyond the head. -->`;
|
|
303
373
|
}
|
|
304
374
|
knowledgeCache.set(p, { key: cacheKey, content });
|
|
305
375
|
enforceKnowledgeCacheCap(knowledgeCache);
|
|
@@ -312,7 +382,7 @@ export function readKnowledge(cwd: string, query?: KnowledgeQuery): string {
|
|
|
312
382
|
conventions: KnowledgeSection[];
|
|
313
383
|
sessionLog: KnowledgeSection[];
|
|
314
384
|
};
|
|
315
|
-
if (cachedSections && cachedSections.key ===
|
|
385
|
+
if (cachedSections && cachedSections.key === sectionKey) {
|
|
316
386
|
parsed = {
|
|
317
387
|
conventions: cachedSections.conventions,
|
|
318
388
|
sessionLog: cachedSections.sessionLog,
|
|
@@ -323,7 +393,7 @@ export function readKnowledge(cwd: string, query?: KnowledgeQuery): string {
|
|
|
323
393
|
const content = sanitizeAgentSystemPrompt(fs.readFileSync(p, "utf8").trim(), "project");
|
|
324
394
|
parsed = parseKnowledgeSections(content);
|
|
325
395
|
sectionCache.set(p, {
|
|
326
|
-
key:
|
|
396
|
+
key: sectionKey,
|
|
327
397
|
conventions: parsed.conventions,
|
|
328
398
|
sessionLog: parsed.sessionLog,
|
|
329
399
|
});
|
|
@@ -331,31 +401,60 @@ export function readKnowledge(cwd: string, query?: KnowledgeQuery): string {
|
|
|
331
401
|
}
|
|
332
402
|
|
|
333
403
|
const queryText = [query.goal, query.taskText].filter(Boolean).join(" \n ");
|
|
334
|
-
const
|
|
404
|
+
const sessionLogCap = knowledgeCap(MAX_SESSION_LOG_BYTES, maxBytes);
|
|
405
|
+
const matchedSessionLog = selectSessionLog(queryText, parsed.sessionLog, sessionLogCap);
|
|
335
406
|
|
|
336
407
|
const parts: string[] = [];
|
|
337
408
|
// Conventions: always injected, but capped at MAX_CONVENTIONS_BYTES
|
|
338
409
|
// (SEC-2 LOW defense-in-depth — the no-query path already bounds raw
|
|
339
410
|
// content at MAX_KNOWLEDGE_HEAD_BYTES; this mirrors that bound on the
|
|
340
411
|
// worker/query path where convention sections were previously uncapped).
|
|
412
|
+
// R1: the effective cap is the R1 knob when set (byte-identical defaults
|
|
413
|
+
// when unset).
|
|
414
|
+
const conventionsCap = knowledgeCap(MAX_CONVENTIONS_BYTES, maxBytes);
|
|
341
415
|
const conventionsBody = parsed.conventions.map((s) => s.body).join("\n");
|
|
342
|
-
if (conventionsBody.length >
|
|
416
|
+
if (conventionsBody.length > conventionsCap) {
|
|
343
417
|
parts.push(
|
|
344
|
-
`${conventionsBody.slice(0,
|
|
418
|
+
`${conventionsBody.slice(0, conventionsCap)}\n\n<!-- ...[truncated] conventions section capped at ${conventionsCap} bytes. Full file: ${p} — use \`read\`. -->`,
|
|
345
419
|
);
|
|
346
420
|
} else if (conventionsBody) {
|
|
347
421
|
parts.push(conventionsBody);
|
|
348
422
|
}
|
|
349
423
|
// Matched session-log (drop-whole, budget-capped).
|
|
350
424
|
for (const sec of matchedSessionLog) parts.push(sec.body);
|
|
351
|
-
// Always: section-index of
|
|
425
|
+
// Always: section-index of session-log headers (recovery safety net).
|
|
426
|
+
// R1.1: bounded to the last MAX_INDEX_ENTRIES headers plus a counter —
|
|
427
|
+
// see MAX_INDEX_ENTRIES doc for the growth-bug rationale. The counter
|
|
428
|
+
// line sits ABOVE the kept entries because the omitted ones are the
|
|
429
|
+
// earlier sections (keep-tail policy).
|
|
352
430
|
if (parsed.sessionLog.length > 0) {
|
|
353
|
-
const
|
|
431
|
+
const omitted = parsed.sessionLog.length - MAX_INDEX_ENTRIES;
|
|
432
|
+
const indexLines = (omitted > 0 ? parsed.sessionLog.slice(-MAX_INDEX_ENTRIES) : parsed.sessionLog).map(
|
|
433
|
+
(s) => ` - ${s.header}`,
|
|
434
|
+
);
|
|
435
|
+
if (omitted > 0) {
|
|
436
|
+
indexLines.unshift(
|
|
437
|
+
`… ${omitted} earlier session-log sections omitted (index capped at ${MAX_INDEX_ENTRIES} most recent) — use \`read\` on the full file to browse them.`,
|
|
438
|
+
);
|
|
439
|
+
}
|
|
354
440
|
parts.push(
|
|
355
441
|
`<!-- Session-log sections in knowledge.md (not injected unless matched above — use \`read\` for detail):\n${indexLines.join("\n")}\nFull file: ${p} -->`,
|
|
356
442
|
);
|
|
357
443
|
}
|
|
358
|
-
|
|
444
|
+
const joined = parts.join("\n").trim();
|
|
445
|
+
// R1.1 whole-block backstop (knob set ONLY): the knobbled query-path
|
|
446
|
+
// budgets (conventions N + session-log N) plus the no-query head budget
|
|
447
|
+
// (N) justify up to 3N of knowledge — but truncation notices and the
|
|
448
|
+
// section-index ride on top. When the FINAL joined block would exceed
|
|
449
|
+
// 3N bytes, cut the tail and point `read` at the file so the knob stays
|
|
450
|
+
// a true upper bound ("max bytes of knowledge per agent" × 3 paths).
|
|
451
|
+
// Unset = no whole-block cap (pre-R1.1 behavior; the per-path defaults
|
|
452
|
+
// already bound each part).
|
|
453
|
+
if (maxBytes !== undefined && joined.length > maxBytes * 3) {
|
|
454
|
+
const wholeBlockCap = maxBytes * 3;
|
|
455
|
+
return `${joined.slice(0, wholeBlockCap)}\n\n<!-- knowledge block truncated at ${wholeBlockCap} bytes (whole-block cap). Full file: ${p} — use the \`read\` tool. -->`;
|
|
456
|
+
}
|
|
457
|
+
return joined;
|
|
359
458
|
} catch {
|
|
360
459
|
return "";
|
|
361
460
|
}
|
|
@@ -44,7 +44,9 @@ import { createSessionSnapshot } from "../../runtime/session-snapshot.ts";
|
|
|
44
44
|
import { applyCrewSettingsTiersToConfig, loadCrewSettingsTiers, scheduledJobsHiddenCountOf } from "../../runtime/settings-store.ts";
|
|
45
45
|
import type { ReconcileResult } from "../../runtime/stale-reconciler.ts";
|
|
46
46
|
import { flushReapEscalationsSync } from "../../runtime/stale-reconciler.ts";
|
|
47
|
+
import { prunePersistedSubagentRecords, resolveSubagentRecordTtlDays } from "../../runtime/subagent-manager.ts";
|
|
47
48
|
import { ensureCoordinationPublicVerifyKey, getMailboxVerifyKeyPublic } from "../../state/coordination/mailbox.ts";
|
|
49
|
+
import { sweepOrphanHealthSnapshots } from "../../state/stores/health-store.ts";
|
|
48
50
|
import { loadRunManifestById, saveRunManifest } from "../../state/stores/state-store.ts";
|
|
49
51
|
import type { TeamRunManifest } from "../../state/types.ts";
|
|
50
52
|
import { installInlinePanel } from "../../ui/inline-panel/index.ts";
|
|
@@ -52,6 +54,7 @@ import { clearSessionSwitchInFlight, markSessionSwitchInFlight } from "../../ui/
|
|
|
52
54
|
import { registerPiCrewPowerbarSegments, resetPowerbarDedupState, updatePiCrewPowerbar } from "../../ui/powerbar-publisher.ts";
|
|
53
55
|
import { updateCrewWidget } from "../../ui/widget/index.ts";
|
|
54
56
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
57
|
+
import { projectCrewRoot, userCrewRoot } from "../../utils/paths.ts";
|
|
55
58
|
import { extractBrokerSessionId } from "../../utils/session-utils.ts";
|
|
56
59
|
import { getBrokerSocketPath } from "../../utils/socket-path.ts";
|
|
57
60
|
import { startAsyncRunNotifier, stopAsyncRunNotifier } from "../async-notifier.ts";
|
|
@@ -508,6 +511,14 @@ async function runDeferredSessionCleanup(
|
|
|
508
511
|
logInternalError("register.sessionStart.reconcileStale", error);
|
|
509
512
|
}
|
|
510
513
|
|
|
514
|
+
// FIX 2 phase-2: wait for the observability init (kicked off synchronously
|
|
515
|
+
// in session_start) so the prunes below release metric labels on the LIVE
|
|
516
|
+
// registry — releasing on a fresh registry would be an empty no-op lie.
|
|
517
|
+
// Best-effort: an init failure (already logged by configureObservability)
|
|
518
|
+
// leaves metricRegistry undefined → prune simply skips label release.
|
|
519
|
+
await ctx.observabilityState.initPromise?.catch(() => {});
|
|
520
|
+
if (ctx.cleanedUp || ctx.sessionGeneration !== ownerGeneration) return; // session switched while we waited
|
|
521
|
+
|
|
511
522
|
// Auto-prune finished project-level run directories
|
|
512
523
|
try {
|
|
513
524
|
// DP-01: keep + age-floor are env-driven; the age floor means a run
|
|
@@ -515,6 +526,7 @@ async function runDeferredSessionCleanup(
|
|
|
515
526
|
const { removed } = pruneFinishedRuns(extensionCtx.cwd, resolveAutoPruneKeep(), {
|
|
516
527
|
ageFloorMs: resolveAutoPruneAgeFloorMs(),
|
|
517
528
|
intent: "session-start-auto",
|
|
529
|
+
metricRegistry: ctx.observabilityState.metricRegistry,
|
|
518
530
|
});
|
|
519
531
|
if (removed.length > 0) {
|
|
520
532
|
ctx.notifyOperator({
|
|
@@ -534,6 +546,7 @@ async function runDeferredSessionCleanup(
|
|
|
534
546
|
const { removed } = pruneUserLevelRuns(resolveAutoPruneKeep(), {
|
|
535
547
|
ageFloorMs: resolveAutoPruneAgeFloorMs(),
|
|
536
548
|
intent: "session-start-auto",
|
|
549
|
+
metricRegistry: ctx.observabilityState.metricRegistry,
|
|
537
550
|
});
|
|
538
551
|
if (removed.length > 0) {
|
|
539
552
|
ctx.notifyOperator({
|
|
@@ -547,6 +560,47 @@ async function runDeferredSessionCleanup(
|
|
|
547
560
|
} catch (error) {
|
|
548
561
|
logInternalError("register.sessionStart.autoPruneUser", error);
|
|
549
562
|
}
|
|
563
|
+
|
|
564
|
+
// FIX 1: sweep orphaned health snapshots — files under
|
|
565
|
+
// <crewRoot>/state/health/ whose run dir is already gone (left behind by
|
|
566
|
+
// pre-fix prunes; 2,322 files / 9.3 MB on the dev box at discovery).
|
|
567
|
+
// Conservative (exists-but-unreadable run dir → KEEP), bounded, never throws.
|
|
568
|
+
try {
|
|
569
|
+
for (const crewRoot of new Set([projectCrewRoot(extensionCtx.cwd), userCrewRoot()])) {
|
|
570
|
+
const { removed } = sweepOrphanHealthSnapshots(crewRoot);
|
|
571
|
+
if (removed.length > 0) {
|
|
572
|
+
ctx.notifyOperator({
|
|
573
|
+
id: `orphan_health_sweep`,
|
|
574
|
+
severity: "info",
|
|
575
|
+
source: "run-maintenance",
|
|
576
|
+
title: `Removed ${removed.length} orphaned health snapshot(s)`,
|
|
577
|
+
body: `Health snapshots whose runs were already pruned (under ${crewRoot}): ${removed
|
|
578
|
+
.slice(0, 10)
|
|
579
|
+
.join(", ")}${removed.length > 10 ? ", …" : ""}`,
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
} catch (error) {
|
|
584
|
+
logInternalError("register.sessionStart.sweepOrphanHealth", error);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// FIX 3: TTL-prune old TERMINAL subagent records (running/blocked/queued
|
|
588
|
+
// never touched — blocked drives blockedSignalFromSubagentDir; keep-50
|
|
589
|
+
// shield preserves recent history).
|
|
590
|
+
try {
|
|
591
|
+
const { removed } = prunePersistedSubagentRecords(extensionCtx.cwd);
|
|
592
|
+
if (removed.length > 0) {
|
|
593
|
+
ctx.notifyOperator({
|
|
594
|
+
id: `subagent_record_ttl_prune`,
|
|
595
|
+
severity: "info",
|
|
596
|
+
source: "run-maintenance",
|
|
597
|
+
title: `Pruned ${removed.length} old subagent record(s)`,
|
|
598
|
+
body: `Terminal subagent records past the ${resolveSubagentRecordTtlDays()}-day TTL (PI_CREW_SUBAGENT_RECORD_TTL_DAYS=0 disables).`,
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
} catch (error) {
|
|
602
|
+
logInternalError("register.sessionStart.pruneSubagentRecords", error);
|
|
603
|
+
}
|
|
550
604
|
}
|
|
551
605
|
|
|
552
606
|
/** Manifest keys the scheduler stamps for traceability. They are NOT declared
|
|
@@ -2,7 +2,9 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { DEFAULT_PATHS } from "../config/defaults.ts";
|
|
4
4
|
import { getCrewEnv } from "../config/env-vars.ts";
|
|
5
|
+
import type { MetricRegistry } from "../observability/metric-registry.ts";
|
|
5
6
|
import { createCancellationToken } from "../runtime/process/cancellation-token.ts";
|
|
7
|
+
import { removeHealthSnapshot } from "../state/stores/health-store.ts";
|
|
6
8
|
import type { TeamRunManifest } from "../state/types.ts";
|
|
7
9
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
8
10
|
import { projectCrewRoot, userCrewRoot } from "../utils/paths.ts";
|
|
@@ -29,6 +31,12 @@ export interface PruneRunsOptions {
|
|
|
29
31
|
* 0 disables the floor. Defaults to the PI_CREW_AUTO_PRUNE_AGE_FLOOR_HOURS
|
|
30
32
|
* value (24h) for auto-prune callers; manual prune passes 0 explicitly. */
|
|
31
33
|
ageFloorMs?: number;
|
|
34
|
+
/** FIX 2 phase-2: the LIVE MetricRegistry to release pruned runs' label
|
|
35
|
+
* series from. Callers pass the shared session registry (TeamContext /
|
|
36
|
+
* observabilityState.metricRegistry) — a second, fresh registry would hold
|
|
37
|
+
* no labels and its release would be a silent no-op lie. Optional: callers
|
|
38
|
+
* without a registry handle (tests) simply skip label release. */
|
|
39
|
+
metricRegistry?: MetricRegistry;
|
|
32
40
|
}
|
|
33
41
|
|
|
34
42
|
/**
|
|
@@ -270,6 +278,15 @@ export function pruneFinishedRuns(cwd: string, keep: number, options: PruneRunsO
|
|
|
270
278
|
}
|
|
271
279
|
fs.rmSync(run.stateRoot, { recursive: true, force: true });
|
|
272
280
|
fs.rmSync(run.artifactsRoot, { recursive: true, force: true });
|
|
281
|
+
// FIX 1: the run's health snapshot lives OUTSIDE stateRoot/artifactsRoot
|
|
282
|
+
// (<crewRoot>/state/health/<runId>.json) and was left orphaned forever.
|
|
283
|
+
// crewRoot derivation mirrors isSafeToPrune above.
|
|
284
|
+
const healthCrewRoot = run.stateRoot.startsWith(userCrewRoot() + path.sep) ? userCrewRoot() : projectCrewRoot(cwd);
|
|
285
|
+
removeHealthSnapshot(healthCrewRoot, run.runId);
|
|
286
|
+
// FIX 2 phase-2: a pruned run must stop pinning metric label series
|
|
287
|
+
// (cardinality) in the live registry. Missing registry = skip (see
|
|
288
|
+
// PruneRunsOptions.metricRegistry).
|
|
289
|
+
options.metricRegistry?.releaseLabels((l) => l.runId === run.runId);
|
|
273
290
|
// DP-01: preserve the reason of a pruned failed/blocked run in a durable
|
|
274
291
|
// index (the run dir that held it is gone now).
|
|
275
292
|
if (run.status === "failed" || run.status === "blocked") appendPrunedFailureIndex(cwd, run);
|
|
@@ -348,6 +365,9 @@ export function pruneUserLevelRuns(keep: number, options: PruneRunsOptions = {})
|
|
|
348
365
|
recursive: true,
|
|
349
366
|
force: true,
|
|
350
367
|
});
|
|
368
|
+
// FIX 1: ghost-run removal also orphans the health snapshot — remove it
|
|
369
|
+
// in the same breath (crewRoot here is always the user root).
|
|
370
|
+
removeHealthSnapshot(crewRoot, manifest.runId);
|
|
351
371
|
ghostRemoved.push(manifest.runId);
|
|
352
372
|
continue;
|
|
353
373
|
}
|
|
@@ -390,6 +410,11 @@ export function pruneUserLevelRuns(keep: number, options: PruneRunsOptions = {})
|
|
|
390
410
|
}
|
|
391
411
|
fs.rmSync(run.stateRoot, { recursive: true, force: true });
|
|
392
412
|
fs.rmSync(run.artifactsRoot, { recursive: true, force: true });
|
|
413
|
+
// FIX 1: user-level runs snapshot to <userCrewRoot>/state/health/ — remove
|
|
414
|
+
// it alongside the state/artifacts dirs (crewRoot is userCrewRoot() here).
|
|
415
|
+
removeHealthSnapshot(crewRoot, run.runId);
|
|
416
|
+
// FIX 2 phase-2: same label release as the project-level prune.
|
|
417
|
+
options.metricRegistry?.releaseLabels((l) => l.runId === run.runId);
|
|
393
418
|
// DP-01: preserve a pruned failed/blocked run's reason (the user-level
|
|
394
419
|
// index lives under the user crew root).
|
|
395
420
|
if (run.status === "failed" || run.status === "blocked") {
|
|
@@ -7,6 +7,7 @@ import { killProcessPid } from "../../runtime/child-pi/child-pi.ts";
|
|
|
7
7
|
import { terminateLiveAgentsForRun } from "../../runtime/live-session/live-agent-manager.ts";
|
|
8
8
|
import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
|
|
9
9
|
import { appendEvent, appendEventAsync } from "../../state/event-log/event-log.ts";
|
|
10
|
+
import { removeHealthSnapshot } from "../../state/stores/health-store.ts";
|
|
10
11
|
import { loadRunManifestById } from "../../state/stores/state-store.ts";
|
|
11
12
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
12
13
|
import { projectCrewRoot, userCrewRoot, userPiRoot } from "../../utils/paths.ts";
|
|
@@ -220,6 +221,9 @@ export async function handlePrune(params: TeamToolParamsValue, ctx: TeamContext)
|
|
|
220
221
|
intent,
|
|
221
222
|
signal: ctx.signal,
|
|
222
223
|
dryRun,
|
|
224
|
+
// FIX 2 phase-2: release pruned runs' metric labels on the LIVE session
|
|
225
|
+
// registry (undefined in synthesized contexts → prune skips the release).
|
|
226
|
+
metricRegistry: ctx.metricRegistry,
|
|
223
227
|
});
|
|
224
228
|
// Fire hook once with all removed run IDs for batch visibility
|
|
225
229
|
if (pruned.removed.length > 0) {
|
|
@@ -352,6 +356,10 @@ export async function handleForget(params: TeamToolParamsValue, ctx: TeamContext
|
|
|
352
356
|
const resolvedArtifactsRoot = resolveRealContainedPath(crewRoot, loaded.manifest.artifactsRoot);
|
|
353
357
|
fs.rmSync(resolvedStateRoot, { recursive: true, force: true });
|
|
354
358
|
fs.rmSync(resolvedArtifactsRoot, { recursive: true, force: true });
|
|
359
|
+
// FIX 1: forget removes the run dir, so its health snapshot (which lives at
|
|
360
|
+
// <crewRoot>/state/health/<runId>.json, outside stateRoot) must go too or it
|
|
361
|
+
// orphans forever (2,322 orphans / 9.3 MB accumulated before this call).
|
|
362
|
+
removeHealthSnapshot(crewRoot, loaded.manifest.runId);
|
|
355
363
|
// FORGET-RESURRECT (observed 2026-08-04): the SIGTERM above is fire-and-forget
|
|
356
364
|
// (killProcessPid returns void; no await on process-group exit). A still-alive
|
|
357
365
|
// background runner/worker can land one final heartbeat write (15s interval)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
2
|
-
import { Counter, Gauge, Histogram, type Metric, type MetricSnapshot } from "./metrics-primitives.ts";
|
|
2
|
+
import { Counter, Gauge, Histogram, type Metric, type MetricLabels, type MetricSnapshot } from "./metrics-primitives.ts";
|
|
3
3
|
|
|
4
4
|
const METRIC_NAME_PATTERN = /^crew\.[a-z]+\.[a-z][a-z_]*$/;
|
|
5
5
|
|
|
@@ -90,6 +90,19 @@ export class MetricRegistry {
|
|
|
90
90
|
return [...this.metrics.values()].map((metric) => metric.snapshot());
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* LABEL-GC: release stored label values matching `predicate` across every
|
|
95
|
+
* registered metric; returns the total number of series dropped. Dispatches
|
|
96
|
+
* polymorphically to {@link Metric.releaseLabelValues}, so Histograms (not
|
|
97
|
+
* run-scoped) are untouched and no private Map is reached into from here.
|
|
98
|
+
* A release is not an eviction — `cardinalityEvictions` is unaffected.
|
|
99
|
+
*/
|
|
100
|
+
releaseLabels(predicate: (labels: MetricLabels) => boolean): number {
|
|
101
|
+
let released = 0;
|
|
102
|
+
for (const metric of this.metrics.values()) released += metric.releaseLabelValues(predicate);
|
|
103
|
+
return released;
|
|
104
|
+
}
|
|
105
|
+
|
|
93
106
|
dispose(): void {
|
|
94
107
|
this.metrics.clear();
|
|
95
108
|
}
|
|
@@ -108,6 +108,18 @@ export abstract class Metric {
|
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
abstract snapshot(): MetricSnapshot;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* LABEL-GC: drop every stored label value whose labels match `predicate`;
|
|
114
|
+
* returns the number of series dropped. Default is a no-op — only the
|
|
115
|
+
* run-scoped value metrics (Counter/Gauge) override it. A deliberate
|
|
116
|
+
* release is NOT a cardinality eviction ({@link cardinalityEvictions} is
|
|
117
|
+
* reserved for MAX_LABEL_COMBINATIONS overflow) — the series is dropped
|
|
118
|
+
* because it can no longer occur, not because the cap was hit.
|
|
119
|
+
*/
|
|
120
|
+
releaseLabelValues(_predicate: (labels: MetricLabels) => boolean): number {
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
111
123
|
}
|
|
112
124
|
|
|
113
125
|
export class Counter extends Metric {
|
|
@@ -134,6 +146,22 @@ export class Counter extends Metric {
|
|
|
134
146
|
return this.values.get(labelKey(labels))?.value ?? 0;
|
|
135
147
|
}
|
|
136
148
|
|
|
149
|
+
/**
|
|
150
|
+
* LABEL-GC: delete stored series whose labels match `predicate`.
|
|
151
|
+
* `cardinalityEvictions` is deliberately NOT touched — a run-scoped series
|
|
152
|
+
* is released because its run stopped being active, not because the
|
|
153
|
+
* MAX_LABEL_COMBINATIONS FIFO cap evicted it.
|
|
154
|
+
*/
|
|
155
|
+
releaseLabelValues(predicate: (labels: MetricLabels) => boolean): number {
|
|
156
|
+
let dropped = 0;
|
|
157
|
+
for (const [key, entry] of this.values) {
|
|
158
|
+
if (!predicate(entry.labels)) continue;
|
|
159
|
+
this.values.delete(key);
|
|
160
|
+
dropped += 1;
|
|
161
|
+
}
|
|
162
|
+
return dropped;
|
|
163
|
+
}
|
|
164
|
+
|
|
137
165
|
snapshot(): MetricSnapshot {
|
|
138
166
|
return {
|
|
139
167
|
type: "counter",
|
|
@@ -168,6 +196,18 @@ export class Gauge extends Metric {
|
|
|
168
196
|
return this.values.get(labelKey(labels))?.value ?? 0;
|
|
169
197
|
}
|
|
170
198
|
|
|
199
|
+
/** LABEL-GC: see {@link Counter.releaseLabelValues} — Gauge shares the same
|
|
200
|
+
* semantics (series released by predicate, eviction counter untouched). */
|
|
201
|
+
releaseLabelValues(predicate: (labels: MetricLabels) => boolean): number {
|
|
202
|
+
let dropped = 0;
|
|
203
|
+
for (const [key, entry] of this.values) {
|
|
204
|
+
if (!predicate(entry.labels)) continue;
|
|
205
|
+
this.values.delete(key);
|
|
206
|
+
dropped += 1;
|
|
207
|
+
}
|
|
208
|
+
return dropped;
|
|
209
|
+
}
|
|
210
|
+
|
|
171
211
|
snapshot(): MetricSnapshot {
|
|
172
212
|
return {
|
|
173
213
|
type: "gauge",
|