brl-subagent 2.4.0 → 2.5.1
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/AGENT.md +9 -2
- package/README.md +6 -3
- package/package.json +6 -6
- package/src/event-bus.ts +7 -2
- package/src/git.ts +67 -0
- package/src/history.ts +77 -10
- package/src/index.ts +355 -63
- package/src/kill-escalation.ts +426 -0
- package/src/logging.ts +37 -3
- package/src/metrics.ts +5 -3
- package/src/notify-completion.ts +23 -0
- package/src/preflight.ts +29 -14
- package/src/proc.ts +390 -0
- package/src/recovery-engine.ts +348 -0
- package/src/recovery-surface.ts +374 -0
- package/src/recovery.ts +343 -0
- package/src/run-registry.ts +330 -0
- package/src/runner.ts +409 -16
- package/src/sanitize.ts +53 -3
- package/src/session-manager.ts +234 -29
- package/src/state.ts +81 -7
- package/src/transcript-path.ts +41 -0
- package/src/transcript.ts +13 -12
- package/src/tui-format.ts +57 -1
- package/src/tui.ts +323 -103
- package/src/types.ts +173 -1
- package/src/unit-run.ts +16 -1
package/src/types.ts
CHANGED
|
@@ -22,6 +22,15 @@ export type CompletionNotifyMode = (typeof COMPLETION_NOTIFY_MODES)[number];
|
|
|
22
22
|
|
|
23
23
|
export const DEFAULT_COMPLETION_NOTIFY: CompletionNotifyMode = "all";
|
|
24
24
|
|
|
25
|
+
// Option B U3 (D4): boot recovery posture. "offer" surfaces interrupted runs
|
|
26
|
+
// (Run History row + one boot notice); "off" silences the notice and marks
|
|
27
|
+
// newly dispatched runs so they are never offered. "auto" is deliberately NOT
|
|
28
|
+
// a member (U4 territory), enforced by RECOVERY_MODES being the shape guard.
|
|
29
|
+
export const RECOVERY_MODES = ["offer", "off"] as const;
|
|
30
|
+
export type RecoveryMode = (typeof RECOVERY_MODES)[number];
|
|
31
|
+
|
|
32
|
+
export const DEFAULT_RECOVERY: RecoveryMode = "offer";
|
|
33
|
+
|
|
25
34
|
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
|
26
35
|
|
|
27
36
|
export const THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
|
|
@@ -62,6 +71,15 @@ export type ErrorCategory =
|
|
|
62
71
|
// literal in sync with classifyError — do NOT reword one without the other.
|
|
63
72
|
export const SUBAGENT_ABORTED_MESSAGE = "Subagent aborted by user";
|
|
64
73
|
|
|
74
|
+
// Issue #295: external-signal kill stamp. A SIGKILL/SIGTERM that was NOT staged
|
|
75
|
+
// by our own timeout/abort handler leaves no terminal event, and Node reports
|
|
76
|
+
// code === null for a signal death. The foreground close handler stamps this
|
|
77
|
+
// message (with the signal name) so classifyError can record the honest cause
|
|
78
|
+
// instead of the false 'done' that a fabricated exit 0 produced. Matched below
|
|
79
|
+
// on its distinctive "killed by external signal" substring (mirroring the
|
|
80
|
+
// SUBAGENT_ABORTED_MESSAGE match) — keep the literal in sync with classifyError.
|
|
81
|
+
export const SUBAGENT_SIGNAL_KILLED_MESSAGE = "Subagent killed by external signal";
|
|
82
|
+
|
|
65
83
|
/**
|
|
66
84
|
* Classify a subagent result into an error category based on its errorMessage,
|
|
67
85
|
* stopReason, exitCode, and stderr content. Inspects patterns in priority order.
|
|
@@ -96,6 +114,15 @@ export function classifyError(result: SubagentResult): ErrorCategory {
|
|
|
96
114
|
// clobbered by the exitError/unknown fallbacks below.
|
|
97
115
|
if (msg.includes("aborted by user")) return "aborted";
|
|
98
116
|
|
|
117
|
+
// Issue #295: a signal death we did NOT stage (no timeout/abort reason) is
|
|
118
|
+
// stamped with SUBAGENT_SIGNAL_KILLED_MESSAGE + the signal name by the
|
|
119
|
+
// foreground close handler. classifyError cannot infer it from the dead
|
|
120
|
+
// process (a signal death carries no numeric code), so the stamp must survive
|
|
121
|
+
// reclassification as a genuine crash — checked before the exitCode fallback,
|
|
122
|
+
// which the -1 signal sentinel would otherwise turn into exit_error. Keep this
|
|
123
|
+
// substring in sync with the constant above.
|
|
124
|
+
if (msg.includes("killed by external signal")) return "crash";
|
|
125
|
+
|
|
99
126
|
if (msg.includes("model not found") || msg.includes("model unavailable"))
|
|
100
127
|
return "model_unavailable";
|
|
101
128
|
|
|
@@ -220,6 +247,9 @@ export interface SubagentState {
|
|
|
220
247
|
// state, "failed" wakes only on failed/stopped, "off" never wakes (still
|
|
221
248
|
// delivers passively at the next user prompt).
|
|
222
249
|
completionNotify: CompletionNotifyMode;
|
|
250
|
+
// Option B U3 (D4): recovery posture for the session. Applies to newly
|
|
251
|
+
// dispatched runs (snapshotted per run) and to the boot notice.
|
|
252
|
+
recovery: RecoveryMode;
|
|
223
253
|
maxHistoryEntries: number; // 0 = unlimited
|
|
224
254
|
sessionCostLimit: number; // 0 = unlimited
|
|
225
255
|
perTaskCostEstimate: number; // 0 = no estimate, use default
|
|
@@ -243,6 +273,21 @@ export interface SubagentState {
|
|
|
243
273
|
*/
|
|
244
274
|
export type ToolResult<T = unknown> = AgentToolResult<T> & { isError?: boolean };
|
|
245
275
|
|
|
276
|
+
/**
|
|
277
|
+
* Option B U1: identity of the conductor process that owns a run record.
|
|
278
|
+
*
|
|
279
|
+
* `pid` plus `start` (the process start-time token — `/proc/<pid>/stat` field
|
|
280
|
+
* 22 on Linux) lets a fresh process distinguish "a live conductor still owns
|
|
281
|
+
* this record" from "its conductor is dead", including two conductors sharing
|
|
282
|
+
* one cwd (which would otherwise be indistinguishable by pid alone after a
|
|
283
|
+
* reboot). `start` is `""` when the platform cannot supply a start token; the
|
|
284
|
+
* boot scan then treats ownership as unverifiable and never marks the record.
|
|
285
|
+
*/
|
|
286
|
+
export interface ProcessOwner {
|
|
287
|
+
pid: number;
|
|
288
|
+
start: string;
|
|
289
|
+
}
|
|
290
|
+
|
|
246
291
|
export interface SubagentRun {
|
|
247
292
|
id: string;
|
|
248
293
|
task: string;
|
|
@@ -278,6 +323,25 @@ export interface SubagentRun {
|
|
|
278
323
|
// Issue #114: per-unit priority (may be absent — the run's slot priority
|
|
279
324
|
// then fell back to the call-level default, which is not recorded here).
|
|
280
325
|
priority?: string;
|
|
326
|
+
// Option B U1 (D3/D4): additive durability identity — all optional, so
|
|
327
|
+
// records written before this change simply lack the keys.
|
|
328
|
+
// - dispatchId: uuid of the logical dispatch; a retry inherits it.
|
|
329
|
+
// - resumeOf: the run id a retry resumes (retries only).
|
|
330
|
+
// - attempt: 1 at first dispatch, (prior.attempt ?? 1) + 1 on a retry.
|
|
331
|
+
// - interruptedAt: D3 mark set by the boot scan when the conductor died;
|
|
332
|
+
// status stays "running" (see isInterruptedRun).
|
|
333
|
+
// - owner: conductor process identity written at dispatch.
|
|
334
|
+
// - childMarker: uuid injected into the child process env on the foreground
|
|
335
|
+
// path; the boot scan discovers the orphan via /proc/*/environ.
|
|
336
|
+
dispatchId?: string;
|
|
337
|
+
resumeOf?: string;
|
|
338
|
+
attempt?: number;
|
|
339
|
+
interruptedAt?: string;
|
|
340
|
+
owner?: ProcessOwner;
|
|
341
|
+
childMarker?: string;
|
|
342
|
+
// Option B U3 (D4): the recovery posture in force when this run was
|
|
343
|
+
// dispatched. Additive: a pre-U3 record lacks it and reads as "offer".
|
|
344
|
+
recoveryPosture?: RecoveryMode;
|
|
281
345
|
startedAt: string;
|
|
282
346
|
finishedAt?: string;
|
|
283
347
|
durationMs?: number;
|
|
@@ -600,6 +664,8 @@ export const EMPTY_USAGE: UsageStats = {
|
|
|
600
664
|
export const NAV_FOOTER = "\u2191\u2193 navigate \u2022 enter select \u2022 esc cancel";
|
|
601
665
|
|
|
602
666
|
export const SIGKILL_GRACE_MS = 5000;
|
|
667
|
+
/** Poll interval while waiting for a reaped child to die (B4 early exit, bounded by SIGKILL_GRACE_MS). */
|
|
668
|
+
export const SIGKILL_POLL_MS = 100;
|
|
603
669
|
export const STATUS_RESET_DELAY_MS = 3000;
|
|
604
670
|
export const TEMP_FILE_MODE = 0o600;
|
|
605
671
|
export const TASK_PREVIEW_MAX_LENGTH = 80;
|
|
@@ -656,7 +722,7 @@ export const RESERVED_NAME_PATTERN = /^__.*__$/;
|
|
|
656
722
|
export const RESERVED_COMMAND_NAMES = new Set([
|
|
657
723
|
"model", "thinking", "concurrency", "depth", "history", "monitor",
|
|
658
724
|
"preset", "retry", "reset", "priority", "templates", "dashboard", "approval",
|
|
659
|
-
"costlimit", "historyentries", "sla", "completionnotify",
|
|
725
|
+
"costlimit", "historyentries", "sla", "completionnotify", "recovery",
|
|
660
726
|
"graph", "sla-stats", "compliance",
|
|
661
727
|
]);
|
|
662
728
|
|
|
@@ -684,6 +750,27 @@ export interface BackgroundAgent {
|
|
|
684
750
|
finalOutput?: string;
|
|
685
751
|
/** @internal — session reference for live monitor polling */
|
|
686
752
|
_sessionRef?: import('@earendil-works/pi-coding-agent').AgentSession;
|
|
753
|
+
/**
|
|
754
|
+
* @internal — Option B U2: the on-disk session file for this run, captured
|
|
755
|
+
* from `SessionManager.getSessionFile()` at spawn. Used by the settle path to
|
|
756
|
+
* prune the EXACT per-run directory. Live-only: stripped by `persistAgent`,
|
|
757
|
+
* never a persisted record field (D4).
|
|
758
|
+
*/
|
|
759
|
+
_sessionFile?: string;
|
|
760
|
+
// Option B U1 (D3/D4): additive durability identity, mirroring SubagentRun.
|
|
761
|
+
// Background records carry dispatchId/owner/attempt. `childMarker` is
|
|
762
|
+
// declared for round-trip tolerance with the run-record/agent-record JSON
|
|
763
|
+
// shape (`isSubagentRunShape`) — no background path sets it, because the
|
|
764
|
+
// session is in-process and has no child process to mark.
|
|
765
|
+
dispatchId?: string;
|
|
766
|
+
resumeOf?: string;
|
|
767
|
+
attempt?: number;
|
|
768
|
+
interruptedAt?: string;
|
|
769
|
+
owner?: ProcessOwner;
|
|
770
|
+
childMarker?: string;
|
|
771
|
+
// Option B U3 (D4): the recovery posture at dispatch, mirrored from the
|
|
772
|
+
// run record. Additive (absent reads as "offer").
|
|
773
|
+
recoveryPosture?: RecoveryMode;
|
|
687
774
|
}
|
|
688
775
|
|
|
689
776
|
// ---------------------------------------------------------------------------
|
|
@@ -748,6 +835,40 @@ export function isSubagentError(result: SubagentResult): boolean {
|
|
|
748
835
|
|| result.stopReason === "pending";
|
|
749
836
|
}
|
|
750
837
|
|
|
838
|
+
/**
|
|
839
|
+
* Issue #298 (C2): the ONE home for the partial-vs-settled renderer decision.
|
|
840
|
+
*
|
|
841
|
+
* `exitCode === -1` is overloaded. Since #206 it is the UNSETTLED streaming
|
|
842
|
+
* sentinel (`emitSubagentUpdate` in runner.ts stamps `{ ...result, exitCode: -1 }`
|
|
843
|
+
* on every live partial). Since #295 a SETTLED signal death — external kill,
|
|
844
|
+
* staged timeout, staged abort — also settles at -1, so the sentinel alone no
|
|
845
|
+
* longer means "still running":
|
|
846
|
+
*
|
|
847
|
+
* - a live partial carries the sentinel and normally no errorCategory — the
|
|
848
|
+
* exception is the abort stamp: `runner.ts` sets `errorCategory: "aborted"`
|
|
849
|
+
* on the live result before close, so a post-abort partial can carry both.
|
|
850
|
+
* The predicate then returns false and it renders the failure branch — the
|
|
851
|
+
* same outcome as the local logic this replaces (the qualifier is comment
|
|
852
|
+
* precision, not a behavior change; #311 review nit);
|
|
853
|
+
* - every FINALIZED foreground result carries an errorCategory because
|
|
854
|
+
* `runSubagent` ends with `result.errorCategory = classifyError(result)`
|
|
855
|
+
* and classifyError is total (its `unknown` fallback means it never returns
|
|
856
|
+
* undefined). A classified -1 is a settled FAILURE and must take the verdict
|
|
857
|
+
* branch, never the raw running text.
|
|
858
|
+
*
|
|
859
|
+
* Encoding that (exitCode, errorCategory) invariant EXACTLY ONCE here is the
|
|
860
|
+
* point of #298: the local disambiguation it replaces (the F1 fix, PR #297) was
|
|
861
|
+
* the same defect class it fixed — inferring liveness from a correlated field
|
|
862
|
+
* in the renderer. Callers must use this predicate rather than re-deriving the
|
|
863
|
+
* pair; the terminal-status structural ratchet in
|
|
864
|
+
* `src/__tests__/terminal-status-consistency.test.ts` fails a local copy.
|
|
865
|
+
*/
|
|
866
|
+
export function isUnsettledPartial(
|
|
867
|
+
details: { exitCode: number; errorCategory?: ErrorCategory } | undefined,
|
|
868
|
+
): boolean {
|
|
869
|
+
return !details || (details.exitCode === -1 && !details.errorCategory);
|
|
870
|
+
}
|
|
871
|
+
|
|
751
872
|
/**
|
|
752
873
|
* Issue #179 (review ratchet): the NARROWER question — was the terminal turn a
|
|
753
874
|
* provider/model error specifically?
|
|
@@ -914,6 +1035,11 @@ export function isSubagentStateShape(value: unknown): value is SubagentState {
|
|
|
914
1035
|
if (!COMPLETION_NOTIFY_MODES.includes(v.completionNotify as CompletionNotifyMode)) return false;
|
|
915
1036
|
}
|
|
916
1037
|
|
|
1038
|
+
// recovery must be one of RECOVERY_MODES if present ("auto" is rejected)
|
|
1039
|
+
if (v.recovery !== undefined) {
|
|
1040
|
+
if (!RECOVERY_MODES.includes(v.recovery as RecoveryMode)) return false;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
917
1043
|
// maxHistoryEntries must be a non-negative number if present
|
|
918
1044
|
if (v.maxHistoryEntries !== undefined) {
|
|
919
1045
|
if (typeof v.maxHistoryEntries !== "number" || v.maxHistoryEntries < 0) return false;
|
|
@@ -949,9 +1075,55 @@ export function isSubagentRunShape(value: unknown): value is SubagentRun {
|
|
|
949
1075
|
if (typeof v.id !== "string") return false;
|
|
950
1076
|
if (typeof v.task !== "string") return false;
|
|
951
1077
|
if (!["running", "done", "failed"].includes(v.status as string)) return false;
|
|
1078
|
+
// U3 (D4): a malformed snapshot is rejected like any other bad field; an
|
|
1079
|
+
// absent one is fine (pre-U3 records) and reads as "offer" via the helper.
|
|
1080
|
+
if (v.recoveryPosture !== undefined) {
|
|
1081
|
+
if (!RECOVERY_MODES.includes(v.recoveryPosture as RecoveryMode)) return false;
|
|
1082
|
+
}
|
|
952
1083
|
return true;
|
|
953
1084
|
}
|
|
954
1085
|
|
|
1086
|
+
/**
|
|
1087
|
+
* Option B U3 (D4): the recovery posture recorded for a run. Absent (pre-U3
|
|
1088
|
+
* records) or an unrecognized value reads as the default "offer" — the
|
|
1089
|
+
* additive-field contract (no migration).
|
|
1090
|
+
*/
|
|
1091
|
+
export function runRecoveryPosture(
|
|
1092
|
+
run: Pick<SubagentRun, "recoveryPosture">,
|
|
1093
|
+
): RecoveryMode {
|
|
1094
|
+
return run.recoveryPosture === "off" ? "off" : DEFAULT_RECOVERY;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* Option B U1 (D3): the ONE interrupted-state predicate. Status deliberately
|
|
1099
|
+
* stays "running" (a new status value would be dropped by old readers and
|
|
1100
|
+
* misread as resolved by the `!== "running"` guards); the additive
|
|
1101
|
+
* `interruptedAt` mark is the only signal. Consumers must key on THIS helper,
|
|
1102
|
+
* never on a raw status compare.
|
|
1103
|
+
*/
|
|
1104
|
+
export function isInterruptedRun(run: SubagentRun): boolean {
|
|
1105
|
+
return run.status === "running" && !!run.interruptedAt;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/**
|
|
1109
|
+
* Fix B (item 2): TRUE when a terminal run record represents an ABORT rather
|
|
1110
|
+
* than a genuine failure. Abandon writes the store's ONLY stopped-outcome
|
|
1111
|
+
* shape — `failed` + `stopReason`/`errorCategory` "aborted" (D5 mapping) — and
|
|
1112
|
+
* a user `stop_subagent` writes the same. Failure tallies (SLA) must exclude
|
|
1113
|
+
* these. The run record carries no dedicated "abandoned" marker and D5 forbids
|
|
1114
|
+
* adding one, so a user-ABANDONED run and a user-STOPPED run are
|
|
1115
|
+
* indistinguishable here; both are treated as non-failures.
|
|
1116
|
+
*/
|
|
1117
|
+
export function isAbortedRun(
|
|
1118
|
+
run: Pick<SubagentRun, "stopReason" | "errorCategory" | "originalParams">,
|
|
1119
|
+
): boolean {
|
|
1120
|
+
return (
|
|
1121
|
+
run.stopReason === "aborted" ||
|
|
1122
|
+
run.errorCategory === "aborted" ||
|
|
1123
|
+
run.originalParams?.errorCategory === "aborted"
|
|
1124
|
+
);
|
|
1125
|
+
}
|
|
1126
|
+
|
|
955
1127
|
/**
|
|
956
1128
|
* Type guard: checks if a value is a valid MultiSubagentDetails shape.
|
|
957
1129
|
*/
|
package/src/unit-run.ts
CHANGED
|
@@ -27,9 +27,11 @@ import type {
|
|
|
27
27
|
SubagentRun,
|
|
28
28
|
SubagentToolOptions,
|
|
29
29
|
Priority,
|
|
30
|
+
RecoveryMode,
|
|
30
31
|
} from "./types";
|
|
31
|
-
import { getFinalOutput, classifyError } from "./types";
|
|
32
|
+
import { getFinalOutput, classifyError, DEFAULT_RECOVERY } from "./types";
|
|
32
33
|
import { snapshotOriginalParams } from "./params";
|
|
34
|
+
import { currentProcessOwner } from "./recovery";
|
|
33
35
|
import { capOutput, stripAnsi, buildCrashResult } from "./sanitize";
|
|
34
36
|
import { finalizeRunRecord, pruneSessionRuns } from "./history";
|
|
35
37
|
import type { Logger } from "./logging";
|
|
@@ -105,6 +107,9 @@ export function createUnitRun(
|
|
|
105
107
|
stepModel: { provider: string; id: string },
|
|
106
108
|
priorityFloor: Priority,
|
|
107
109
|
presetName?: string,
|
|
110
|
+
// U3 (D4): the session recovery posture at dispatch. Optional so existing
|
|
111
|
+
// callers/tests stay valid; production passes `state.config.recovery`.
|
|
112
|
+
recoveryPosture: RecoveryMode = DEFAULT_RECOVERY,
|
|
108
113
|
): { runId: string; run: SubagentRun } {
|
|
109
114
|
const runId = crypto.randomUUID();
|
|
110
115
|
const run: SubagentRun = {
|
|
@@ -123,6 +128,16 @@ export function createUnitRun(
|
|
|
123
128
|
// same priority the run entry itself carried.
|
|
124
129
|
priority: source.priority ?? priorityFloor,
|
|
125
130
|
startedAt: new Date().toISOString(),
|
|
131
|
+
// Option B U1: durability identity. Fan-out units are never a retry
|
|
132
|
+
// target (a retried unit degrades to a single foreground run), so a
|
|
133
|
+
// fresh dispatchId/attempt=1 and a fresh child marker always apply.
|
|
134
|
+
dispatchId: crypto.randomUUID(),
|
|
135
|
+
attempt: 1,
|
|
136
|
+
owner: currentProcessOwner(),
|
|
137
|
+
childMarker: crypto.randomUUID(),
|
|
138
|
+
// U3 (D4): snapshot the posture so the boot notice/offer reads what was
|
|
139
|
+
// in force at dispatch.
|
|
140
|
+
recoveryPosture,
|
|
126
141
|
// The execution-shape fields (background/gitMode/approvalMode/force) are
|
|
127
142
|
// deliberately omitted: a fan-out unit retries as a SINGLE run, so it must
|
|
128
143
|
// not inherit the fan-out's background-ness. A retry that wants background
|