brl-subagent 2.4.0 → 2.5.0
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/README.md +2 -2
- package/package.json +5 -5
- package/src/event-bus.ts +7 -2
- package/src/git.ts +67 -0
- package/src/history.ts +58 -1
- package/src/index.ts +184 -32
- package/src/kill-escalation.ts +94 -0
- package/src/logging.ts +37 -3
- package/src/preflight.ts +29 -14
- package/src/proc.ts +140 -0
- package/src/recovery-engine.ts +262 -0
- package/src/recovery.ts +325 -0
- package/src/run-registry.ts +269 -0
- package/src/runner.ts +206 -16
- package/src/sanitize.ts +53 -3
- package/src/session-manager.ts +106 -23
- package/src/state.ts +51 -7
- package/src/transcript-path.ts +41 -0
- package/src/transcript.ts +13 -12
- package/src/tui.ts +228 -88
- package/src/types.ts +107 -0
- package/src/unit-run.ts +8 -0
package/src/types.ts
CHANGED
|
@@ -62,6 +62,15 @@ export type ErrorCategory =
|
|
|
62
62
|
// literal in sync with classifyError — do NOT reword one without the other.
|
|
63
63
|
export const SUBAGENT_ABORTED_MESSAGE = "Subagent aborted by user";
|
|
64
64
|
|
|
65
|
+
// Issue #295: external-signal kill stamp. A SIGKILL/SIGTERM that was NOT staged
|
|
66
|
+
// by our own timeout/abort handler leaves no terminal event, and Node reports
|
|
67
|
+
// code === null for a signal death. The foreground close handler stamps this
|
|
68
|
+
// message (with the signal name) so classifyError can record the honest cause
|
|
69
|
+
// instead of the false 'done' that a fabricated exit 0 produced. Matched below
|
|
70
|
+
// on its distinctive "killed by external signal" substring (mirroring the
|
|
71
|
+
// SUBAGENT_ABORTED_MESSAGE match) — keep the literal in sync with classifyError.
|
|
72
|
+
export const SUBAGENT_SIGNAL_KILLED_MESSAGE = "Subagent killed by external signal";
|
|
73
|
+
|
|
65
74
|
/**
|
|
66
75
|
* Classify a subagent result into an error category based on its errorMessage,
|
|
67
76
|
* stopReason, exitCode, and stderr content. Inspects patterns in priority order.
|
|
@@ -96,6 +105,15 @@ export function classifyError(result: SubagentResult): ErrorCategory {
|
|
|
96
105
|
// clobbered by the exitError/unknown fallbacks below.
|
|
97
106
|
if (msg.includes("aborted by user")) return "aborted";
|
|
98
107
|
|
|
108
|
+
// Issue #295: a signal death we did NOT stage (no timeout/abort reason) is
|
|
109
|
+
// stamped with SUBAGENT_SIGNAL_KILLED_MESSAGE + the signal name by the
|
|
110
|
+
// foreground close handler. classifyError cannot infer it from the dead
|
|
111
|
+
// process (a signal death carries no numeric code), so the stamp must survive
|
|
112
|
+
// reclassification as a genuine crash — checked before the exitCode fallback,
|
|
113
|
+
// which the -1 signal sentinel would otherwise turn into exit_error. Keep this
|
|
114
|
+
// substring in sync with the constant above.
|
|
115
|
+
if (msg.includes("killed by external signal")) return "crash";
|
|
116
|
+
|
|
99
117
|
if (msg.includes("model not found") || msg.includes("model unavailable"))
|
|
100
118
|
return "model_unavailable";
|
|
101
119
|
|
|
@@ -243,6 +261,21 @@ export interface SubagentState {
|
|
|
243
261
|
*/
|
|
244
262
|
export type ToolResult<T = unknown> = AgentToolResult<T> & { isError?: boolean };
|
|
245
263
|
|
|
264
|
+
/**
|
|
265
|
+
* Option B U1: identity of the conductor process that owns a run record.
|
|
266
|
+
*
|
|
267
|
+
* `pid` plus `start` (the process start-time token — `/proc/<pid>/stat` field
|
|
268
|
+
* 22 on Linux) lets a fresh process distinguish "a live conductor still owns
|
|
269
|
+
* this record" from "its conductor is dead", including two conductors sharing
|
|
270
|
+
* one cwd (which would otherwise be indistinguishable by pid alone after a
|
|
271
|
+
* reboot). `start` is `""` when the platform cannot supply a start token; the
|
|
272
|
+
* boot scan then treats ownership as unverifiable and never marks the record.
|
|
273
|
+
*/
|
|
274
|
+
export interface ProcessOwner {
|
|
275
|
+
pid: number;
|
|
276
|
+
start: string;
|
|
277
|
+
}
|
|
278
|
+
|
|
246
279
|
export interface SubagentRun {
|
|
247
280
|
id: string;
|
|
248
281
|
task: string;
|
|
@@ -278,6 +311,22 @@ export interface SubagentRun {
|
|
|
278
311
|
// Issue #114: per-unit priority (may be absent — the run's slot priority
|
|
279
312
|
// then fell back to the call-level default, which is not recorded here).
|
|
280
313
|
priority?: string;
|
|
314
|
+
// Option B U1 (D3/D4): additive durability identity — all optional, so
|
|
315
|
+
// records written before this change simply lack the keys.
|
|
316
|
+
// - dispatchId: uuid of the logical dispatch; a retry inherits it.
|
|
317
|
+
// - resumeOf: the run id a retry resumes (retries only).
|
|
318
|
+
// - attempt: 1 at first dispatch, (prior.attempt ?? 1) + 1 on a retry.
|
|
319
|
+
// - interruptedAt: D3 mark set by the boot scan when the conductor died;
|
|
320
|
+
// status stays "running" (see isInterruptedRun).
|
|
321
|
+
// - owner: conductor process identity written at dispatch.
|
|
322
|
+
// - childMarker: uuid injected into the child process env on the foreground
|
|
323
|
+
// path; the boot scan discovers the orphan via /proc/*/environ.
|
|
324
|
+
dispatchId?: string;
|
|
325
|
+
resumeOf?: string;
|
|
326
|
+
attempt?: number;
|
|
327
|
+
interruptedAt?: string;
|
|
328
|
+
owner?: ProcessOwner;
|
|
329
|
+
childMarker?: string;
|
|
281
330
|
startedAt: string;
|
|
282
331
|
finishedAt?: string;
|
|
283
332
|
durationMs?: number;
|
|
@@ -600,6 +649,8 @@ export const EMPTY_USAGE: UsageStats = {
|
|
|
600
649
|
export const NAV_FOOTER = "\u2191\u2193 navigate \u2022 enter select \u2022 esc cancel";
|
|
601
650
|
|
|
602
651
|
export const SIGKILL_GRACE_MS = 5000;
|
|
652
|
+
/** Poll interval while waiting for a reaped child to die (B4 early exit, bounded by SIGKILL_GRACE_MS). */
|
|
653
|
+
export const SIGKILL_POLL_MS = 100;
|
|
603
654
|
export const STATUS_RESET_DELAY_MS = 3000;
|
|
604
655
|
export const TEMP_FILE_MODE = 0o600;
|
|
605
656
|
export const TASK_PREVIEW_MAX_LENGTH = 80;
|
|
@@ -684,6 +735,17 @@ export interface BackgroundAgent {
|
|
|
684
735
|
finalOutput?: string;
|
|
685
736
|
/** @internal — session reference for live monitor polling */
|
|
686
737
|
_sessionRef?: import('@earendil-works/pi-coding-agent').AgentSession;
|
|
738
|
+
// Option B U1 (D3/D4): additive durability identity, mirroring SubagentRun.
|
|
739
|
+
// Background records carry dispatchId/owner/attempt. `childMarker` is
|
|
740
|
+
// declared for round-trip tolerance with the run-record/agent-record JSON
|
|
741
|
+
// shape (`isSubagentRunShape`) — no background path sets it, because the
|
|
742
|
+
// session is in-process and has no child process to mark.
|
|
743
|
+
dispatchId?: string;
|
|
744
|
+
resumeOf?: string;
|
|
745
|
+
attempt?: number;
|
|
746
|
+
interruptedAt?: string;
|
|
747
|
+
owner?: ProcessOwner;
|
|
748
|
+
childMarker?: string;
|
|
687
749
|
}
|
|
688
750
|
|
|
689
751
|
// ---------------------------------------------------------------------------
|
|
@@ -748,6 +810,40 @@ export function isSubagentError(result: SubagentResult): boolean {
|
|
|
748
810
|
|| result.stopReason === "pending";
|
|
749
811
|
}
|
|
750
812
|
|
|
813
|
+
/**
|
|
814
|
+
* Issue #298 (C2): the ONE home for the partial-vs-settled renderer decision.
|
|
815
|
+
*
|
|
816
|
+
* `exitCode === -1` is overloaded. Since #206 it is the UNSETTLED streaming
|
|
817
|
+
* sentinel (`emitSubagentUpdate` in runner.ts stamps `{ ...result, exitCode: -1 }`
|
|
818
|
+
* on every live partial). Since #295 a SETTLED signal death — external kill,
|
|
819
|
+
* staged timeout, staged abort — also settles at -1, so the sentinel alone no
|
|
820
|
+
* longer means "still running":
|
|
821
|
+
*
|
|
822
|
+
* - a live partial carries the sentinel and normally no errorCategory — the
|
|
823
|
+
* exception is the abort stamp: `runner.ts` sets `errorCategory: "aborted"`
|
|
824
|
+
* on the live result before close, so a post-abort partial can carry both.
|
|
825
|
+
* The predicate then returns false and it renders the failure branch — the
|
|
826
|
+
* same outcome as the local logic this replaces (the qualifier is comment
|
|
827
|
+
* precision, not a behavior change; #311 review nit);
|
|
828
|
+
* - every FINALIZED foreground result carries an errorCategory because
|
|
829
|
+
* `runSubagent` ends with `result.errorCategory = classifyError(result)`
|
|
830
|
+
* and classifyError is total (its `unknown` fallback means it never returns
|
|
831
|
+
* undefined). A classified -1 is a settled FAILURE and must take the verdict
|
|
832
|
+
* branch, never the raw running text.
|
|
833
|
+
*
|
|
834
|
+
* Encoding that (exitCode, errorCategory) invariant EXACTLY ONCE here is the
|
|
835
|
+
* point of #298: the local disambiguation it replaces (the F1 fix, PR #297) was
|
|
836
|
+
* the same defect class it fixed — inferring liveness from a correlated field
|
|
837
|
+
* in the renderer. Callers must use this predicate rather than re-deriving the
|
|
838
|
+
* pair; the terminal-status structural ratchet in
|
|
839
|
+
* `src/__tests__/terminal-status-consistency.test.ts` fails a local copy.
|
|
840
|
+
*/
|
|
841
|
+
export function isUnsettledPartial(
|
|
842
|
+
details: { exitCode: number; errorCategory?: ErrorCategory } | undefined,
|
|
843
|
+
): boolean {
|
|
844
|
+
return !details || (details.exitCode === -1 && !details.errorCategory);
|
|
845
|
+
}
|
|
846
|
+
|
|
751
847
|
/**
|
|
752
848
|
* Issue #179 (review ratchet): the NARROWER question — was the terminal turn a
|
|
753
849
|
* provider/model error specifically?
|
|
@@ -952,6 +1048,17 @@ export function isSubagentRunShape(value: unknown): value is SubagentRun {
|
|
|
952
1048
|
return true;
|
|
953
1049
|
}
|
|
954
1050
|
|
|
1051
|
+
/**
|
|
1052
|
+
* Option B U1 (D3): the ONE interrupted-state predicate. Status deliberately
|
|
1053
|
+
* stays "running" (a new status value would be dropped by old readers and
|
|
1054
|
+
* misread as resolved by the `!== "running"` guards); the additive
|
|
1055
|
+
* `interruptedAt` mark is the only signal. Consumers must key on THIS helper,
|
|
1056
|
+
* never on a raw status compare.
|
|
1057
|
+
*/
|
|
1058
|
+
export function isInterruptedRun(run: SubagentRun): boolean {
|
|
1059
|
+
return run.status === "running" && !!run.interruptedAt;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
955
1062
|
/**
|
|
956
1063
|
* Type guard: checks if a value is a valid MultiSubagentDetails shape.
|
|
957
1064
|
*/
|
package/src/unit-run.ts
CHANGED
|
@@ -30,6 +30,7 @@ import type {
|
|
|
30
30
|
} from "./types";
|
|
31
31
|
import { getFinalOutput, classifyError } from "./types";
|
|
32
32
|
import { snapshotOriginalParams } from "./params";
|
|
33
|
+
import { currentProcessOwner } from "./recovery";
|
|
33
34
|
import { capOutput, stripAnsi, buildCrashResult } from "./sanitize";
|
|
34
35
|
import { finalizeRunRecord, pruneSessionRuns } from "./history";
|
|
35
36
|
import type { Logger } from "./logging";
|
|
@@ -123,6 +124,13 @@ export function createUnitRun(
|
|
|
123
124
|
// same priority the run entry itself carried.
|
|
124
125
|
priority: source.priority ?? priorityFloor,
|
|
125
126
|
startedAt: new Date().toISOString(),
|
|
127
|
+
// Option B U1: durability identity. Fan-out units are never a retry
|
|
128
|
+
// target (a retried unit degrades to a single foreground run), so a
|
|
129
|
+
// fresh dispatchId/attempt=1 and a fresh child marker always apply.
|
|
130
|
+
dispatchId: crypto.randomUUID(),
|
|
131
|
+
attempt: 1,
|
|
132
|
+
owner: currentProcessOwner(),
|
|
133
|
+
childMarker: crypto.randomUUID(),
|
|
126
134
|
// The execution-shape fields (background/gitMode/approvalMode/force) are
|
|
127
135
|
// deliberately omitted: a fan-out unit retries as a SINGLE run, so it must
|
|
128
136
|
// not inherit the fan-out's background-ness. A retry that wants background
|