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/CHANGELOG.md +29 -0
- package/dist/index.mjs +1153 -857
- package/package.json +1 -2
- package/scripts/smoke-team-perf.ts +43 -0
- package/src/extension/registration/team-tool.ts +2 -1
- package/src/extension/team-tool/param-error.ts +114 -0
- package/src/extension/team-tool/status.ts +6 -1
- package/src/runtime/attention-events.ts +3 -2
- package/src/runtime/child-pi-streams.ts +12 -0
- package/src/runtime/child-pi.ts +20 -7
- package/src/runtime/compact-stages/bounded-tail.ts +84 -0
- package/src/runtime/concurrency.ts +8 -2
- package/src/runtime/crew-agent-records.ts +48 -2
- package/src/runtime/diagnostic-export.ts +3 -2
- package/src/runtime/manifest-cache.ts +5 -1
- package/src/runtime/semaphore.ts +4 -1
- package/src/runtime/supervisor-contact.ts +27 -12
- package/src/runtime/task-runner/child-executor.ts +5 -9
- package/src/runtime/team-runner.ts +62 -37
- package/src/schema/team-tool-schema.ts +19 -11
- package/src/state/event-log-rotation.ts +28 -0
- package/src/state/event-log.ts +39 -13
- package/src/state/state-store.ts +18 -4
- package/src/utils/redaction.ts +39 -0
- package/src/worktree/worktree-manager.ts +23 -8
- package/dist/build-meta.json +0 -16807
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-crew",
|
|
3
|
-
"version": "0.9.
|
|
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(
|
|
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 && !
|
|
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,
|
|
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 =
|
|
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
|
|
package/src/runtime/child-pi.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
435
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
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;
|
package/src/runtime/semaphore.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
if (!
|
|
49
|
-
const record =
|
|
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
|
+
}
|