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/AGENT.md +5 -0
- package/README.md +20 -8
- package/package.json +11 -9
- package/src/concurrency.ts +1 -0
- package/src/diff.ts +1 -0
- package/src/event-bus.ts +8 -2
- package/src/git.ts +68 -0
- package/src/history.ts +59 -1
- package/src/index.ts +244 -406
- package/src/kill-escalation.ts +94 -0
- package/src/logging.ts +58 -4
- package/src/messaging.ts +1 -0
- package/src/metrics.ts +1 -0
- package/src/model-availability.ts +1 -0
- package/src/notify-completion.ts +1 -0
- package/src/params.ts +1 -0
- package/src/paths.ts +1 -0
- package/src/preflight.ts +30 -14
- package/src/prelude.ts +1 -0
- package/src/presets.ts +1 -0
- package/src/proc.ts +140 -0
- package/src/prompt.ts +1 -0
- package/src/recovery-engine.ts +262 -0
- package/src/recovery.ts +325 -0
- package/src/reports.ts +64 -8
- package/src/router.ts +1 -0
- package/src/run-registry.ts +269 -0
- package/src/runner.ts +207 -16
- package/src/sanitize.ts +54 -3
- package/src/scheduler.ts +1 -0
- package/src/schema.ts +254 -0
- package/src/session-manager.ts +142 -111
- package/src/state.ts +52 -7
- package/src/templates.ts +1 -0
- package/src/transcript-path.ts +41 -0
- package/src/transcript-tail.ts +1 -0
- package/src/transcript.ts +14 -12
- package/src/tui-format.ts +1 -0
- package/src/tui.ts +231 -296
- package/src/types.ts +130 -2
- package/src/unit-run.ts +9 -0
- package/src/validate.ts +16 -2
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
|
|
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
|
|
394
|
-
*
|
|
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;
|