brl-subagent 2.3.9 → 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
@@ -1,3 +1,4 @@
1
+ // Purpose: Shared types, constants, and the error/termination classification taxonomy.
1
2
  /**
2
3
  * brl-subagent — Type Definitions
3
4
  *
@@ -6,6 +7,8 @@
6
7
 
7
8
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
8
9
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
10
+ import type { Static } from "typebox";
11
+ import type { delegateTaskParamsSchema } from "./schema";
9
12
  import type { TranscriptMessage } from "./transcript-tail";
10
13
 
11
14
  // ---------------------------------------------------------------------------
@@ -59,6 +62,15 @@ export type ErrorCategory =
59
62
  // literal in sync with classifyError — do NOT reword one without the other.
60
63
  export const SUBAGENT_ABORTED_MESSAGE = "Subagent aborted by user";
61
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
+
62
74
  /**
63
75
  * Classify a subagent result into an error category based on its errorMessage,
64
76
  * stopReason, exitCode, and stderr content. Inspects patterns in priority order.
@@ -93,6 +105,15 @@ export function classifyError(result: SubagentResult): ErrorCategory {
93
105
  // clobbered by the exitError/unknown fallbacks below.
94
106
  if (msg.includes("aborted by user")) return "aborted";
95
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
+
96
117
  if (msg.includes("model not found") || msg.includes("model unavailable"))
97
118
  return "model_unavailable";
98
119
 
@@ -240,6 +261,21 @@ export interface SubagentState {
240
261
  */
241
262
  export type ToolResult<T = unknown> = AgentToolResult<T> & { isError?: boolean };
242
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
+
243
279
  export interface SubagentRun {
244
280
  id: string;
245
281
  task: string;
@@ -275,6 +311,22 @@ export interface SubagentRun {
275
311
  // Issue #114: per-unit priority (may be absent — the run's slot priority
276
312
  // then fell back to the call-level default, which is not recorded here).
277
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;
278
330
  startedAt: string;
279
331
  finishedAt?: string;
280
332
  durationMs?: number;
@@ -458,7 +510,7 @@ export interface GraphTask {
458
510
  noBuiltinTools?: boolean;
459
511
  systemPrompt?: string;
460
512
  inheritSystemPrompt?: boolean;
461
- dependsOn: string[];
513
+ dependsOn?: string[];
462
514
  }
463
515
 
464
516
  /** One wave of tasks executed together in a graph. */
@@ -492,6 +544,24 @@ export type DelegateTaskDetails =
492
544
  | GraphDetails
493
545
  | undefined;
494
546
 
547
+ /**
548
+ * The delegate_task handler's params (issue #229 item 4) — the type the
549
+ * handler's inline block used to spell out, hoisted here so it is nameable.
550
+ *
551
+ * Why it matters: `approvalMode` is in the registered schema and in
552
+ * KNOWN_DELEGATE_KEYS, and reaches execute() at runtime through the spread —
553
+ * but a reader of `params` inside the handler could not see it. The key-set
554
+ * is compile-time ratcheted in src/index.ts against KNOWN_DELEGATE_KEYS
555
+ * (schema ↔ KNOWN_DELEGATE_KEYS ↔ this type); the missing key is invisible at
556
+ * runtime, so only the compiler can catch its removal.
557
+ *
558
+ * A type ALIAS, not an interface: the handler passes `params` to
559
+ * findUnknownParams/resolveSubagentParams (Record<string, unknown> params),
560
+ * and only a type alias gets the implicit index signature an interface
561
+ * lacks.
562
+ */
563
+ export type DelegateTaskParams = Static<typeof delegateTaskParamsSchema>;
564
+
495
565
  // ---------------------------------------------------------------------------
496
566
  // Resolved params (after preset merging + validation)
497
567
  // ---------------------------------------------------------------------------
@@ -579,6 +649,8 @@ export const EMPTY_USAGE: UsageStats = {
579
649
  export const NAV_FOOTER = "\u2191\u2193 navigate \u2022 enter select \u2022 esc cancel";
580
650
 
581
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;
582
654
  export const STATUS_RESET_DELAY_MS = 3000;
583
655
  export const TEMP_FILE_MODE = 0o600;
584
656
  export const TASK_PREVIEW_MAX_LENGTH = 80;
@@ -636,7 +708,7 @@ export const RESERVED_COMMAND_NAMES = new Set([
636
708
  "model", "thinking", "concurrency", "depth", "history", "monitor",
637
709
  "preset", "retry", "reset", "priority", "templates", "dashboard", "approval",
638
710
  "costlimit", "historyentries", "sla", "completionnotify",
639
- "graph", "sla-stats",
711
+ "graph", "sla-stats", "compliance",
640
712
  ]);
641
713
 
642
714
  // ---------------------------------------------------------------------------
@@ -663,6 +735,17 @@ export interface BackgroundAgent {
663
735
  finalOutput?: string;
664
736
  /** @internal — session reference for live monitor polling */
665
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;
666
749
  }
667
750
 
668
751
  // ---------------------------------------------------------------------------
@@ -727,6 +810,40 @@ export function isSubagentError(result: SubagentResult): boolean {
727
810
  || result.stopReason === "pending";
728
811
  }
729
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
+
730
847
  /**
731
848
  * Issue #179 (review ratchet): the NARROWER question — was the terminal turn a
732
849
  * provider/model error specifically?
@@ -931,6 +1048,17 @@ export function isSubagentRunShape(value: unknown): value is SubagentRun {
931
1048
  return true;
932
1049
  }
933
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
+
934
1062
  /**
935
1063
  * Type guard: checks if a value is a valid MultiSubagentDetails shape.
936
1064
  */
package/src/unit-run.ts CHANGED
@@ -1,3 +1,4 @@
1
+ // Purpose: Per-unit run-entry helpers shared by chain, parallel, and graph modes.
1
2
  /**
2
3
  * Per-unit run-entry helpers extracted from src/index.ts (issue #132).
3
4
  *
@@ -29,6 +30,7 @@ import type {
29
30
  } from "./types";
30
31
  import { getFinalOutput, classifyError } from "./types";
31
32
  import { snapshotOriginalParams } from "./params";
33
+ import { currentProcessOwner } from "./recovery";
32
34
  import { capOutput, stripAnsi, buildCrashResult } from "./sanitize";
33
35
  import { finalizeRunRecord, pruneSessionRuns } from "./history";
34
36
  import type { Logger } from "./logging";
@@ -122,6 +124,13 @@ export function createUnitRun(
122
124
  // same priority the run entry itself carried.
123
125
  priority: source.priority ?? priorityFloor,
124
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(),
125
134
  // The execution-shape fields (background/gitMode/approvalMode/force) are
126
135
  // deliberately omitted: a fan-out unit retries as a SINGLE run, so it must
127
136
  // not inherit the fan-out's background-ness. A retry that wants background
package/src/validate.ts CHANGED
@@ -1,3 +1,4 @@
1
+ // Purpose: H1 pre-task validation: deterministic tool/thinking/git checks and failure post-mortems.
1
2
  /**
2
3
  * H1: Pre-task validation
3
4
  *
@@ -382,6 +383,17 @@ export function validatePreTask(config: ValidateConfig): ValidateResult {
382
383
  };
383
384
  }
384
385
 
386
+ /**
387
+ * Default deadline for a BACKGROUND run that specifies no explicit `timeout`
388
+ * (issue #28 orphan protection). This is a DEFAULT, not a ceiling: an explicit
389
+ * timeout is honored verbatim (after normalizeTimeout), however long it is
390
+ * (issue #240). Callers must NOT treat this as an upper bound.
391
+ *
392
+ * Kept next to normalizeTimeout (same setTimeout-boundary concern) and shared
393
+ * by the session-manager W3 timer and the index.ts hard-cap timer.
394
+ */
395
+ export const DEFAULT_BACKGROUND_DEADLINE_MS = 30 * 60 * 1000;
396
+
385
397
  /**
386
398
  * Normalize a user-supplied timeout to a safe deadline value, or undefined for
387
399
  * "no timeout". Guards the setTimeout boundary — Node fires `setTimeout(fn, 0)`,
@@ -390,8 +402,10 @@ export function validatePreTask(config: ValidateConfig): ValidateResult {
390
402
  * review M1/m1).
391
403
  *
392
404
  * Returns undefined for: undefined, 0, negative, NaN, Infinity, >=2^31-1.
393
- * Returns the value unchanged otherwise (foreground permits >30min timeouts;
394
- * the background hard cap applies its own 30min Math.min).
405
+ * Returns the value unchanged otherwise. A background run with no explicit
406
+ * timeout (undefined here) falls back to DEFAULT_BACKGROUND_DEADLINE_MS; an
407
+ * explicit timeout is honored verbatim — the 30m default is not a ceiling
408
+ * (issue #240).
395
409
  */
396
410
  export function normalizeTimeout(timeout: number | undefined): number | undefined {
397
411
  if (timeout === undefined) return undefined;