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
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HeadSnapStage — keep the first N bytes of the input, optionally snapping to
|
|
3
|
+
* the last newline within that region for clean line boundaries.
|
|
4
|
+
*
|
|
5
|
+
* Distinct from TruncationStage (head + important-middle + tail, P0-B / P0-A):
|
|
6
|
+
* this stage is pure head-only with optional newline-snap, used by the
|
|
7
|
+
* iteration-hooks hook-output capture where the goal is "first N bytes
|
|
8
|
+
* snapped to a clean line" rather than head + tail.
|
|
9
|
+
*
|
|
10
|
+
* Use case in pi-crew:
|
|
11
|
+
* - `iteration-hooks.ts` truncateToLimit — hook stdout capture capped at
|
|
12
|
+
* MAX_STDOUT_BYTES (8KB), snapped to the last newline in the head region
|
|
13
|
+
* so partial lines never appear in the captured preview.
|
|
14
|
+
*
|
|
15
|
+
* Byte cap (not char cap) to preserve the original memory budget semantic:
|
|
16
|
+
* the input is converted from Buffer to string once, then this stage ensures
|
|
17
|
+
* the output never exceeds the byte cap by walking back any partial UTF-8
|
|
18
|
+
* sequence at the cut boundary.
|
|
19
|
+
*/
|
|
20
|
+
import type { ICompactStage } from "../compact-pipeline.ts";
|
|
21
|
+
|
|
22
|
+
export interface HeadSnapStageConfig {
|
|
23
|
+
/** Maximum output size in bytes. */
|
|
24
|
+
maxBytes: number;
|
|
25
|
+
/** When true, snap the cut to the last newline within the head region. */
|
|
26
|
+
snapToNewline?: boolean;
|
|
27
|
+
/** Optional explicit id; defaults to "head-snap". */
|
|
28
|
+
id?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class HeadSnapStage implements ICompactStage {
|
|
32
|
+
readonly id: string;
|
|
33
|
+
private readonly maxBytes: number;
|
|
34
|
+
private readonly snapToNewline: boolean;
|
|
35
|
+
constructor(config: HeadSnapStageConfig) {
|
|
36
|
+
if (!Number.isFinite(config.maxBytes) || config.maxBytes <= 0) {
|
|
37
|
+
throw new Error(`HeadSnapStage: maxBytes must be a positive finite number, got ${config.maxBytes}`);
|
|
38
|
+
}
|
|
39
|
+
this.maxBytes = config.maxBytes;
|
|
40
|
+
this.snapToNewline = config.snapToNewline !== false;
|
|
41
|
+
this.id = config.id ?? "head-snap";
|
|
42
|
+
}
|
|
43
|
+
apply(text: string): string {
|
|
44
|
+
if (Buffer.byteLength(text, "utf-8") <= this.maxBytes) return text;
|
|
45
|
+
// Approximate: slice by char count, then walk back any partial UTF-8
|
|
46
|
+
// sequence to keep byte-length <= maxBytes.
|
|
47
|
+
let slice = text.slice(0, this.maxBytes);
|
|
48
|
+
while (Buffer.byteLength(slice, "utf-8") > this.maxBytes) {
|
|
49
|
+
slice = slice.slice(0, slice.length - 1);
|
|
50
|
+
}
|
|
51
|
+
if (this.snapToNewline) {
|
|
52
|
+
const lastNewline = slice.lastIndexOf("\n");
|
|
53
|
+
if (lastNewline >= 0) return slice.slice(0, lastNewline);
|
|
54
|
+
}
|
|
55
|
+
return slice;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Barrel re-exports for the compact-stages module.
|
|
3
|
+
*
|
|
4
|
+
* Callers import from `../../runtime/compact-stages/index.ts` (or just
|
|
5
|
+
* `../../runtime/compact-stages/`) rather than reaching into individual
|
|
6
|
+
* stage files, so internal refactors do not break the public surface.
|
|
7
|
+
*/
|
|
8
|
+
export { AnsiStripStage, ANSI_STRIP_STAGE } from "./ansi-strip-stage.ts";
|
|
9
|
+
export { BlankCollapseStage, BLANK_COLLAPSE_STAGE } from "./blank-collapse-stage.ts";
|
|
10
|
+
export { DeduplicateStage, DEDUPLICATE_STAGE } from "./deduplicate-stage.ts";
|
|
11
|
+
export { TruncationStage, type TruncationMarkerConfig } from "./truncation-stage.ts";
|
|
12
|
+
export { HeadSnapStage, type HeadSnapStageConfig } from "./head-snap-stage.ts";
|
|
13
|
+
export { TailCaptureStage, TAIL_CAPTURE_STREAM_STAGE, type TailCaptureStageConfig } from "./tail-capture-stage.ts";
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TailCaptureStage — keep the last N characters/bytes of the input, prepend
|
|
3
|
+
* an optional marker when truncation fires.
|
|
4
|
+
*
|
|
5
|
+
* Distinct from TruncationStage (head + important-middle + tail, P0-B / P0-A):
|
|
6
|
+
* this stage is pure tail-capture, used by streaming accumulators that need to
|
|
7
|
+
* keep the most recent N chars/bytes and drop the oldest. No important-line
|
|
8
|
+
* preservation, no head — just the tail + optional marker.
|
|
9
|
+
*
|
|
10
|
+
* Use cases in pi-crew:
|
|
11
|
+
* - `appendBoundedTail` (child-pi.ts) — stdout/stderr streaming accumulator
|
|
12
|
+
* with byte cap and a `[pi-crew captured output truncated to last X KiB]`
|
|
13
|
+
* marker.
|
|
14
|
+
* - `stream-preview.ts` textBuffer — incremental text buffer for the live UI
|
|
15
|
+
* preview, char cap, NO marker (the UI shows raw text without a prefix).
|
|
16
|
+
*
|
|
17
|
+
* Two cap modes:
|
|
18
|
+
* - `maxChars`: character-based cap (UTF-8 safe by definition).
|
|
19
|
+
* - `maxBytes`: byte-based cap (legacy, used when memory budget matters
|
|
20
|
+
* more than UTF-8 safety). The tail is snapped to the last byte that
|
|
21
|
+
* keeps the result ≤ maxBytes to avoid splitting a multi-byte sequence.
|
|
22
|
+
*/
|
|
23
|
+
import type { ICompactStage } from "../compact-pipeline.ts";
|
|
24
|
+
|
|
25
|
+
export interface TailCaptureStageConfig {
|
|
26
|
+
/** Character cap (UTF-8 safe). Mutually exclusive with maxBytes. */
|
|
27
|
+
maxChars?: number;
|
|
28
|
+
/** Byte cap (legacy, used by streaming accumulators). Mutually exclusive with maxChars. */
|
|
29
|
+
maxBytes?: number;
|
|
30
|
+
/** Marker prepended (with a newline separator) when truncation fires. Empty string = no marker. */
|
|
31
|
+
marker?: string;
|
|
32
|
+
/** Optional explicit id; defaults to "tail-capture" (or "tail-capture-stream" if maxBytes mode). */
|
|
33
|
+
id?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class TailCaptureStage implements ICompactStage {
|
|
37
|
+
readonly id: string;
|
|
38
|
+
private readonly maxChars: number | undefined;
|
|
39
|
+
private readonly maxBytes: number | undefined;
|
|
40
|
+
private readonly marker: string;
|
|
41
|
+
constructor(config: TailCaptureStageConfig) {
|
|
42
|
+
const hasChars = typeof config.maxChars === "number";
|
|
43
|
+
const hasBytes = typeof config.maxBytes === "number";
|
|
44
|
+
if (hasChars === hasBytes) {
|
|
45
|
+
throw new Error(`TailCaptureStage requires exactly one of maxChars or maxBytes (got chars=${config.maxChars} bytes=${config.maxBytes})`);
|
|
46
|
+
}
|
|
47
|
+
if (hasChars && (config.maxChars as number) <= 0) throw new Error(`TailCaptureStage: maxChars must be > 0, got ${config.maxChars}`);
|
|
48
|
+
if (hasBytes && (config.maxBytes as number) <= 0) throw new Error(`TailCaptureStage: maxBytes must be > 0, got ${config.maxBytes}`);
|
|
49
|
+
this.maxChars = config.maxChars;
|
|
50
|
+
this.maxBytes = config.maxBytes;
|
|
51
|
+
this.marker = config.marker ?? "";
|
|
52
|
+
this.id = config.id ?? (hasBytes ? "tail-capture" : "tail-capture");
|
|
53
|
+
}
|
|
54
|
+
apply(text: string): string {
|
|
55
|
+
if (this.maxBytes !== undefined) {
|
|
56
|
+
// Byte cap mode — snap tail to a UTF-8 char boundary so the result
|
|
57
|
+
// never contains a partial multi-byte sequence.
|
|
58
|
+
if (Buffer.byteLength(text, "utf-8") <= this.maxBytes) return text;
|
|
59
|
+
let tail = text.slice(Math.max(0, text.length - this.maxBytes));
|
|
60
|
+
while (Buffer.byteLength(tail, "utf-8") > this.maxBytes) tail = tail.slice(1024);
|
|
61
|
+
return this.marker ? `${this.marker}\n${tail}` : tail;
|
|
62
|
+
}
|
|
63
|
+
// Char cap mode.
|
|
64
|
+
const max = this.maxChars as number;
|
|
65
|
+
if (text.length <= max) return text;
|
|
66
|
+
const tail = text.slice(text.length - max);
|
|
67
|
+
return this.marker ? `${this.marker}\n${tail}` : tail;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Singleton: char-cap tail capture with no marker (for `stream-preview.ts` textBuffer). */
|
|
72
|
+
export const TAIL_CAPTURE_STREAM_STAGE = new TailCaptureStage({ maxChars: 16_384, id: "tail-capture-stream" });
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TruncationStage — head(75%) + important-middle + tail(25%) compression.
|
|
3
|
+
*
|
|
4
|
+
* Wraps the head/tail/important-line split (from P0-B's `important-line-classifier.ts`)
|
|
5
|
+
* as a pipeline stage so it composes with other stages (ANSI strip, blank
|
|
6
|
+
* collapse, etc.). When the input is at or below `maxChars`, returns the
|
|
7
|
+
* input unchanged (idempotent — the pipeline gate then marks this stage as
|
|
8
|
+
* a no-op).
|
|
9
|
+
*
|
|
10
|
+
* Marker wording is parameterized so the SAME stage serves both `compactString`
|
|
11
|
+
* ("compacted ... chars") and `readIfSmall` ("truncated ... chars") with
|
|
12
|
+
* their distinct separators. Defaults match `compactString`'s pre-P0-A output
|
|
13
|
+
* exactly so that callers that do not opt into additional stages get
|
|
14
|
+
* bit-identical output (L4 backward-compat safety).
|
|
15
|
+
*/
|
|
16
|
+
import type { ICompactStage } from "../compact-pipeline.ts";
|
|
17
|
+
import { splitWithImportantLines } from "../important-line-classifier.ts";
|
|
18
|
+
|
|
19
|
+
export interface TruncationMarkerConfig {
|
|
20
|
+
/** "compacted" (compactString default) or "truncated" (readIfSmall default). */
|
|
21
|
+
verb: "compacted" | "truncated";
|
|
22
|
+
/** Unit reported in the marker. Both callers currently use "chars" post-Sprint 1. */
|
|
23
|
+
unit: "chars" | "bytes";
|
|
24
|
+
/** Newline(s) between `head` and the marker line. compactString uses "\n"; readIfSmall uses "\n\n". */
|
|
25
|
+
headSeparator: string;
|
|
26
|
+
/** Newline(s) between the marker (or joined important lines) and `tail`. Both callers use "\n". */
|
|
27
|
+
tailSeparator: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const DEFAULT_MARKER: TruncationMarkerConfig = {
|
|
31
|
+
verb: "compacted",
|
|
32
|
+
unit: "chars",
|
|
33
|
+
headSeparator: "\n",
|
|
34
|
+
tailSeparator: "\n",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export class TruncationStage implements ICompactStage {
|
|
38
|
+
readonly id = "truncation";
|
|
39
|
+
private readonly maxChars: number;
|
|
40
|
+
private readonly preserveImportant: boolean;
|
|
41
|
+
private readonly marker: TruncationMarkerConfig;
|
|
42
|
+
constructor(
|
|
43
|
+
maxChars: number,
|
|
44
|
+
opts: { preserveImportant?: boolean; marker?: Partial<TruncationMarkerConfig> } = {},
|
|
45
|
+
) {
|
|
46
|
+
if (!Number.isFinite(maxChars) || maxChars <= 0) {
|
|
47
|
+
throw new Error(`TruncationStage: maxChars must be a positive finite number, got ${maxChars}`);
|
|
48
|
+
}
|
|
49
|
+
this.maxChars = maxChars;
|
|
50
|
+
this.preserveImportant = opts.preserveImportant !== false;
|
|
51
|
+
this.marker = { ...DEFAULT_MARKER, ...(opts.marker ?? {}) };
|
|
52
|
+
}
|
|
53
|
+
apply(text: string): string {
|
|
54
|
+
if (text.length <= this.maxChars) return text;
|
|
55
|
+
const { head, tail, importantLines, baseDropped } = splitWithImportantLines(text, this.maxChars, {
|
|
56
|
+
preserveImportant: this.preserveImportant,
|
|
57
|
+
});
|
|
58
|
+
let result: string;
|
|
59
|
+
if (importantLines.length === 0) {
|
|
60
|
+
result = `${head}${this.marker.headSeparator}...[pi-crew ${this.marker.verb} ${baseDropped} ${this.marker.unit}, head+tail preserved]...${this.marker.tailSeparator}${tail}`;
|
|
61
|
+
} else {
|
|
62
|
+
const joined = importantLines.join("\n");
|
|
63
|
+
const remaining = text.length - head.length - tail.length - joined.length;
|
|
64
|
+
result = `${head}${this.marker.headSeparator}...[pi-crew ${this.marker.verb} ${baseDropped} ${this.marker.unit}, head+tail + ${importantLines.length} important lines preserved, ${remaining} ${this.marker.unit} remaining dropped]...\n${joined}${this.marker.tailSeparator}${tail}`;
|
|
65
|
+
}
|
|
66
|
+
// Defense-in-depth: this stage's own monotonic-shrink invariant. The
|
|
67
|
+
// pipeline gate is a SECOND line of defense.
|
|
68
|
+
if (result.length >= text.length) return text;
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crash Classification Taxonomy — pure function for categorizing worker exits.
|
|
3
|
+
*
|
|
4
|
+
* Distilled from gajae-code's `debug/crash-diagnostics.ts` (P0 item #1).
|
|
5
|
+
* Unlike the original, this module is pure: it does NOT write crash reports or
|
|
6
|
+
* touch the filesystem. The file-I/O layer is intentionally omitted; callers
|
|
7
|
+
* that want durable crash logs can layer them on top of {@link classifyProcessCrash}.
|
|
8
|
+
*
|
|
9
|
+
* The classification precedence (most-significant first) mirrors the
|
|
10
|
+
* reference implementation:
|
|
11
|
+
*
|
|
12
|
+
* 1. timeout — process was terminated by the response-timeout guard
|
|
13
|
+
* 2. cancelled — cooperative cancellation (AbortSignal) triggered exit
|
|
14
|
+
* 3. spawn_error — child_process emitted an `error` event before `exit`
|
|
15
|
+
* 4. native_panic — stderr indicates a native crash (SIGSEGV / abort / panic)
|
|
16
|
+
* 5. signal_exit — the process was terminated by an OS signal
|
|
17
|
+
* 6. clean_exit — exit code 0
|
|
18
|
+
* 7. non_zero_exit — exit code != 0 (and != null)
|
|
19
|
+
* 8. protocol_exit — exit code is null with no signal (protocol/stream
|
|
20
|
+
* ended before a normal exit was observed)
|
|
21
|
+
* 9. unknown — defensive fallback (should not occur in practice)
|
|
22
|
+
*
|
|
23
|
+
* NOTE on timeout-vs-cancel precedence: when BOTH `timedOut` and `cancelled`
|
|
24
|
+
* are true, `timeout` wins (the timeout terminated the process). This matches
|
|
25
|
+
* gajae-code and the existing child-pi.ts response-timeout guard, which fires
|
|
26
|
+
* the hard kill and is the proximate cause.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Categorical classification of why a worker process ended.
|
|
31
|
+
*
|
|
32
|
+
* @see classifyProcessCrash
|
|
33
|
+
*/
|
|
34
|
+
export type CrashClass =
|
|
35
|
+
| "clean_exit"
|
|
36
|
+
| "non_zero_exit"
|
|
37
|
+
| "signal_exit"
|
|
38
|
+
| "timeout"
|
|
39
|
+
| "cancelled"
|
|
40
|
+
| "spawn_error"
|
|
41
|
+
| "protocol_exit"
|
|
42
|
+
| "native_panic"
|
|
43
|
+
| "unknown";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Inputs to {@link classifyProcessCrash}. All fields are optional/safe-defaulting
|
|
47
|
+
* so callers can pass a partial view (e.g. just `{ exitCode: 0 }`).
|
|
48
|
+
*
|
|
49
|
+
* Field semantics:
|
|
50
|
+
* - `exitCode` — the OS exit code, or `null` when no code was observed.
|
|
51
|
+
* - `signal` — the terminating signal name (e.g. `"SIGTERM"`) or `null`.
|
|
52
|
+
* - `cancelled` — true when cooperative cancellation (AbortSignal) was requested.
|
|
53
|
+
* - `timedOut` — true when the response-timeout guard fired (and likely killed).
|
|
54
|
+
* - `killed` — true when the parent explicitly killed the child (best-effort).
|
|
55
|
+
* - `spawnError` — truthy when the child emitted a spawn/process `error` event.
|
|
56
|
+
* - `stderrSnippet` — tail of captured stderr, used to detect native panics.
|
|
57
|
+
*/
|
|
58
|
+
export interface CrashClassificationInput {
|
|
59
|
+
exitCode?: number | null;
|
|
60
|
+
signal?: string | null;
|
|
61
|
+
cancelled?: boolean;
|
|
62
|
+
timedOut?: boolean;
|
|
63
|
+
killed?: boolean;
|
|
64
|
+
spawnError?: unknown;
|
|
65
|
+
stderrSnippet?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Result of classifying an exit. `crashClass` is machine-readable;
|
|
70
|
+
* `reason` is a human-friendly one-liner suitable for logs/diagnostics.
|
|
71
|
+
*/
|
|
72
|
+
export interface CrashClassification {
|
|
73
|
+
crashClass: CrashClass;
|
|
74
|
+
reason: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── native-panic detection ──────────────────────────────────────────────────
|
|
78
|
+
//
|
|
79
|
+
// We look for a small, well-known set of native-crash signatures in the stderr
|
|
80
|
+
// tail. This is deliberately conservative: false positives would mislabel
|
|
81
|
+
// ordinary non-zero exits as native panics. The patterns are anchored on
|
|
82
|
+
// substrings that do not appear in normal application output.
|
|
83
|
+
|
|
84
|
+
interface NativePanicSignature {
|
|
85
|
+
/** Substring to search for (case-insensitive). */
|
|
86
|
+
pattern: string;
|
|
87
|
+
/** Human-readable class-specific reason suffix. */
|
|
88
|
+
label: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const NATIVE_PANIC_SIGNATURES: readonly NativePanicSignature[] = [
|
|
92
|
+
{ pattern: "sigsegv", label: "segmentation fault" },
|
|
93
|
+
{ pattern: "segfault", label: "segmentation fault" },
|
|
94
|
+
{ pattern: "segmentation fault", label: "segmentation fault" },
|
|
95
|
+
{ pattern: "sigabrt", label: "abort signal" },
|
|
96
|
+
{ pattern: "abort(", label: "abort" },
|
|
97
|
+
{ pattern: "fatal error", label: "V8/node fatal error" },
|
|
98
|
+
{ pattern: "panic:", label: "rust/go panic" },
|
|
99
|
+
{ pattern: "thread '", label: "rust panic (thread context)" },
|
|
100
|
+
{ pattern: "illegal instruction", label: "illegal instruction" },
|
|
101
|
+
{ pattern: "double free", label: "heap corruption (double free)" },
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* If the stderr tail contains a recognizable native-crash signature, return the
|
|
106
|
+
* matching label; otherwise `null`. Case-insensitive.
|
|
107
|
+
*/
|
|
108
|
+
function detectNativePanic(stderrSnippet: string | undefined): string | null {
|
|
109
|
+
if (!stderrSnippet) return null;
|
|
110
|
+
const lower = stderrSnippet.toLowerCase();
|
|
111
|
+
for (const sig of NATIVE_PANIC_SIGNATURES) {
|
|
112
|
+
if (lower.includes(sig.pattern)) return sig.label;
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Normalize an optional/signal-ish value to `string | null`. */
|
|
118
|
+
function normalizeSignal(signal: string | null | undefined): string | null {
|
|
119
|
+
return signal ?? null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Classify a worker exit into a {@link CrashClass}.
|
|
124
|
+
*
|
|
125
|
+
* Pure: no I/O, no globals, no side effects. Deterministic given the same input.
|
|
126
|
+
* Safe to call from any context (including signal handlers).
|
|
127
|
+
*
|
|
128
|
+
* @example
|
|
129
|
+
* classifyProcessCrash({ exitCode: 0 }) // → clean_exit
|
|
130
|
+
* classifyProcessCrash({ exitCode: 1 }) // → non_zero_exit
|
|
131
|
+
* classifyProcessCrash({ signal: "SIGTERM" }) // → signal_exit
|
|
132
|
+
* classifyProcessCrash({ timedOut: true, exitCode: null }) // → timeout
|
|
133
|
+
* classifyProcessCrash({ cancelled: true, exitCode: null }) // → cancelled
|
|
134
|
+
* classifyProcessCrash({ spawnError: new Error("ENOENT") }) // → spawn_error
|
|
135
|
+
* classifyProcessCrash({ exitCode: null }) // → protocol_exit
|
|
136
|
+
* classifyProcessCrash({ exitCode: 139, signal: "SIGSEGV" }) // → signal_exit
|
|
137
|
+
* classifyProcessCrash({ exitCode: 134, stderrSnippet: "abort()" }) // → native_panic
|
|
138
|
+
*/
|
|
139
|
+
export function classifyProcessCrash(input: CrashClassificationInput): CrashClassification {
|
|
140
|
+
const exitCode = input.exitCode ?? null;
|
|
141
|
+
const signal = normalizeSignal(input.signal);
|
|
142
|
+
|
|
143
|
+
// 1. Timeout takes precedence: the response-timeout guard is the proximate
|
|
144
|
+
// cause of death even if cancellation was also requested.
|
|
145
|
+
if (input.timedOut) {
|
|
146
|
+
return { crashClass: "timeout", reason: "process timed out (response timeout guard fired)" };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 2. Cooperative cancellation.
|
|
150
|
+
if (input.cancelled) {
|
|
151
|
+
return { crashClass: "cancelled", reason: "process was cancelled (abort requested)" };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 3. Spawn error: the child never started or emitted a process error.
|
|
155
|
+
if (input.spawnError !== undefined && input.spawnError !== null) {
|
|
156
|
+
return {
|
|
157
|
+
crashClass: "spawn_error",
|
|
158
|
+
reason: `spawn error: ${stringifyError(input.spawnError)}`,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 4. Native panic from stderr (only when we have a signal/abnormal exit —
|
|
163
|
+
// never reclassify a clean exit as a panic based on stderr noise).
|
|
164
|
+
const abnormalExit = signal !== null || (exitCode !== null && exitCode !== 0);
|
|
165
|
+
if (abnormalExit) {
|
|
166
|
+
const panic = detectNativePanic(input.stderrSnippet);
|
|
167
|
+
if (panic !== null) {
|
|
168
|
+
return { crashClass: "native_panic", reason: `native panic detected: ${panic}` };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 5. Signal exit.
|
|
173
|
+
if (signal !== null) {
|
|
174
|
+
return { crashClass: "signal_exit", reason: `process exited after signal ${signal}` };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// 6. Clean exit.
|
|
178
|
+
if (exitCode === 0) {
|
|
179
|
+
return { crashClass: "clean_exit", reason: "process exited cleanly" };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// 7. Non-zero exit.
|
|
183
|
+
if (exitCode !== null) {
|
|
184
|
+
return { crashClass: "non_zero_exit", reason: `process exited with code ${exitCode}` };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 8. Protocol exit: exitCode is null with no signal — the process stream
|
|
188
|
+
// ended before a normal exit was observed (e.g. stdio closed unexpectedly).
|
|
189
|
+
// If `killed` is true but no signal was recorded, treat as protocol_exit
|
|
190
|
+
// (the kill may not have delivered a signal we could capture).
|
|
191
|
+
if (input.killed) {
|
|
192
|
+
return { crashClass: "protocol_exit", reason: "process was killed but no signal/exit code was captured" };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 8b. Truly null exitCode with no other context — protocol/stream ended early.
|
|
196
|
+
return { crashClass: "protocol_exit", reason: "process exited before protocol completion (exit code unknown)" };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Render an unknown error value to a short message string. */
|
|
200
|
+
function stringifyError(error: unknown): string {
|
|
201
|
+
if (error instanceof Error) return error.message || error.name;
|
|
202
|
+
if (typeof error === "string") return error;
|
|
203
|
+
try {
|
|
204
|
+
return String(error);
|
|
205
|
+
} catch {
|
|
206
|
+
return "(unstringifiable error)";
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { defineTool, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
16
16
|
import { Type, type Static } from "@sinclair/typebox";
|
|
17
|
-
import { listLiveAgents, sendIrcMessage, broadcastIrcMessage } from "../live-agent-manager.ts";
|
|
17
|
+
import { listLiveAgents, sendIrcMessage, broadcastIrcMessage, respondAsBackground } from "../live-agent-manager.ts";
|
|
18
18
|
import type { IrcMessage } from "../live-irc.ts";
|
|
19
19
|
|
|
20
20
|
const IrcParams = Type.Object({
|
|
@@ -37,7 +37,7 @@ const IrcParams = Type.Object({
|
|
|
37
37
|
),
|
|
38
38
|
awaitReply: Type.Optional(
|
|
39
39
|
Type.Boolean({
|
|
40
|
-
description: "Wait for a reply (default: true for DM, false for broadcast).
|
|
40
|
+
description: "Wait for a prose reply (default: true for DM, false for broadcast). For DMs the recipient receives the message as a non-blocking background turn and its reply is returned to the caller. Broadcast always ignores this flag.",
|
|
41
41
|
}),
|
|
42
42
|
),
|
|
43
43
|
});
|
|
@@ -64,6 +64,8 @@ interface IrcDetails {
|
|
|
64
64
|
delivered?: string[];
|
|
65
65
|
notFound?: string[];
|
|
66
66
|
peers?: Array<{ id: string; status: string }>;
|
|
67
|
+
/** Replies received from recipients (awaitReply DM path). */
|
|
68
|
+
replies?: Array<{ from: string; text: string }>;
|
|
67
69
|
error?: string;
|
|
68
70
|
}
|
|
69
71
|
|
|
@@ -130,10 +132,10 @@ function executeList(selfId: string): { content: Array<{ type: "text"; text: str
|
|
|
130
132
|
};
|
|
131
133
|
}
|
|
132
134
|
|
|
133
|
-
function executeSend(
|
|
135
|
+
async function executeSend(
|
|
134
136
|
selfId: string,
|
|
135
137
|
params: IrcParams,
|
|
136
|
-
): { content: Array<{ type: "text"; text: string }>; details: IrcDetails } {
|
|
138
|
+
): Promise<{ content: Array<{ type: "text"; text: string }>; details: IrcDetails }> {
|
|
137
139
|
const to = params.to?.trim();
|
|
138
140
|
const message = params.message?.trim();
|
|
139
141
|
|
|
@@ -156,23 +158,52 @@ function executeSend(
|
|
|
156
158
|
};
|
|
157
159
|
}
|
|
158
160
|
|
|
161
|
+
// awaitReply defaults to true for DMs, false for broadcast. Broadcast
|
|
162
|
+
// always ignores the flag (fire-and-forget) — there is no single sender
|
|
163
|
+
// to receive a correlated reply from.
|
|
164
|
+
const isBroadcast = to === "all";
|
|
165
|
+
const wantsReply = !isBroadcast && (params.awaitReply ?? true);
|
|
166
|
+
|
|
159
167
|
const ircMessage: IrcMessage = {
|
|
160
168
|
from: selfId,
|
|
161
169
|
to,
|
|
162
170
|
content: message,
|
|
163
171
|
timestamp: new Date().toISOString(),
|
|
164
|
-
awaitReply:
|
|
172
|
+
awaitReply: wantsReply,
|
|
165
173
|
};
|
|
166
174
|
|
|
167
175
|
const notFound: string[] = [];
|
|
168
176
|
const delivered: string[] = [];
|
|
177
|
+
const replies: Array<{ from: string; text: string }> = [];
|
|
169
178
|
|
|
170
179
|
try {
|
|
171
|
-
if (
|
|
180
|
+
if (isBroadcast) {
|
|
181
|
+
// Broadcast: always fire-and-forget regardless of awaitReply.
|
|
172
182
|
const recipients = broadcastIrcMessage(selfId, ircMessage);
|
|
173
183
|
delivered.push(...recipients);
|
|
184
|
+
} else if (wantsReply) {
|
|
185
|
+
// DM with reply: use the non-blocking side-channel.
|
|
186
|
+
const agents = listLiveAgents();
|
|
187
|
+
const target = agents.find((a) => a.agentId === to);
|
|
188
|
+
if (!target || (target.status !== "running" && target.status !== "queued")) {
|
|
189
|
+
notFound.push(to);
|
|
190
|
+
} else {
|
|
191
|
+
const result = await respondAsBackground(to, selfId, message, { awaitReply: true });
|
|
192
|
+
if (result.ok) {
|
|
193
|
+
delivered.push(to);
|
|
194
|
+
if (result.replyContent) replies.push({ from: to, text: result.replyContent });
|
|
195
|
+
} else if (result.timedOut) {
|
|
196
|
+
// Message was delivered (non-blocking), but no reply in time.
|
|
197
|
+
delivered.push(to);
|
|
198
|
+
replies.push({ from: to, text: `(no reply — timed out)` });
|
|
199
|
+
} else {
|
|
200
|
+
// Delivery channel unavailable or cancelled.
|
|
201
|
+
if (result.error === "cancelled") delivered.push(to);
|
|
202
|
+
else notFound.push(to);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
174
205
|
} else {
|
|
175
|
-
// DM
|
|
206
|
+
// DM fire-and-forget (awaitReply explicitly false).
|
|
176
207
|
const agents = listLiveAgents();
|
|
177
208
|
const target = agents.find((a) => a.agentId === to);
|
|
178
209
|
if (!target || (target.status !== "running" && target.status !== "queued")) {
|
|
@@ -197,6 +228,14 @@ function executeSend(
|
|
|
197
228
|
} else {
|
|
198
229
|
lines.push("No recipients received the message.");
|
|
199
230
|
}
|
|
231
|
+
if (replies.length > 0) {
|
|
232
|
+
lines.push("");
|
|
233
|
+
lines.push("## Replies");
|
|
234
|
+
for (const reply of replies) {
|
|
235
|
+
lines.push(`### ${reply.from}`);
|
|
236
|
+
lines.push(reply.text);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
200
239
|
if (notFound.length > 0) {
|
|
201
240
|
lines.push(`Unknown / unavailable peers: ${notFound.join(", ")}`);
|
|
202
241
|
}
|
|
@@ -209,6 +248,7 @@ function executeSend(
|
|
|
209
248
|
to,
|
|
210
249
|
delivered: delivered.length > 0 ? delivered : undefined,
|
|
211
250
|
notFound: notFound.length > 0 ? notFound : undefined,
|
|
251
|
+
replies: replies.length > 0 ? replies : undefined,
|
|
212
252
|
},
|
|
213
253
|
};
|
|
214
254
|
}
|
|
@@ -202,6 +202,16 @@ export class HandoffManager {
|
|
|
202
202
|
this.cleanupTimer = setInterval(() => {
|
|
203
203
|
this.cleanupStaleHandoffs();
|
|
204
204
|
}, this.options.cleanupIntervalMs);
|
|
205
|
+
// FIX (BG2 hang): without .unref(), the cleanup interval keeps the Node
|
|
206
|
+
// event loop alive forever — tests that create HandoffManager without
|
|
207
|
+
// calling dispose() (e.g. chain-runner.test.ts mock helper that does
|
|
208
|
+
// `return new HandoffManager()`) leak an interval per test, and the
|
|
209
|
+
// file-level test never completes because Node waits for all handles
|
|
210
|
+
// to close. .unref() lets the process exit when nothing else is pending
|
|
211
|
+
// — this is the standard Node.js pattern for background timers.
|
|
212
|
+
if (typeof this.cleanupTimer.unref === "function") {
|
|
213
|
+
this.cleanupTimer.unref();
|
|
214
|
+
}
|
|
205
215
|
}
|
|
206
216
|
|
|
207
217
|
/**
|