pi-crew 0.9.8 → 0.9.10
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 +311 -0
- package/README.md +2 -2
- package/docs/fixes/v0.9.10/locks-fix-verify.md +3 -0
- package/docs/fixes/v0.9.10/smoke-test.md +12 -0
- package/package.json +1 -1
- package/src/extension/register.ts +94 -21
- package/src/extension/registration/subagent-helpers.ts +1 -0
- package/src/extension/registration/subagent-tools.ts +9 -0
- package/src/extension/team-tool/doctor.ts +41 -18
- package/src/runtime/batch-barrier.ts +145 -0
- package/src/runtime/child-pi.ts +135 -22
- package/src/runtime/compact-pipeline.ts +56 -0
- package/src/runtime/compact-stages/ansi-strip-stage.ts +25 -0
- package/src/runtime/compact-stages/blank-collapse-stage.ts +31 -0
- package/src/runtime/compact-stages/deduplicate-stage.ts +34 -0
- package/src/runtime/compact-stages/head-snap-stage.ts +57 -0
- package/src/runtime/compact-stages/index.ts +13 -0
- package/src/runtime/compact-stages/tail-capture-stage.ts +72 -0
- package/src/runtime/compact-stages/truncation-stage.ts +71 -0
- package/src/runtime/crash-classification.ts +208 -0
- package/src/runtime/custom-tools/irc-tool.ts +47 -7
- package/src/runtime/handoff-manager.ts +10 -0
- package/src/runtime/important-line-classifier.ts +130 -0
- package/src/runtime/iteration-hooks.ts +7 -19
- package/src/runtime/live-agent-manager.ts +185 -0
- package/src/runtime/live-session-runtime.ts +50 -1
- package/src/runtime/model-fallback.ts +29 -1
- package/src/runtime/process-lifecycle.ts +481 -0
- package/src/runtime/role-permission.ts +2 -2
- package/src/runtime/stream-preview.ts +9 -2
- package/src/runtime/subagent-manager.ts +6 -0
- package/src/runtime/task-output-context.ts +209 -24
- package/src/runtime/task-runner.ts +76 -15
- package/src/runtime/tool-output-pruner.ts +334 -0
- package/src/state/locks.ts +16 -0
- package/src/state/state-store.ts +8 -2
- package/src/state/types.ts +5 -0
- package/src/ui/live-run-sidebar.ts +6 -1
- package/src/ui/loaders.ts +24 -4
- package/src/ui/run-dashboard.ts +6 -1
- package/src/ui/run-event-bus.ts +1 -1
- package/src/ui/run-snapshot-cache.ts +50 -16
- package/src/ui/widget/index.ts +27 -5
- package/src/ui/widget/widget-renderer.ts +43 -13
- package/src/utils/redaction.ts +17 -1
- package/src/utils/visual.ts +6 -0
- package/src/ui/crew-widget.ts +0 -544
|
@@ -28,27 +28,47 @@ function firstOutputLine(stdout: string | null | undefined, stderr: string | nul
|
|
|
28
28
|
return output.split(/\r?\n/).find((line) => line.trim().length > 0)?.trim() ?? "available";
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// Round 29 optimization: memoize spawnSync probe results at module level.
|
|
32
|
+
// The probes (git --version, pi --version) are stable for the process
|
|
33
|
+
// lifetime, and spawnSync on a node script can cost 1-2s. Without the
|
|
34
|
+
// cache, each buildTeamDoctorReport() call would pay that cost, and a
|
|
35
|
+
// file with 12 tests would take 20s+ even with empty cwd. The cache is
|
|
36
|
+
// safe: a doctor check is informational, and a stale ok=true would
|
|
37
|
+
// self-correct on the next process restart.
|
|
38
|
+
const commandExistsCache = new Map<string, { ok: boolean; detail: string }>();
|
|
31
39
|
function commandExists(command: string, args: string[]): { ok: boolean; detail: string } {
|
|
40
|
+
const cacheKey = `${command} ${args.join(" ")}`;
|
|
41
|
+
const cached = commandExistsCache.get(cacheKey);
|
|
42
|
+
if (cached) return cached;
|
|
43
|
+
let result: { ok: boolean; detail: string };
|
|
32
44
|
try {
|
|
33
45
|
const output = spawnSync(command, args, { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] });
|
|
34
46
|
if (output.error) {
|
|
35
|
-
|
|
47
|
+
result = { ok: false, detail: output.error.message };
|
|
48
|
+
} else if (output.status !== 0) {
|
|
49
|
+
result = { ok: false, detail: firstOutputLine(output.stdout, output.stderr) || `status ${output.status}` };
|
|
50
|
+
} else {
|
|
51
|
+
result = { ok: true, detail: firstOutputLine(output.stdout, output.stderr) };
|
|
36
52
|
}
|
|
37
|
-
if (output.status !== 0) {
|
|
38
|
-
return { ok: false, detail: firstOutputLine(output.stdout, output.stderr) || `status ${output.status}` };
|
|
39
|
-
}
|
|
40
|
-
return { ok: true, detail: firstOutputLine(output.stdout, output.stderr) };
|
|
41
53
|
} catch (error) {
|
|
42
|
-
|
|
54
|
+
result = { ok: false, detail: error instanceof Error ? error.message : String(error) };
|
|
43
55
|
}
|
|
56
|
+
commandExistsCache.set(cacheKey, result);
|
|
57
|
+
return result;
|
|
44
58
|
}
|
|
45
59
|
|
|
60
|
+
let piCommandExistsCache: { ok: boolean; detail: string } | undefined;
|
|
46
61
|
function piCommandExists(): { ok: boolean; detail: string } {
|
|
62
|
+
if (piCommandExistsCache) return piCommandExistsCache;
|
|
47
63
|
const spec = getPiSpawnCommand(["--version"]);
|
|
48
64
|
const output = commandExists(spec.command, spec.args);
|
|
49
|
-
if (!output.ok)
|
|
65
|
+
if (!output.ok) {
|
|
66
|
+
piCommandExistsCache = output;
|
|
67
|
+
return piCommandExistsCache;
|
|
68
|
+
}
|
|
50
69
|
const executable = spec.command === "pi" ? "pi" : `${spec.command} ${spec.args[0] ?? ""}`.trim();
|
|
51
|
-
|
|
70
|
+
piCommandExistsCache = { ok: true, detail: `${output.detail} (${executable})` };
|
|
71
|
+
return piCommandExistsCache;
|
|
52
72
|
}
|
|
53
73
|
|
|
54
74
|
function checkWritableDir(dir: string): { ok: boolean; detail: string } {
|
|
@@ -119,12 +139,18 @@ export interface TeamDoctorReport {
|
|
|
119
139
|
}
|
|
120
140
|
|
|
121
141
|
export function buildTeamDoctorReport(input: TeamDoctorReportInput): TeamDoctorReport {
|
|
142
|
+
// Discover once — used in both Drift and Discovery sections. Walking the
|
|
143
|
+
// filesystem 3x (agents/teams/workflows) is the dominant cost of this
|
|
144
|
+
// function; calling it twice doubles the cost. Round 29 optimization.
|
|
145
|
+
const discoveredAgentsAll = allAgents(discoverAgents(input.cwd));
|
|
146
|
+
const discoveredTeamsAll = allTeams(discoverTeams(input.cwd));
|
|
147
|
+
const discoveredWorkflowsAll = allWorkflows(discoverWorkflows(input.cwd));
|
|
122
148
|
// Compute drift once — reused in both Drift section and return value
|
|
123
149
|
const driftResult = detectDrift(
|
|
124
150
|
{
|
|
125
|
-
agents:
|
|
126
|
-
teams:
|
|
127
|
-
workflows:
|
|
151
|
+
agents: discoveredAgentsAll.map((a) => a.name),
|
|
152
|
+
teams: discoveredTeamsAll.map((t) => t.name),
|
|
153
|
+
workflows: discoveredWorkflowsAll.map((w) => w.name),
|
|
128
154
|
},
|
|
129
155
|
loadConfig(input.cwd).config,
|
|
130
156
|
);
|
|
@@ -153,14 +179,11 @@ export function buildTeamDoctorReport(input: TeamDoctorReportInput): TeamDoctorR
|
|
|
153
179
|
];
|
|
154
180
|
}),
|
|
155
181
|
section("Discovery", () => {
|
|
156
|
-
const
|
|
157
|
-
const discoveredTeams = allTeams(discoverTeams(input.cwd));
|
|
158
|
-
const discoveredWorkflows = allWorkflows(discoverWorkflows(input.cwd));
|
|
159
|
-
const agentModelHints = discoveredAgents.filter((agent) => agent.model || agent.fallbackModels?.length).length;
|
|
182
|
+
const agentModelHints = discoveredAgentsAll.filter((agent) => agent.model || agent.fallbackModels?.length).length;
|
|
160
183
|
return [
|
|
161
|
-
{ label: "agents", ok: true, detail: `${
|
|
162
|
-
{ label: "teams", ok: true, detail: `${
|
|
163
|
-
{ label: "workflows", ok: true, detail: `${
|
|
184
|
+
{ label: "agents", ok: true, detail: `${discoveredAgentsAll.length} discovered` },
|
|
185
|
+
{ label: "teams", ok: true, detail: `${discoveredTeamsAll.length} discovered` },
|
|
186
|
+
{ label: "workflows", ok: true, detail: `${discoveredWorkflowsAll.length} discovered` },
|
|
164
187
|
{ label: "resource model hints", ok: true, detail: `${agentModelHints} agents declare model/fallback preferences` },
|
|
165
188
|
];
|
|
166
189
|
}),
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BatchBarrier — Rule 1 (no-wait batch grouping).
|
|
3
|
+
*
|
|
4
|
+
* When a leader launches several background subagents with the SAME `batchId`
|
|
5
|
+
* and does NOT join them immediately (`get_subagent_result(wait:true)`), the
|
|
6
|
+
* completion notifications are coalesced: instead of N individual
|
|
7
|
+
* "changed state" wake-ups, the leader receives ONE consolidated notification
|
|
8
|
+
* once ALL members of the batch have reached a terminal state.
|
|
9
|
+
*
|
|
10
|
+
* Semantics:
|
|
11
|
+
* - `register(batchId, agentId)` is called at spawn time (synchronous within a
|
|
12
|
+
* leader turn). All members of a batch are therefore known by the time the
|
|
13
|
+
* first completion fires (completion is observed via the 1000ms poll loop).
|
|
14
|
+
* - `markTerminal(batchId, agentId)` returns whether THIS completion made every
|
|
15
|
+
* registered member terminal ("allDone"). When allDone, the caller emits a
|
|
16
|
+
* single consolidated notification and calls `markNotified`.
|
|
17
|
+
* - If a member reaches terminal after the batch already notified (late spawn
|
|
18
|
+
* edge case), `markTerminal` returns allDone=false for the straggler path is
|
|
19
|
+
* NOT covered — but `alreadyNotified` lets the caller suppress stray
|
|
20
|
+
* individual notifications once the consolidated one fired.
|
|
21
|
+
*
|
|
22
|
+
* Thread-safety: single-threaded JS event loop. No locks needed.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export interface BatchMember {
|
|
26
|
+
id: string;
|
|
27
|
+
description?: string;
|
|
28
|
+
type?: string;
|
|
29
|
+
status: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface BatchSnapshot {
|
|
33
|
+
batchId: string;
|
|
34
|
+
members: BatchMember[];
|
|
35
|
+
terminal: BatchMember[];
|
|
36
|
+
/** true when every registered member has reached a terminal state. */
|
|
37
|
+
allDone: boolean;
|
|
38
|
+
/** true once the consolidated notification has been emitted. */
|
|
39
|
+
notified: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const TERMINAL_STATUSES = new Set([
|
|
43
|
+
"completed",
|
|
44
|
+
"failed",
|
|
45
|
+
"cancelled",
|
|
46
|
+
"error",
|
|
47
|
+
"stopped",
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
export function isTerminalStatus(status: string): boolean {
|
|
51
|
+
return TERMINAL_STATUSES.has(status);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class BatchBarrier {
|
|
55
|
+
private readonly batches = new Map<
|
|
56
|
+
string,
|
|
57
|
+
{
|
|
58
|
+
members: Map<string, BatchMember>;
|
|
59
|
+
terminal: Map<string, BatchMember>;
|
|
60
|
+
notified: boolean;
|
|
61
|
+
}
|
|
62
|
+
>();
|
|
63
|
+
|
|
64
|
+
/** Register a member at spawn time. Idempotent per (batchId, agentId). */
|
|
65
|
+
register(batchId: string, agentId: string, meta?: { description?: string; type?: string }): void {
|
|
66
|
+
let batch = this.batches.get(batchId);
|
|
67
|
+
if (!batch) {
|
|
68
|
+
batch = { members: new Map(), terminal: new Map(), notified: false };
|
|
69
|
+
this.batches.set(batchId, batch);
|
|
70
|
+
}
|
|
71
|
+
if (!batch.members.has(agentId)) {
|
|
72
|
+
batch.members.set(agentId, {
|
|
73
|
+
id: agentId,
|
|
74
|
+
description: meta?.description,
|
|
75
|
+
type: meta?.type,
|
|
76
|
+
status: "running",
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Record that a member reached a terminal state. Returns the batch snapshot.
|
|
83
|
+
* `snapshot.allDone` is true iff every registered member is now terminal.
|
|
84
|
+
* If the batch was never seen (defensive edge case), the member is registered
|
|
85
|
+
* on-the-fly as a batch-of-one so its terminal state is not silently lost.
|
|
86
|
+
*/
|
|
87
|
+
markTerminal(batchId: string, member: BatchMember): BatchSnapshot {
|
|
88
|
+
let batch = this.batches.get(batchId);
|
|
89
|
+
if (!batch) {
|
|
90
|
+
batch = { members: new Map(), terminal: new Map(), notified: false };
|
|
91
|
+
this.batches.set(batchId, batch);
|
|
92
|
+
}
|
|
93
|
+
// Ensure the member is known (auto-register for the defensive case).
|
|
94
|
+
if (!batch.members.has(member.id)) {
|
|
95
|
+
batch.members.set(member.id, { ...member, status: member.status });
|
|
96
|
+
}
|
|
97
|
+
if (isTerminalStatus(member.status)) {
|
|
98
|
+
batch.terminal.set(member.id, { ...member });
|
|
99
|
+
const existing = batch.members.get(member.id);
|
|
100
|
+
if (existing) batch.members.set(member.id, { ...existing, status: member.status });
|
|
101
|
+
}
|
|
102
|
+
const allDone =
|
|
103
|
+
batch.members.size > 0 &&
|
|
104
|
+
[...batch.members.keys()].every((id) => batch.terminal.has(id));
|
|
105
|
+
return {
|
|
106
|
+
batchId,
|
|
107
|
+
members: [...batch.members.values()],
|
|
108
|
+
terminal: [...batch.terminal.values()],
|
|
109
|
+
allDone,
|
|
110
|
+
notified: batch.notified,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Has the consolidated notification already been emitted for this batch? */
|
|
115
|
+
alreadyNotified(batchId: string): boolean {
|
|
116
|
+
return this.batches.get(batchId)?.notified ?? false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Mark the consolidated notification as emitted. No-op if already set. */
|
|
120
|
+
markNotified(batchId: string): void {
|
|
121
|
+
const batch = this.batches.get(batchId);
|
|
122
|
+
if (batch) batch.notified = true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Read-only snapshot (for tests / debugging). */
|
|
126
|
+
snapshot(batchId: string): BatchSnapshot | undefined {
|
|
127
|
+
const batch = this.batches.get(batchId);
|
|
128
|
+
if (!batch) return undefined;
|
|
129
|
+
return {
|
|
130
|
+
batchId,
|
|
131
|
+
members: [...batch.members.values()],
|
|
132
|
+
terminal: [...batch.terminal.values()],
|
|
133
|
+
allDone:
|
|
134
|
+
batch.members.size > 0 &&
|
|
135
|
+
[...batch.members.keys()].every((id) => batch.terminal.has(id)),
|
|
136
|
+
notified: batch.notified,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Drop a batch (used on cleanup / test reset). */
|
|
141
|
+
dispose(batchId?: string): void {
|
|
142
|
+
if (batchId === undefined) this.batches.clear();
|
|
143
|
+
else this.batches.delete(batchId);
|
|
144
|
+
}
|
|
145
|
+
}
|
package/src/runtime/child-pi.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
3
4
|
import * as path from "node:path";
|
|
4
5
|
import { WINDOWS_ESSENTIAL_ENV_VARS } from "../utils/env-allowlist.ts";
|
|
5
6
|
import type { AgentConfig } from "../agents/agent-config.ts";
|
|
@@ -9,9 +10,12 @@ import { getPiSpawnCommand } from "./pi-spawn.ts";
|
|
|
9
10
|
import { DEFAULT_CHILD_PI } from "../config/defaults.ts";
|
|
10
11
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
11
12
|
import { attachPostExitStdioGuard, trySignalChild } from "./post-exit-stdio-guard.ts";
|
|
12
|
-
import { redactJsonLine } from "../utils/redaction.ts";
|
|
13
|
+
import { redactJsonLine, redactSecretString } from "../utils/redaction.ts";
|
|
14
|
+
import { applyCompactPipeline } from "./compact-pipeline.ts";
|
|
15
|
+
import { TruncationStage, TailCaptureStage } from "./compact-stages/index.ts";
|
|
13
16
|
import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
|
|
14
17
|
import { registerChildProcess, unregisterChildProcess } from "../extension/crew-cleanup.ts";
|
|
18
|
+
import { classifyProcessCrash } from "./crash-classification.ts";
|
|
15
19
|
import { resolveRealContainedPath } from "../utils/safe-paths.ts";
|
|
16
20
|
|
|
17
21
|
const POST_EXIT_STDIO_GUARD_MS = DEFAULT_CHILD_PI.postExitStdioGuardMs;
|
|
@@ -26,12 +30,33 @@ const MAX_COMPACT_CONTENT_CHARS = DEFAULT_CHILD_PI.maxCompactContentChars;
|
|
|
26
30
|
const activeChildProcesses = new Map<number, ChildProcess>();
|
|
27
31
|
const childHardKillTimers = new Map<number, NodeJS.Timeout>();
|
|
28
32
|
|
|
33
|
+
/**
|
|
34
|
+
* SEC-1: Extract a redacted stderr/stdout excerpt for embedding in lifecycle
|
|
35
|
+
* events and error messages. The in-memory stdout/stderr accumulators receive
|
|
36
|
+
* RAW worker output (only structurally compacted via compactChildPiEvent —
|
|
37
|
+
* NOT secret-redacted), so any slice embedded into a persisted event must be
|
|
38
|
+
* redacted here. Otherwise worker-emitted secrets (API keys, tokens returned
|
|
39
|
+
* from a tool call) leak through diagnostic logs that bypass artifact-store
|
|
40
|
+
* redaction.
|
|
41
|
+
*
|
|
42
|
+
* Extracted as a single helper (8 call sites were duplicating this) so the
|
|
43
|
+
* redaction boundary is unit-testable directly. The real spawn error/timeout
|
|
44
|
+
* paths are integration-level and NOT reachable via PI_TEAMS_MOCK_CHILD_PI
|
|
45
|
+
* (the mock returns before the lifecycle-event handlers run), so a behavior
|
|
46
|
+
* test must target this helper rather than the full runChildPi path.
|
|
47
|
+
*/
|
|
48
|
+
export function redactStderrExcerpt(stderr: string, maxChars: number): string {
|
|
49
|
+
return redactSecretString(stderr.slice(-maxChars));
|
|
50
|
+
}
|
|
51
|
+
|
|
29
52
|
function appendBoundedTail(current: string, chunk: string, maxBytes = MAX_CAPTURE_BYTES): string {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
53
|
+
// Sprint 5: refactored onto TailCaptureStage (P0-A stage-chain). The marker
|
|
54
|
+
// embeds the cap size in KiB so the caller sees how much was dropped. Stage
|
|
55
|
+
// construction per call is cheap (4 fields) and avoids caching concerns.
|
|
56
|
+
return new TailCaptureStage({
|
|
57
|
+
maxBytes,
|
|
58
|
+
marker: `[pi-crew captured output truncated to last ${Math.round(maxBytes / 1024)} KiB]`,
|
|
59
|
+
}).apply(current + chunk);
|
|
35
60
|
}
|
|
36
61
|
|
|
37
62
|
function clearHardKillTimer(pid: number | undefined): void {
|
|
@@ -378,32 +403,56 @@ function appendTranscript(input: ChildPiRunInput, line: string): void {
|
|
|
378
403
|
}
|
|
379
404
|
}
|
|
380
405
|
|
|
381
|
-
function compactString(
|
|
406
|
+
export function compactString(
|
|
407
|
+
value: string,
|
|
408
|
+
maxChars = MAX_COMPACT_CONTENT_CHARS,
|
|
409
|
+
opts: { preserveImportant?: boolean } = {},
|
|
410
|
+
): string {
|
|
382
411
|
if (value.length <= maxChars) return value;
|
|
383
412
|
// L4: head + tail instead of head-only. Keeps closing markdown structure
|
|
384
413
|
// (code fences, headings, list tails) instead of dropping them — the old
|
|
385
414
|
// head-only slice left unclosed ``` fences that downstream parsers and
|
|
386
415
|
// output-validator.ts flagged as "output may be truncated". Head gets 75%
|
|
387
416
|
// (opening structure + bulk of content); tail gets 25% (closing structure).
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
417
|
+
// P0-A: compose the value through the stage-chain compression pipeline.
|
|
418
|
+
// The default pipeline is just [TruncationStage] (single-stage, equivalent
|
|
419
|
+
// to the pre-P0-A implementation) so plain text with no ANSI / no blank
|
|
420
|
+
// runs / no consecutive duplicates produces bit-identical output (L4
|
|
421
|
+
// regression safety). Callers that want noise stripping can opt into
|
|
422
|
+
// additional stages via the pipeline — but compactString's caller surface
|
|
423
|
+
// keeps the simple `(value, maxChars, opts)` signature.
|
|
424
|
+
// P0-B: the TruncationStage scans the middle slice for important diagnostic
|
|
425
|
+
// lines (error, file:line, HTTP 4xx/5xx, compiler codes) and preserves them
|
|
426
|
+
// within a 15% slack budget. The `preserveImportant` opt propagates here.
|
|
427
|
+
const result = applyCompactPipeline(value, [new TruncationStage(maxChars, { preserveImportant: opts.preserveImportant })]);
|
|
428
|
+
return result.text;
|
|
391
429
|
}
|
|
392
430
|
|
|
393
|
-
function compactValue(value: unknown): unknown {
|
|
431
|
+
export function compactValue(value: unknown): unknown {
|
|
394
432
|
if (typeof value === "string") return compactString(value);
|
|
395
|
-
if (Array.isArray(value))
|
|
433
|
+
if (Array.isArray(value)) {
|
|
434
|
+
// BUG-4: silent .slice(0, 20) lost items 21-50 with no marker.
|
|
435
|
+
// Append a truncation marker when entries are dropped so downstream
|
|
436
|
+
// consumers know data was elided (consistent with compactString style).
|
|
437
|
+
if (value.length > 20) {
|
|
438
|
+
return [...value.slice(0, 20).map(compactValue), `[pi-crew truncated ${value.length - 20} entries]`];
|
|
439
|
+
}
|
|
440
|
+
return value.map(compactValue);
|
|
441
|
+
}
|
|
396
442
|
const record = asRecord(value);
|
|
397
443
|
if (!record) return value;
|
|
444
|
+
const entries = Object.entries(record);
|
|
398
445
|
const compacted: Record<string, unknown> = {};
|
|
399
|
-
for (const [key, entry] of
|
|
446
|
+
for (const [key, entry] of entries.slice(0, 20)) compacted[key] = compactValue(entry);
|
|
447
|
+
// BUG-4: mark elided object keys so consumers know data was dropped.
|
|
448
|
+
if (entries.length > 20) compacted["[truncated]"] = `${entries.length - 20} entries`;
|
|
400
449
|
return compacted;
|
|
401
450
|
}
|
|
402
451
|
|
|
403
452
|
function compactContentPart(part: unknown): unknown | undefined {
|
|
404
453
|
const record = asRecord(part);
|
|
405
454
|
if (!record) return undefined;
|
|
406
|
-
if (record.type === "text") return { type: "text", text: typeof record.text === "string" ? compactString(record.text, MAX_ASSISTANT_TEXT_CHARS) : "" };
|
|
455
|
+
if (record.type === "text") return { type: "text", text: typeof record.text === "string" ? compactString(record.text, MAX_ASSISTANT_TEXT_CHARS, { preserveImportant: false }) : "" };
|
|
407
456
|
if (record.type === "toolCall") return { type: "toolCall", name: record.name, input: compactValue(typeof record.input === "string" ? compactString(record.input, MAX_TOOL_INPUT_CHARS) : record.input) };
|
|
408
457
|
if (record.type === "toolResult") return { type: "toolResult", name: record.name, content: compactValue(typeof record.content === "string" ? compactString(record.content, MAX_TOOL_RESULT_CHARS) : record.content) };
|
|
409
458
|
return undefined;
|
|
@@ -568,6 +617,55 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
568
617
|
return { exitCode: 0, stdout, stderr: "" };
|
|
569
618
|
}
|
|
570
619
|
if (mock === "retryable-failure") return { exitCode: 1, stdout: "", stderr: "[MOCK] rate limit: mock failure" };
|
|
620
|
+
// E2E fallback-chain fixture: invocation #1 returns a SILENT retryable
|
|
621
|
+
// failure (exit code 0, no real assistant text, message_end carries a
|
|
622
|
+
// retryable-pattern errorMessage). Invocation #2+ delegates to the
|
|
623
|
+
// standard json-success shape. Counter lives in os.tmpdir() keyed by
|
|
624
|
+
// process.pid + mock name so concurrent test processes don't collide.
|
|
625
|
+
// The test cleans up the file in its finally block.
|
|
626
|
+
if (mock === "retryable-failure-then-success") {
|
|
627
|
+
const counterFile = path.join(os.tmpdir(), `pi-crew-mock-counter-${process.pid}-retryable-failure-then-success`);
|
|
628
|
+
let count = 0;
|
|
629
|
+
try {
|
|
630
|
+
const raw = fs.readFileSync(counterFile, "utf-8");
|
|
631
|
+
const parsed = Number.parseInt(raw.trim(), 10);
|
|
632
|
+
if (Number.isFinite(parsed) && parsed >= 0) count = parsed;
|
|
633
|
+
} catch {
|
|
634
|
+
// file missing or unreadable — first invocation in this process
|
|
635
|
+
}
|
|
636
|
+
count += 1;
|
|
637
|
+
try {
|
|
638
|
+
fs.writeFileSync(counterFile, String(count));
|
|
639
|
+
} catch (error) {
|
|
640
|
+
logInternalError("child-pi.mock-counter-write", error as Error, `file=${counterFile}`);
|
|
641
|
+
}
|
|
642
|
+
if (count === 1) {
|
|
643
|
+
// Silent retryable failure: exit 0, no real text, message_end
|
|
644
|
+
// carries errorMessage matching `/provider[_ ]?error/i` so that
|
|
645
|
+
// `detectRetryableModelFailureFromOutput` surfaces it as an error
|
|
646
|
+
// and `isRetryableModelFailure` routes the next attempt to the
|
|
647
|
+
// next candidate model. `stopReason:"error"` (NOT "stop") so
|
|
648
|
+
// `isFinalAssistantEvent` does NOT prematurely terminate the run.
|
|
649
|
+
const failureEvent = {
|
|
650
|
+
type: "message_end",
|
|
651
|
+
message: {
|
|
652
|
+
role: "assistant",
|
|
653
|
+
content: [],
|
|
654
|
+
errorMessage: "Provider error: api_error",
|
|
655
|
+
stopReason: "error",
|
|
656
|
+
},
|
|
657
|
+
};
|
|
658
|
+
const stdout = `${JSON.stringify(failureEvent)}\n`;
|
|
659
|
+
observeStdoutChunk(input, stdout);
|
|
660
|
+
return { exitCode: 0, stdout, stderr: "" };
|
|
661
|
+
}
|
|
662
|
+
// Subsequent invocations: delegate to json-success shape so the
|
|
663
|
+
// fallback chain's second attempt succeeds and the run completes.
|
|
664
|
+
const text = `[MOCK] JSON success for ${input.agent.name}`;
|
|
665
|
+
const stdout = `${JSON.stringify({ type: "message", message: { role: "assistant", content: [{ type: "text", text }] } })}\n${JSON.stringify({ type: "message_end", usage: { input: 10, output: 5, cost: 0.001, turns: 1 } })}\n`;
|
|
666
|
+
observeStdoutChunk(input, stdout);
|
|
667
|
+
return { exitCode: 0, stdout, stderr: "" };
|
|
668
|
+
}
|
|
571
669
|
return { exitCode: 1, stdout: "", stderr: `[MOCK] failure: ${mock}` };
|
|
572
670
|
}
|
|
573
671
|
const built = buildPiWorkerArgs({ task: effectiveTask, agent: input.agent, model: input.model, sessionEnabled: true, maxDepth: input.maxDepth, skillPaths: input.skillPaths, role: input.role });
|
|
@@ -687,7 +785,9 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
687
785
|
noResponseTimer = setTimeout(() => {
|
|
688
786
|
responseTimeoutHit = true;
|
|
689
787
|
// Capture stderr at timeout moment for debugging
|
|
690
|
-
|
|
788
|
+
// SEC-1: redact secrets before embedding in lifecycle event so
|
|
789
|
+
// worker-emitted secrets (API keys etc.) don't bypass redaction.
|
|
790
|
+
const timeoutStderr = redactStderrExcerpt(stderr, 1024); // Last 1KB of stderr (redacted, SEC-1)
|
|
691
791
|
input.onLifecycleEvent?.({ type: "response_timeout", pid: child.pid, error: `No output for ${responseTimeoutMs}ms`, ts: new Date().toISOString(), stderr: timeoutStderr || undefined });
|
|
692
792
|
killProcessTree(child.pid, child);
|
|
693
793
|
try {
|
|
@@ -903,16 +1003,17 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
903
1003
|
});
|
|
904
1004
|
child.on("error", (error) => {
|
|
905
1005
|
// Reject pending operations with process error context
|
|
1006
|
+
// SEC-1: redact stderr secrets embedded in the error message + excerpt.
|
|
906
1007
|
const processError = new Error(
|
|
907
|
-
`Child Pi process error: ${error.message}. Stderr: ${stderr
|
|
1008
|
+
`Child Pi process error: ${error.message}. Stderr: ${redactStderrExcerpt(stderr, 500) || "(none)"}`,
|
|
908
1009
|
);
|
|
909
1010
|
rejectPendingOperations(processError);
|
|
910
1011
|
try {
|
|
911
|
-
input.onLifecycleEvent?.({ type: "spawn_error", pid: child.pid, error: processError.message, ts: new Date().toISOString(), stderrExcerpt: stderr
|
|
1012
|
+
input.onLifecycleEvent?.({ type: "spawn_error", pid: child.pid, error: processError.message, ts: new Date().toISOString(), stderrExcerpt: redactStderrExcerpt(stderr, 500) || undefined });
|
|
912
1013
|
} catch (err) {
|
|
913
1014
|
logInternalError("child-pi.on-lifecycle-event", err, `event=error, pid=${child.pid}`);
|
|
914
1015
|
}
|
|
915
|
-
settle({ exitCode: null, stdout, stderr, error: processError.message });
|
|
1016
|
+
settle({ exitCode: null, stdout, stderr, error: processError.message, exitStatus: { exitCode: null, cancelled: abortRequested, timedOut: responseTimeoutHit, killed: false, cleanupErrors, finalDrainMs, crashClass: classifyProcessCrash({ exitCode: null, cancelled: abortRequested, timedOut: responseTimeoutHit, spawnError: error, stderrSnippet: stderr ? redactStderrExcerpt(stderr, 1000) : undefined }).crashClass } });
|
|
916
1017
|
});
|
|
917
1018
|
child.on("exit", (code, signal) => {
|
|
918
1019
|
if (child.pid) {
|
|
@@ -931,7 +1032,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
931
1032
|
const exitError = isUnexpectedExit
|
|
932
1033
|
? new Error(
|
|
933
1034
|
`Child Pi process exited unexpectedly (code=${code ?? "null"} signal=${signal ?? "null"}). `
|
|
934
|
-
+ `Stderr: ${stderr
|
|
1035
|
+
+ `Stderr: ${redactStderrExcerpt(stderr, 1000) || "(none)"}`,
|
|
935
1036
|
)
|
|
936
1037
|
: null;
|
|
937
1038
|
if (exitError) {
|
|
@@ -947,7 +1048,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
947
1048
|
exitCode: code,
|
|
948
1049
|
ts: new Date().toISOString(),
|
|
949
1050
|
error: exitError?.message,
|
|
950
|
-
stderrExcerpt: isUnexpectedExit ? stderr
|
|
1051
|
+
stderrExcerpt: isUnexpectedExit ? redactStderrExcerpt(stderr, 1000) || undefined : undefined,
|
|
951
1052
|
// Phase-0 diagnostic fields (kept optional — no type change required).
|
|
952
1053
|
...(signal ? { signal } : {}),
|
|
953
1054
|
...(finalDrainArmed || forcedFinalDrain
|
|
@@ -987,7 +1088,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
987
1088
|
} catch (err) {
|
|
988
1089
|
logInternalError("child-pi.on-lifecycle-event", err, `event=close, pid=${child.pid}`);
|
|
989
1090
|
}
|
|
990
|
-
const timeoutError = responseTimeoutHit && !stderr.trim() ? { error: `Child Pi produced no new output for ${responseTimeoutMs}ms; process was terminated as unresponsive.` } : responseTimeoutHit && stderr.trim() ? { error: `Child Pi timed out after ${responseTimeoutMs}ms with stderr: ${stderr
|
|
1091
|
+
const timeoutError = responseTimeoutHit && !stderr.trim() ? { error: `Child Pi produced no new output for ${responseTimeoutMs}ms; process was terminated as unresponsive.` } : responseTimeoutHit && stderr.trim() ? { error: `Child Pi timed out after ${responseTimeoutMs}ms with stderr: ${redactStderrExcerpt(stderr, 500)}` } : undefined;
|
|
991
1092
|
// M6 fix: log when forced final drain converts non-zero exit to 0.
|
|
992
1093
|
// This is expected in normal operation (child finished cleanly but linger was killed),
|
|
993
1094
|
// but the telemetry helps detect regressions where crashes are hidden.
|
|
@@ -1001,7 +1102,19 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
1001
1102
|
// is logged, not fatal). The steerError branch is retained for safety in
|
|
1002
1103
|
// case a future change reintroduces a fatal steer path.
|
|
1003
1104
|
const steerError = steerInjectionFailed ? "Steer injection failed due to stdin backpressure; process killed" : undefined;
|
|
1004
|
-
|
|
1105
|
+
// P0 crash taxonomy: classify the exit so callers/dashboards can bucket
|
|
1106
|
+
// failure modes (timeout vs cancel vs native panic vs signal …).
|
|
1107
|
+
// The classifier is a pure function; this is the single integration point.
|
|
1108
|
+
const crashClassification = classifyProcessCrash({
|
|
1109
|
+
exitCode: finalExitCode,
|
|
1110
|
+
signal: child.signalCode ?? undefined,
|
|
1111
|
+
cancelled: abortRequested,
|
|
1112
|
+
timedOut: responseTimeoutHit,
|
|
1113
|
+
killed: hardKilled,
|
|
1114
|
+
spawnError: undefined,
|
|
1115
|
+
stderrSnippet: stderr ? redactStderrExcerpt(stderr, 1000) : undefined,
|
|
1116
|
+
});
|
|
1117
|
+
settle({ exitCode: finalExitCode, stdout, stderr, ...(timeoutError ? { error: timeoutError.error } : {}), ...(steerError ? { error: steerError } : {}), aborted: wasGraceAborted || wasParentAborted, steered: softLimitReached && !wasGraceAborted, exitStatus: { exitCode: finalExitCode, cancelled: abortRequested, timedOut: responseTimeoutHit, killed: hardKilled, cleanupErrors, finalDrainMs, crashClass: crashClassification.crashClass } });
|
|
1005
1118
|
});
|
|
1006
1119
|
});
|
|
1007
1120
|
} finally {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stage-chain compression pipeline (P0-A).
|
|
3
|
+
*
|
|
4
|
+
* Composable, monotonic-shrink-safe text compression. Each stage declares an
|
|
5
|
+
* `id` and an `apply(text): string` method. The pipeline runs stages in
|
|
6
|
+
* order, applying each stage's output ONLY if it is no longer than the
|
|
7
|
+
* stage's input. This is the safety property that prevents the family of
|
|
8
|
+
* bugs the old L4 caveman-shrink refactor surfaced (24/27 artifacts corrupted
|
|
9
|
+
* with null bytes because a regex-based shrink expanded its input in some
|
|
10
|
+
* cases — knowledge.md "L4 output-handling"). With the monotonic-shrink gate,
|
|
11
|
+
* a buggy stage implementation can NEVER cause output growth, and therefore
|
|
12
|
+
* cannot corrupt downstream structure.
|
|
13
|
+
*
|
|
14
|
+
* Ported from Hypa's `src/Hypa.Infrastructure/Compression/GenericOutputCompressor.cs`
|
|
15
|
+
* (stage loop with `if (next.Length <= text.Length)` gate).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface ICompactStage {
|
|
19
|
+
/** Stable identifier; surfaced in `PipelineResult.applied` for observability. */
|
|
20
|
+
readonly id: string;
|
|
21
|
+
/**
|
|
22
|
+
* Transform `text`. MUST be pure (no side effects, deterministic for a
|
|
23
|
+
* given input). MAY return the input unchanged when nothing to do — the
|
|
24
|
+
* pipeline will skip it via the monotonic-shrink gate regardless, but
|
|
25
|
+
* returning the same string keeps `applied` honest.
|
|
26
|
+
*/
|
|
27
|
+
apply(text: string): string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PipelineResult {
|
|
31
|
+
text: string;
|
|
32
|
+
/** ids of stages whose output was accepted (shorter-or-equal than their input). */
|
|
33
|
+
applied: readonly string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Run `stages` in order. Each stage is applied only if its output is no
|
|
38
|
+
* longer than its current input. The pipeline NEVER expands text — if a
|
|
39
|
+
* stage would expand, it is silently skipped (its id is not added to
|
|
40
|
+
* `applied`).
|
|
41
|
+
*/
|
|
42
|
+
export function applyCompactPipeline(text: string, stages: readonly ICompactStage[]): PipelineResult {
|
|
43
|
+
let current = text;
|
|
44
|
+
const applied: string[] = [];
|
|
45
|
+
for (const stage of stages) {
|
|
46
|
+
if (!stage || typeof stage.apply !== "function") continue; // defensive: skip malformed entries
|
|
47
|
+
const next = stage.apply(current);
|
|
48
|
+
if (typeof next !== "string") continue; // defensive: skip non-string output
|
|
49
|
+
if (next.length <= current.length) {
|
|
50
|
+
current = next;
|
|
51
|
+
applied.push(stage.id);
|
|
52
|
+
}
|
|
53
|
+
// else: stage attempted to expand input — silently drop (monotonic-shrink gate).
|
|
54
|
+
}
|
|
55
|
+
return { text: current, applied };
|
|
56
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AnsiStripStage — strip ANSI CSI escape sequences.
|
|
3
|
+
*
|
|
4
|
+
* Matches the common CSI pattern: ESC `[` followed by parameter bytes
|
|
5
|
+
* (0-9 ; ?), intermediate bytes (space - /), and a final byte (@-~).
|
|
6
|
+
* Sufficient for the color/cursor codes emitted by npm, cargo, jest, etc.
|
|
7
|
+
* Does not attempt to handle OSC / DCS / private modes (rare in CLI output
|
|
8
|
+
* captured into artifacts; can be added later if real-world signal emerges).
|
|
9
|
+
*
|
|
10
|
+
* Idempotent (no ANSI in → no change; ANSI in → ANSI out).
|
|
11
|
+
*/
|
|
12
|
+
import type { ICompactStage } from "../compact-pipeline.ts";
|
|
13
|
+
|
|
14
|
+
// CSI: ESC [ <params 0-9;> <intermediates space-/ > <final @-~>
|
|
15
|
+
const ANSI_CSI_PATTERN = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
16
|
+
|
|
17
|
+
export class AnsiStripStage implements ICompactStage {
|
|
18
|
+
readonly id = "ansi-strip";
|
|
19
|
+
apply(text: string): string {
|
|
20
|
+
if (text.indexOf("\x1b") === -1) return text; // fast path: no ESC at all
|
|
21
|
+
return text.replace(ANSI_CSI_PATTERN, "");
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const ANSI_STRIP_STAGE = new AnsiStripStage();
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BlankCollapseStage — collapse runs of 3+ consecutive newlines to a single
|
|
3
|
+
* blank line (i.e., 2 newlines).
|
|
4
|
+
*
|
|
5
|
+
* Reduces whitespace noise in long command output (npm install, cargo build,
|
|
6
|
+
* jest, etc. frequently emit blocks of blank lines between sections). Does
|
|
7
|
+
* NOT touch 1 or 2 consecutive newlines — those are legitimate paragraph
|
|
8
|
+
* breaks in prose.
|
|
9
|
+
*
|
|
10
|
+
* Idempotent (already-collapsed input → unchanged).
|
|
11
|
+
*/
|
|
12
|
+
import type { ICompactStage } from "../compact-pipeline.ts";
|
|
13
|
+
|
|
14
|
+
export class BlankCollapseStage implements ICompactStage {
|
|
15
|
+
readonly id = "blank-collapse";
|
|
16
|
+
// NOTE: deliberately NOT using parameter-property shorthand here because
|
|
17
|
+
// Node's --experimental-strip-types does not support it. Field + ctor
|
|
18
|
+
// assignment is the portable shape.
|
|
19
|
+
private readonly minConsecutive: number;
|
|
20
|
+
constructor(minConsecutive = 3) {
|
|
21
|
+
this.minConsecutive = minConsecutive;
|
|
22
|
+
}
|
|
23
|
+
apply(text: string): string {
|
|
24
|
+
if (this.minConsecutive < 2) return text;
|
|
25
|
+
// {minConsecutive,} matches minConsecutive or more; replace with "\n\n" (one blank line).
|
|
26
|
+
const pattern = new RegExp(`\\n{${this.minConsecutive},}`, "g");
|
|
27
|
+
return text.replace(pattern, "\n\n");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const BLANK_COLLAPSE_STAGE = new BlankCollapseStage();
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeduplicateStage — collapse CONSECUTIVE duplicate lines into one.
|
|
3
|
+
*
|
|
4
|
+
* Useful for log output where the same line repeats (retry attempts, poll
|
|
5
|
+
* loops, etc.). Only collapses CONSECUTIVE duplicates — non-adjacent
|
|
6
|
+
* repetitions are kept (they may be legitimately repeated later). Does NOT
|
|
7
|
+
* touch whitespace-only differences.
|
|
8
|
+
*
|
|
9
|
+
* Idempotent.
|
|
10
|
+
*
|
|
11
|
+
* SAFETY: do NOT enable this stage on assistant prose. "I I I went to the
|
|
12
|
+
* store" would lose emphasis. compactString's default pipeline does NOT
|
|
13
|
+
* include this stage for that reason; it is opt-in only.
|
|
14
|
+
*/
|
|
15
|
+
import type { ICompactStage } from "../compact-pipeline.ts";
|
|
16
|
+
|
|
17
|
+
export class DeduplicateStage implements ICompactStage {
|
|
18
|
+
readonly id = "deduplicate";
|
|
19
|
+
apply(text: string): string {
|
|
20
|
+
if (text.length === 0) return text;
|
|
21
|
+
const lines = text.split(/\r?\n/);
|
|
22
|
+
if (lines.length < 2) return text;
|
|
23
|
+
const out: string[] = [lines[0]!];
|
|
24
|
+
for (let i = 1; i < lines.length; i++) {
|
|
25
|
+
const cur = lines[i]!;
|
|
26
|
+
if (cur !== out[out.length - 1]) out.push(cur);
|
|
27
|
+
}
|
|
28
|
+
// Preserve original line ending style: if input used \r\n, restore that.
|
|
29
|
+
const sep = text.includes("\r\n") ? "\r\n" : "\n";
|
|
30
|
+
return out.join(sep);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const DEDUPLICATE_STAGE = new DeduplicateStage();
|