pi-crew 0.9.55 → 0.9.56

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.9.55",
3
+ "version": "0.9.56",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -33,7 +33,6 @@
33
33
  "*.mjs",
34
34
  "src/",
35
35
  "dist/index.mjs",
36
- "dist/build-meta.json",
37
36
  "agents/",
38
37
  "teams/",
39
38
  "workflows/",
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env -S npx tsx
2
+ /**
3
+ * Tier 7 smoke (real-test-pi-crew): drive a REAL fast-fix team run via
4
+ * handleRun() directly — bypasses the `team` tool (which the agent context
5
+ * can't emit action='run' for). Exercises the live orchestration path that the
6
+ * perf-batch changes touched: team-runner dispatch (Promise.all hooks,
7
+ * saveCrewAgentsCoalesced, tasks.find→Map), event-log append (fsync best-effort
8
+ * + F3a + rotation counter), crew-agent-records (path memo), child-pi
9
+ * (BoundedTail), manifest-cache/state-store (cache-hit + resolveRunStateRoot memo).
10
+ *
11
+ * Usage:
12
+ * npx tsx scripts/smoke-team-perf.ts # real model run
13
+ * PI_TEAMS_MOCK_CHILD_PI=adaptive-plan npx tsx scripts/smoke-team-perf.ts # fast/free mock
14
+ */
15
+ import { handleRun } from "../src/extension/team-tool/run.ts";
16
+
17
+ const goal =
18
+ "Smoke test: write the text 'pi-crew perf-batch smoke OK' to /tmp/pi-crew-perf-smoke.txt, " +
19
+ "confirm the file exists with that exact content, and report the file path. " +
20
+ "Keep it under 90 seconds; do NOT run any test or build commands.";
21
+
22
+ const ctx = { cwd: process.cwd() };
23
+ console.log("[smoke-team-perf] Launching fast-fix team run (real orchestration path)...");
24
+ console.log("[smoke-team-perf] cwd:", ctx.cwd);
25
+ const t0 = Date.now();
26
+ let result;
27
+ try {
28
+ result = await handleRun({ action: "run", team: "fast-fix", workflow: "fast-fix", goal }, ctx);
29
+ } catch (error) {
30
+ const ms = Date.now() - t0;
31
+ console.error(`[smoke-team-perf] handleRun THREW after ${ms}ms:`, error);
32
+ process.exit(1);
33
+ }
34
+ const ms = Date.now() - t0;
35
+ console.log(`\n[smoke-team-perf] Completed in ${(ms / 1000).toFixed(1)}s. isError=${result.isError}`);
36
+ console.log("=== RESULT details ===");
37
+ console.log(JSON.stringify(result.details, null, 2));
38
+ const text = result.content?.[0];
39
+ if (text && "text" in text) {
40
+ console.log("\n=== SUMMARY ===");
41
+ console.log(text.text);
42
+ }
43
+ process.exit(result.isError ? 1 : 0);
@@ -33,6 +33,7 @@ import { loadRunManifestById } from "../../state/state-store.ts";
33
33
  import { formatCompactToolProgress } from "../../ui/tool-progress-formatter.ts";
34
34
  import { logInternalError } from "../../utils/internal-error.ts";
35
35
  import { withSessionId } from "../team-tool/context.ts";
36
+ import { formatTeamToolParamError } from "../team-tool/param-error.ts";
36
37
  import { toolResult } from "../tool-result.ts";
37
38
 
38
39
  const TEAM_TOOL_PROGRESS_TICK_MS = 1000;
@@ -101,7 +102,7 @@ export function registerTeamTool(pi: ExtensionAPI, deps: RegisterTeamToolDeps):
101
102
  try {
102
103
  // Defense-in-depth: validate params at runtime even though Pi framework already does
103
104
  if (!Value.Check(TeamToolParams, params)) {
104
- return toolResult("Invalid team tool parameters", { action: "list", status: "error" }, true);
105
+ return toolResult(formatTeamToolParamError(TeamToolParams, params), { action: "list", status: "error" }, true);
105
106
  }
106
107
  const resolved = params as TeamToolParamsValue;
107
108
  const cwdOverride = resolveCwdOverride(ctx.cwd, resolved.cwd);
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Friendly team-tool parameter validation error formatting.
3
+ *
4
+ * The team tool validates every call with `Value.Check(TeamToolParams, ...)`.
5
+ * On failure it used to return the opaque `"Invalid team tool parameters"` —
6
+ * which gave calling agents (LLMs) no clue WHAT was wrong, so they retried the
7
+ * same malformed shape indefinitely (observed in the wild: agents passing
8
+ * `goal` as an array/object, or `action` with wrong casing, then looping).
9
+ *
10
+ * `formatTeamToolParamError` produces an actionable message:
11
+ * 1. Targeted heuristics for the COMMON mistakes (action casing; goal/team/
12
+ * runId passed as a non-string) — pinpoint the exact field, because the
13
+ * TypeBox Union reports only a root-level "Expected union value".
14
+ * 2. TypeBox `Value.Errors` output (catches anything the heuristics miss).
15
+ * 3. Concrete correct-call examples for the common actions.
16
+ * Agents can self-correct from this instead of looping.
17
+ */
18
+
19
+ import type { TSchema } from "@sinclair/typebox";
20
+ import { Value } from "@sinclair/typebox/value";
21
+
22
+ /** Short human description of a received value (for error messages). */
23
+ function describeValue(v: unknown): string {
24
+ if (v === null) return "null";
25
+ if (Array.isArray(v)) return `array (length ${v.length})`;
26
+ if (typeof v === "object") return "object";
27
+ if (typeof v === "string") return `string "${v.length > 40 ? `${v.slice(0, 37)}...` : v}"`;
28
+ return `${typeof v} (${String(v).slice(0, 40)})`;
29
+ }
30
+
31
+ /** Pinpoint the common agent mistakes that TypeBox Union can't field-locate. */
32
+ function diagnoseCommonMistakes(params: unknown): string[] {
33
+ const hints: string[] = [];
34
+ if (typeof params !== "object" || params === null || Array.isArray(params)) {
35
+ return hints;
36
+ }
37
+ const p = params as Record<string, unknown>;
38
+
39
+ // action: casing / non-string
40
+ if ("action" in p) {
41
+ const a = p.action;
42
+ if (typeof a !== "string") {
43
+ hints.push(` • 'action': must be a lowercase string (e.g. "run", "status", "list") — got ${describeValue(a)}.`);
44
+ } else if (a !== a.toLowerCase()) {
45
+ hints.push(` • 'action': "${a}" has wrong casing — action is case-sensitive. Use "${a.toLowerCase()}".`);
46
+ }
47
+ }
48
+
49
+ // Flat-string fields that agents commonly pass as objects/arrays
50
+ const stringFields = ["goal", "team", "runId", "task", "role", "agent", "workflow", "model", "cwd", "chain"];
51
+ for (const f of stringFields) {
52
+ if (!(f in p)) continue;
53
+ const v = p[f];
54
+ if (typeof v === "string") continue;
55
+ if (Array.isArray(v)) {
56
+ hints.push(
57
+ ` • '${f}': must be a single flat string — got an array (length ${v.length}). Pass e.g. "${f}": "<value>", not a list.`,
58
+ );
59
+ } else if (typeof v === "object" && v !== null) {
60
+ hints.push(` • '${f}': must be a single flat string — got an object. Pass e.g. "${f}": "<value>".`);
61
+ } else {
62
+ hints.push(` • '${f}': must be a string — got ${describeValue(v)}.`);
63
+ }
64
+ }
65
+ return hints;
66
+ }
67
+
68
+ /**
69
+ * Build a helpful validation-failure message for a team-tool call.
70
+ *
71
+ * @param schema The TypeBox schema (TeamToolParams) that rejected the params.
72
+ * @param params The raw params the caller passed.
73
+ * @returns A multi-line message: what went wrong + correct-call examples.
74
+ */
75
+ export function formatTeamToolParamError(schema: TSchema, params: unknown): string {
76
+ const issues = [...diagnoseCommonMistakes(params)];
77
+
78
+ try {
79
+ const errors = [...Value.Errors(schema, params)];
80
+ const seen = new Set<string>();
81
+ for (const e of errors) {
82
+ if (issues.length >= 8) break;
83
+ const key = `${e.path}|${e.type}`;
84
+ if (seen.has(key)) continue;
85
+ seen.add(key);
86
+ const pathLabel = e.path ? `'${e.path}'` : "(schema)";
87
+ issues.push(` • ${pathLabel}: ${e.message} — received ${describeValue(e.value)}`);
88
+ }
89
+ } catch {
90
+ /* Value.Errors itself threw — the heuristic issues are still shown */
91
+ }
92
+
93
+ if (issues.length === 0) {
94
+ issues.push(" • params did not match any valid team-tool action shape");
95
+ }
96
+
97
+ return [
98
+ "Invalid team tool parameters — the call was rejected by schema validation.",
99
+ "",
100
+ "What went wrong:",
101
+ ...issues,
102
+ "",
103
+ "Correct call shapes — `action` is case-sensitive (lowercase); `goal`,",
104
+ "`team`, `runId`, `task`, `role` MUST be flat strings, never objects/arrays:",
105
+ ' • Run a team: { "action": "run", "goal": "<what to achieve>", "team": "implementation" }',
106
+ ' • Suggest a team: { "action": "recommend", "goal": "<what to achieve>" }',
107
+ ' • List recent runs: { "action": "list" }',
108
+ ' • Run status: { "action": "status", "runId": "<runId>" }',
109
+ ' • Direct one agent: { "action": "run", "role": "<agent>", "task": "<one task>" }',
110
+ "",
111
+ 'Common mistakes: `action` uppercase (use "run" not "Run"); `goal` passed',
112
+ "as an array/object instead of a single string; params nested instead of flat.",
113
+ ].join("\n");
114
+ }
@@ -60,6 +60,11 @@ export function handleStatus(params: TeamToolParamsValue, ctx: TeamContext): PiT
60
60
  const phaseProgress = computePhaseProgress(tasks);
61
61
  const allEvents = readEvents(manifest.eventsPath);
62
62
  const events = allEvents.slice(-8);
63
+ // P1-8: pre-build the ack-timeout requestId set once (was O(events × messages)
64
+ // via allEvents.some() inside the mailbox loop).
65
+ const ackTimeoutRequestIds = new Set(
66
+ allEvents.filter((event) => event.type === "agent.group_join.ack_timeout").map((event) => String(event.data?.requestId ?? "")),
67
+ );
63
68
  const attentionByTask = new Map(
64
69
  allEvents.filter((event) => event.type === "task.attention" && event.taskId).map((event) => [event.taskId!, event]),
65
70
  );
@@ -80,7 +85,7 @@ export function handleStatus(params: TeamToolParamsValue, ctx: TeamContext): PiT
80
85
  const ageMs = Date.now() - new Date(message.createdAt).getTime();
81
86
  const requestId = String(message.data?.requestId ?? "unknown");
82
87
  const timedOut = ack === "pending" && ackTimeoutMs !== undefined && Number.isFinite(ageMs) && ageMs > ackTimeoutMs;
83
- if (timedOut && !allEvents.some((event) => event.type === "agent.group_join.ack_timeout" && event.data?.requestId === requestId)) {
88
+ if (timedOut && !ackTimeoutRequestIds.has(requestId)) {
84
89
  appendEvent(manifest.eventsPath, {
85
90
  type: "agent.group_join.ack_timeout",
86
91
  runId: manifest.runId,
@@ -1,5 +1,6 @@
1
- import { appendEvent, readEvents } from "../state/event-log.ts";
1
+ import { appendEvent, type TeamEvent } from "../state/event-log.ts";
2
2
  import type { CrewAttentionEventData, TeamRunManifest } from "../state/types.ts";
3
+ import { readJsonlTail } from "../utils/incremental-reader.ts";
3
4
 
4
5
  export interface AppendTaskAttentionInput {
5
6
  manifest: TeamRunManifest;
@@ -9,7 +10,7 @@ export interface AppendTaskAttentionInput {
9
10
  }
10
11
 
11
12
  export function appendTaskAttentionEvent(input: AppendTaskAttentionInput): boolean {
12
- const recent = readEvents(input.manifest.eventsPath).slice(-200);
13
+ const recent = readJsonlTail<TeamEvent>(input.manifest.eventsPath, 256 * 1024).items.slice(-200);
13
14
  const dedupKey = `${input.taskId ?? ""}:${input.data.reason}:${input.data.activityState}`;
14
15
  const duplicate = recent.some(
15
16
  (event) =>
@@ -87,6 +87,18 @@ function compactChildPiEvent(event: unknown): unknown | undefined {
87
87
  stopReason: record.stopReason,
88
88
  };
89
89
  }
90
+ // P2-25: pass supervisor_contact events through with their full payload. The
91
+ // generic fallthrough below strips everything but `type`, which made the
92
+ // feature dead — the contact never reached recordSupervisorContact.
93
+ if (record.type === "supervisor_contact" || record.type === "crew_supervisor_contact") {
94
+ return {
95
+ type: record.type,
96
+ taskId: record.taskId,
97
+ reason: record.reason,
98
+ message: record.message,
99
+ data: record.data,
100
+ };
101
+ }
90
102
  return record.type ? { type: record.type } : undefined;
91
103
  }
92
104
 
@@ -11,11 +11,12 @@ import { logInternalError } from "../utils/internal-error.ts";
11
11
  import { redactSecretString } from "../utils/redaction.ts";
12
12
  import { getActiveBrokerIssuer } from "./broker-issuer.ts";
13
13
  import { FINAL_DRAIN_MS, HARD_KILL_MS, POST_EXIT_STDIO_GUARD_MS, RESPONSE_TIMEOUT_MS } from "./child-pi-constants.ts";
14
- import { appendBoundedTail, clearHardKillTimer, killProcessTree, registerActiveChild, unregisterActiveChild } from "./child-pi-kill.ts";
14
+ import { clearHardKillTimer, killProcessTree, registerActiveChild, unregisterActiveChild } from "./child-pi-kill.ts";
15
15
  import { buildFinalChildPiSpawnOptions, prepareSpawnContext } from "./child-pi-spawn.ts";
16
16
  import { ChildPiSteeringController } from "./child-pi-steering.ts";
17
17
  // Internal helpers for active-child bookkeeping (extracted to child-pi-kill.ts).
18
18
  import { ChildPiLineObserver } from "./child-pi-streams.ts";
19
+ import { BoundedTail } from "./compact-stages/bounded-tail.ts";
19
20
 
20
21
  // ── Re-exports from child-pi-kill.ts (H-7 decomposition step 2) ──
21
22
  // killProcessTree is internal (not previously exported) — keep that invariant.
@@ -431,8 +432,10 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
431
432
  ts: new Date().toISOString(),
432
433
  });
433
434
  }
434
- let stdout = "";
435
- let stderr = "";
435
+ // P0-1: O(1)-amortized bounded accumulators (segment ring) — replaces the O(n²)
436
+ // appendBoundedTail rebuild-per-line pattern that re-scanned 512 KiB every line.
437
+ const stdoutTail = new BoundedTail();
438
+ const stderrTail = new BoundedTail();
436
439
  let settled = false;
437
440
  let childExited = false;
438
441
  let postExitGuardCleanup: (() => void) | undefined;
@@ -526,6 +529,8 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
526
529
  if (noResponseTimer) clearTimeout(noResponseTimer);
527
530
  noResponseTimer = setTimeout(() => {
528
531
  responseTimeoutHit = true;
532
+ // P0-1: snapshot the bounded stderr accumulator once for this timer fire.
533
+ const stderr = stderrTail.value();
529
534
  // Capture stderr at timeout moment for debugging
530
535
  // SEC-1: redact secrets before embedding in lifecycle event so
531
536
  // worker-emitted secrets (API keys etc.) don't bypass redaction.
@@ -569,8 +574,8 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
569
574
  const timeoutErr = `Child Pi produced no new output for ${responseTimeoutMs}ms; killed but did not exit within ${SAFETY_SETTLE_MS}ms (possible zombie).`;
570
575
  void settle({
571
576
  exitCode: null,
572
- stdout,
573
- stderr,
577
+ stdout: stdoutTail.value(),
578
+ stderr: stderrTail.value(),
574
579
  error: timeoutErr,
575
580
  exitStatus: {
576
581
  exitCode: null,
@@ -600,7 +605,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
600
605
  ...input,
601
606
  onStdoutLine: (line) => {
602
607
  if (!steeringController.isHardAbortInitiated()) restartNoResponseTimer();
603
- stdout = appendBoundedTail(stdout, `${line}\n`);
608
+ stdoutTail.push(`${line}\n`);
604
609
  input.onStdoutLine?.(line);
605
610
  },
606
611
  onJsonEvent: (event) => {
@@ -913,9 +918,12 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
913
918
  });
914
919
  child.stderr?.on("data", (chunk: Buffer) => {
915
920
  if (!steeringController.isHardAbortInitiated()) restartNoResponseTimer();
916
- stderr = appendBoundedTail(stderr, chunk.toString("utf-8"));
921
+ stderrTail.push(chunk.toString("utf-8"));
917
922
  });
918
923
  child.on("error", (error) => {
924
+ // P0-1: snapshot the bounded accumulators once for this handler.
925
+ const stdout = stdoutTail.value();
926
+ const stderr = stderrTail.value();
919
927
  // Reject pending operations with process error context
920
928
  // SEC-1: redact stderr secrets embedded in the error message + excerpt.
921
929
  const processError = new Error(
@@ -956,6 +964,8 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
956
964
  });
957
965
  });
958
966
  child.on("exit", (code, signal) => {
967
+ // P0-1: snapshot the bounded stderr accumulator once for this handler.
968
+ const stderr = stderrTail.value();
959
969
  if (child.pid) {
960
970
  unregisterActiveChild(child.pid);
961
971
  clearHardKillTimer(child.pid);
@@ -1017,6 +1027,9 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
1017
1027
  }
1018
1028
  });
1019
1029
  child.on("close", (exitCode) => {
1030
+ // P0-1: snapshot the bounded accumulators once for this handler.
1031
+ const stdout = stdoutTail.value();
1032
+ const stderr = stderrTail.value();
1020
1033
  if (child.pid) {
1021
1034
  unregisterActiveChild(child.pid);
1022
1035
  clearHardKillTimer(child.pid);
@@ -0,0 +1,84 @@
1
+ /**
2
+ * BoundedTail — O(1)-amortized bounded string accumulator (segment ring + running
3
+ * byte counter).
4
+ *
5
+ * Replaces the O(n²) `appendBoundedTail` rebuild-per-line pattern on the child stdout/stderr
6
+ * hot path. The defect (P0-1, verified 2026-07-29): the old accumulator did
7
+ * `new TailCaptureStage(...).apply(current + chunk)` on EVERY output line, so once the
8
+ * buffer filled to the 512 KiB cap each line re-scanned the whole buffer with
9
+ * `Buffer.byteLength` AND ran a per-character `while(...) tail = tail.slice(0,-1)` trim
10
+ * — 2.3 s (ASCII) to 112 s (multi-byte) of main-thread block per task.
11
+ *
12
+ * `push(chunk)` is O(chunk): it appends the segment and shifts whole leading segments
13
+ * while over cap, recomputing byte length only for the dropped segment — never the full
14
+ * buffer, never per-character. The single byte-exact boundary trim runs ONCE, in
15
+ * `value()`, which is read only at result-build / error time (rare).
16
+ *
17
+ * Output parity with the old `TailCaptureStage`-based path is preserved: under cap →
18
+ * verbatim; over cap → `[pi-crew captured output truncated to last X KiB]\n` + the last
19
+ * ≤maxBytes bytes snapped to a UTF-8 char boundary.
20
+ */
21
+ import { DEFAULT_CHILD_PI } from "../../config/defaults.ts";
22
+
23
+ const DEFAULT_MAX_BYTES = DEFAULT_CHILD_PI.maxCaptureBytes;
24
+
25
+ /** Default marker, identical to the old appendBoundedTail/TailCaptureStage wording. */
26
+ function defaultMarker(maxBytes: number): string {
27
+ return `[pi-crew captured output truncated to last ${Math.round(maxBytes / 1024)} KiB]`;
28
+ }
29
+
30
+ export class BoundedTail {
31
+ readonly #maxBytes: number;
32
+ readonly #marker: string;
33
+ #segs: string[] = [];
34
+ #bytes = 0;
35
+ #dropped = false;
36
+ #cached: string | undefined;
37
+ #dirty = false;
38
+
39
+ constructor(maxBytes: number = DEFAULT_MAX_BYTES, marker?: string) {
40
+ if (!(maxBytes > 0)) throw new Error(`BoundedTail: maxBytes must be > 0, got ${maxBytes}`);
41
+ this.#maxBytes = maxBytes;
42
+ this.#marker = marker ?? defaultMarker(maxBytes);
43
+ }
44
+
45
+ /** Append a chunk. O(chunk) amortized; never scans the full buffer. */
46
+ push(chunk: string): this {
47
+ if (!chunk) return this;
48
+ this.#segs.push(chunk);
49
+ this.#bytes += Buffer.byteLength(chunk, "utf-8");
50
+ // Drop whole leading segments while over cap. Stop at length 1 so a single
51
+ // oversized segment is trimmed exactly once in value() instead of per-char.
52
+ while (this.#bytes > this.#maxBytes && this.#segs.length > 1) {
53
+ const dropped = this.#segs.shift() as string;
54
+ this.#bytes -= Buffer.byteLength(dropped, "utf-8");
55
+ this.#dropped = true;
56
+ }
57
+ this.#dirty = true;
58
+ return this;
59
+ }
60
+
61
+ /** Materialize the bounded string. Computed at most once per push; cached otherwise. */
62
+ value(): string {
63
+ if (!this.#dirty && this.#cached !== undefined) return this.#cached;
64
+ const body = this.#segs.join("");
65
+ const result = this.#bound(body);
66
+ this.#cached = result;
67
+ this.#dirty = false;
68
+ return result;
69
+ }
70
+
71
+ /** Byte-exact bound with marker parity vs the old TailCaptureStage path. */
72
+ #bound(body: string): string {
73
+ if (Buffer.byteLength(body, "utf-8") <= this.#maxBytes) {
74
+ // Under cap: add the marker only if we dropped earlier segments.
75
+ return this.#dropped ? `${this.#marker}\n${body}` : body;
76
+ }
77
+ // Over cap (single segment larger than max, or a boundary segment): keep the
78
+ // last ≤maxBytes bytes snapped forward past any UTF-8 continuation bytes.
79
+ const buf = Buffer.from(body, "utf-8");
80
+ let start = Math.max(0, buf.length - this.#maxBytes);
81
+ while (start < buf.length && (buf[start] & 0xc0) === 0x80) start++;
82
+ return `${this.#marker}\n${buf.subarray(start).toString("utf-8")}`;
83
+ }
84
+ }
@@ -1,4 +1,5 @@
1
1
  import { DEFAULT_CONCURRENCY } from "../config/defaults.ts";
2
+ import { getWorkerCapCapacity } from "./global-worker-cap.ts";
2
3
 
3
4
  export interface ResolveBatchConcurrencyInput {
4
5
  workflowName: string;
@@ -7,6 +8,8 @@ export interface ResolveBatchConcurrencyInput {
7
8
  limitMaxConcurrentWorkers?: number;
8
9
  allowUnboundedConcurrency?: boolean;
9
10
  hardCap?: number;
11
+ /** Optional override for the global worker cap (test determinism). Defaults to getWorkerCapCapacity(). */
12
+ workerCap?: number;
10
13
  readyCount: number;
11
14
  workspaceMode?: "single" | "worktree";
12
15
  readyRoles?: string[];
@@ -45,9 +48,12 @@ export function resolveBatchConcurrency(input: ResolveBatchConcurrencyInput): Ba
45
48
  else if (teamMax !== undefined) source = "team";
46
49
  else source = "workflow";
47
50
  const hardCap = positiveInteger(input.hardCap) ?? DEFAULT_CONCURRENCY.hardCap;
48
- const maxConcurrent = input.allowUnboundedConcurrency ? requested : Math.min(requested, hardCap);
51
+ // P1-7: consult the global worker cap so the scheduler doesn't over-dispatch
52
+ // (e.g. dispatch 4 while the global semaphore holds 2 on a 4-core machine).
53
+ const workerCap = positiveInteger(input.workerCap) ?? getWorkerCapCapacity();
54
+ const maxConcurrent = input.allowUnboundedConcurrency ? requested : Math.min(requested, hardCap, workerCap);
49
55
  const readyCount = Math.max(0, Math.trunc(Number.isFinite(input.readyCount) ? input.readyCount : 0));
50
- const cappedReason = maxConcurrent < requested ? `;capped:${hardCap}` : "";
56
+ const cappedReason = maxConcurrent < requested ? `;capped:hard=${hardCap},worker=${workerCap}` : "";
51
57
  const unboundedReason = input.allowUnboundedConcurrency && requested > hardCap ? `;unbounded:${hardCap}` : "";
52
58
  return {
53
59
  maxConcurrent,
@@ -26,14 +26,39 @@ export function agentStateDir(manifest: TeamRunManifest, taskId: string): string
26
26
  return path.join(agentsRoot(manifest), safeAgentTaskId(taskId));
27
27
  }
28
28
 
29
+ // P1-5: per-task path memoization. The agent state dir + file paths cannot
30
+ // change within a task, so the mkdir/lstat/resolveRealContainedPath validation
31
+ // (≈30 syscalls each, incl. a full ancestor walk) only needs to run ONCE per
32
+ // task — not on every event/output line (was ~60 syscalls/event for the double
33
+ // ensureAgentStateDir call alone). FIFO-bounded so the cache can't grow with
34
+ // run count.
35
+ const ensuredAgentDirs = new Map<string, string>();
36
+ const resolvedAgentFiles = new Map<string, string>();
37
+ const AGENT_PATH_CACHE_MAX = 512;
38
+
39
+ function bumpAgentPathCache(): void {
40
+ if (ensuredAgentDirs.size > AGENT_PATH_CACHE_MAX) {
41
+ const oldest = ensuredAgentDirs.keys().next().value;
42
+ if (oldest !== undefined) ensuredAgentDirs.delete(oldest);
43
+ }
44
+ if (resolvedAgentFiles.size > AGENT_PATH_CACHE_MAX) {
45
+ const oldest = resolvedAgentFiles.keys().next().value;
46
+ if (oldest !== undefined) resolvedAgentFiles.delete(oldest);
47
+ }
48
+ }
49
+
29
50
  export function ensureAgentStateDir(manifest: TeamRunManifest, taskId: string): string {
30
51
  const root = agentsRoot(manifest);
52
+ const dir = agentStateDir(manifest, taskId);
53
+ const cachedDir = ensuredAgentDirs.get(dir);
54
+ if (cachedDir !== undefined) return cachedDir;
31
55
  fs.mkdirSync(root, { recursive: true });
32
56
  if (fs.lstatSync(root).isSymbolicLink()) throw new Error(`Invalid agents root: ${root}`);
33
- const dir = agentStateDir(manifest, taskId);
34
57
  fs.mkdirSync(dir, { recursive: true });
35
58
  if (fs.lstatSync(dir).isSymbolicLink()) throw new Error(`Invalid agent state directory: ${dir}`);
36
59
  resolveRealContainedPath(root, path.basename(dir));
60
+ ensuredAgentDirs.set(dir, dir);
61
+ bumpAgentPathCache();
37
62
  return dir;
38
63
  }
39
64
 
@@ -45,8 +70,26 @@ function safeExistingAgentFile(manifest: TeamRunManifest, taskId: string, fileNa
45
70
  }
46
71
 
47
72
  export function agentStateFile(manifest: TeamRunManifest, taskId: string, fileName: string): string {
73
+ const dir = agentStateDir(manifest, taskId);
74
+ const cacheKey = `${dir}\0${fileName}`;
75
+ const cached = resolvedAgentFiles.get(cacheKey);
76
+ if (cached !== undefined) return cached;
48
77
  ensureAgentStateDir(manifest, taskId);
49
- return safeExistingAgentFile(manifest, taskId, fileName);
78
+ const resolved = safeExistingAgentFile(manifest, taskId, fileName);
79
+ resolvedAgentFiles.set(cacheKey, resolved);
80
+ bumpAgentPathCache();
81
+ return resolved;
82
+ }
83
+
84
+ /** @internal Test-only: clear the path memoization cache. */
85
+ export function __test_clearAgentPathCache(): void {
86
+ ensuredAgentDirs.clear();
87
+ resolvedAgentFiles.clear();
88
+ }
89
+
90
+ /** @internal Test-only: inspect cache occupancy (regression guard for P1-5). */
91
+ export function __test_agentPathCacheStats(): { dirs: number; files: number } {
92
+ return { dirs: ensuredAgentDirs.size, files: resolvedAgentFiles.size };
50
93
  }
51
94
 
52
95
  export function agentStatusPath(manifest: TeamRunManifest, taskId: string): string {
@@ -250,6 +293,9 @@ export async function readCrewAgentsAsync(manifest: TeamRunManifest): Promise<Cr
250
293
  }
251
294
 
252
295
  export function saveCrewAgents(manifest: TeamRunManifest, records: CrewAgentRecord[]): void {
296
+ // P0-3: flush any pending coalesced (best-effort) write first so a stale
297
+ // debounced progress snapshot can't clobber this durable write.
298
+ flushPendingAgentWrites();
253
299
  withAgentsLock(manifest, () => {
254
300
  fs.mkdirSync(manifest.stateRoot, { recursive: true });
255
301
  const filePath = agentsPath(manifest);
@@ -4,11 +4,12 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
4
  import type { MetricRegistry } from "../observability/metric-registry.ts";
5
5
  import type { MetricSnapshot } from "../observability/metrics-primitives.ts";
6
6
  import { atomicWriteFile } from "../state/atomic-write.ts";
7
- import { readEvents, type TeamEvent } from "../state/event-log.ts";
7
+ import type { TeamEvent } from "../state/event-log.ts";
8
8
  import { loadRunManifestById } from "../state/state-store.ts";
9
9
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
10
10
  import { type HeartbeatSummary, summarizeHeartbeats } from "../ui/heartbeat-aggregator.ts";
11
11
  import type { RunUiSnapshot } from "../ui/snapshot-types.ts";
12
+ import { readJsonlTail } from "../utils/incremental-reader.ts";
12
13
  import { isSecretKey, redactSecrets } from "../utils/redaction.ts";
13
14
  import { readCrewAgents } from "./crew-agent-records.ts";
14
15
  import { buildRecoveryLedger, type RecoveryLedgerEntry } from "./recovery-recipes.ts";
@@ -92,7 +93,7 @@ export async function exportDiagnostic(
92
93
  if (!loaded) throw new Error(`Run '${runId}' not found.`);
93
94
  const exportedAt = new Date().toISOString();
94
95
  const safeTimestamp = exportedAt.replace(/[:.]/g, "-");
95
- const recentEvents = readEvents(loaded.manifest.eventsPath).slice(-200);
96
+ const recentEvents = readJsonlTail<TeamEvent>(loaded.manifest.eventsPath, 256 * 1024).items.slice(-200);
96
97
  const metricsSnapshot = options.registry?.snapshot();
97
98
  const taskDiagnostics: Record<string, Record<string, unknown>> = {};
98
99
  const terminalEvidence: Record<string, TeamTaskState["terminalEvidence"]> = {};
@@ -109,7 +109,11 @@ function parseManifestIfChanged(root: string, runId: string, filePath: string, p
109
109
  return undefined;
110
110
  }
111
111
  if (previous && previous.mtimeMs === stat.mtimeMs && previous.size === stat.size) {
112
- return validateManifestForRoot(root, runId, previous.manifest) ? previous : undefined;
112
+ // P1-9: the manifest file is unchanged, so its recorded paths and their
113
+ // containment verdict are unchanged too — skip the ~10-syscall
114
+ // validateManifestForRoot re-check on cache hit. list() and listActive()
115
+ // both scan within the same TTL window, so the second scan is now free.
116
+ return previous;
113
117
  }
114
118
  const manifest = parseManifest(filePath);
115
119
  if (!manifest || !validateManifestForRoot(root, runId, manifest)) return undefined;
@@ -33,7 +33,10 @@ export class Semaphore {
33
33
  // implementation let #queue grow without bound, risking memory
34
34
  // exhaustion under sustained high concurrency with slow releases.
35
35
  if (this.#queue.length >= Semaphore.MAX_QUEUE) {
36
- throw new Error(`Semaphore queue full: ${this.#queue.length} waiters (max ${Semaphore.MAX_QUEUE}); cannot acquire slot`);
36
+ // P1-7: reject (don't throw) so callers can backpressure instead of crashing.
37
+ return Promise.reject(
38
+ new Error(`Semaphore queue full: ${this.#queue.length} waiters (max ${Semaphore.MAX_QUEUE}); cannot acquire slot`),
39
+ );
37
40
  }
38
41
  const { promise, resolve } = (() => {
39
42
  let res: () => void;
@@ -37,22 +37,21 @@ export function recordSupervisorContact(manifest: TeamRunManifest, payload: Omit
37
37
  * Parse a supervisor contact request from child Pi stdout.
38
38
  * Detects structured JSON lines with type "supervisor_contact".
39
39
  */
40
- export function parseSupervisorContactFromLine(line: string): Omit<SupervisorContactPayload, "timestamp" | "runId"> | undefined {
41
- if (!line.trim()) return undefined;
42
- let parsed: unknown;
43
- try {
44
- parsed = JSON.parse(line);
45
- } catch {
46
- return undefined;
47
- }
48
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
49
- const record = parsed as Record<string, unknown>;
40
+ const SUPERVISOR_CONTACT_REASONS = ["decision_needed", "clarification", "approval", "error_escalation", "custom"] as const;
41
+
42
+ /**
43
+ * Validate a parsed event object as a supervisor-contact payload. Shared by the
44
+ * event-based (current) and line-based (deprecated) entry points. Returns the
45
+ * normalized payload, or undefined if the event is not a supervisor contact.
46
+ */
47
+ export function supervisorContactFromEvent(event: unknown): Omit<SupervisorContactPayload, "timestamp" | "runId"> | undefined {
48
+ if (!event || typeof event !== "object" || Array.isArray(event)) return undefined;
49
+ const record = event as Record<string, unknown>;
50
50
  if (record.type !== "supervisor_contact" && record.type !== "crew_supervisor_contact") return undefined;
51
51
  return {
52
52
  taskId: typeof record.taskId === "string" ? record.taskId : "",
53
53
  reason:
54
- typeof record.reason === "string" &&
55
- ["decision_needed", "clarification", "approval", "error_escalation", "custom"].includes(record.reason)
54
+ typeof record.reason === "string" && (SUPERVISOR_CONTACT_REASONS as readonly string[]).includes(record.reason)
56
55
  ? (record.reason as SupervisorContactPayload["reason"])
57
56
  : "custom",
58
57
  message: typeof record.message === "string" ? record.message : String(record.message ?? ""),
@@ -62,3 +61,19 @@ export function parseSupervisorContactFromLine(line: string): Omit<SupervisorCon
62
61
  : undefined,
63
62
  };
64
63
  }
64
+
65
+ /**
66
+ * @deprecated The compact pipeline now passes `supervisor_contact` events through
67
+ * to `onJsonEvent` with their full payload (P2-25 fix). Prefer
68
+ * `supervisorContactFromEvent` on the parsed event. Kept for backward compat.
69
+ */
70
+ export function parseSupervisorContactFromLine(line: string): Omit<SupervisorContactPayload, "timestamp" | "runId"> | undefined {
71
+ if (!line.trim()) return undefined;
72
+ let parsed: unknown;
73
+ try {
74
+ parsed = JSON.parse(line);
75
+ } catch {
76
+ return undefined;
77
+ }
78
+ return supervisorContactFromEvent(parsed);
79
+ }