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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.11.57",
3
+ "version": "0.11.59",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -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 (est tokens, chars/4) for the dynamic.dependencyContext
83
- // prompt layer. Above the ceiling the budget is treated as the OFF-switch
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 (est tokens,
417
- * chars/4). Deliberately NOT parsePositiveInteger: out-of-range values are
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" },
@@ -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: "est-token (chars/4) budget for the dynamic.dependencyContext prompt layer; beats runtime.handoffBudgetTokens, default 1800; resolved ≤0 or >1_000_000 disables the trim (task-output-context.ts renderDependencyOutputContext)",
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)",
@@ -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", "bash", "ask"],
66
+ tools: ["read", "grep", "find", "ls", "glob", "ask"],
66
67
  excludeTools: ["edit", "write"],
67
68
  },
68
69
 
@@ -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
- * Est-token budget for the dynamic.dependencyContext prompt layer
183
+ * Effective-token budget for the dynamic.dependencyContext prompt layer
184
184
  * (handoff budget). When the rendered dependency output exceeds this many
185
- * estimated tokens (chars/4 — the same heuristic the prompt breakdown
186
- * uses), dependencies are trimmed IN DECLARATION ORDER: earlier deps keep
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
- const cacheKey = `${stat.mtimeMs}:${stat.size}`;
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 > MAX_KNOWLEDGE_HEAD_BYTES) {
302
- content = `${content.slice(0, MAX_KNOWLEDGE_HEAD_BYTES)}\n\n<!-- knowledge.md truncated at ${MAX_KNOWLEDGE_HEAD_BYTES} bytes (head shown). Full file: ${p} — use the \`read\` tool if you need sections beyond the head. -->`;
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 === cacheKey) {
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: cacheKey,
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 matchedSessionLog = selectSessionLog(queryText, parsed.sessionLog, MAX_SESSION_LOG_BYTES);
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 > MAX_CONVENTIONS_BYTES) {
416
+ if (conventionsBody.length > conventionsCap) {
343
417
  parts.push(
344
- `${conventionsBody.slice(0, MAX_CONVENTIONS_BYTES)}\n\n<!-- ...[truncated] conventions section capped at ${MAX_CONVENTIONS_BYTES} bytes. Full file: ${p} — use \`read\`. -->`,
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 ALL session-log headers (recovery safety net).
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 indexLines = parsed.sessionLog.map((s) => ` - ${s.header}`);
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
- return parts.join("\n").trim();
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",