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/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