brl-subagent 2.3.7 → 2.3.8
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 -4
- package/README.md +18 -5
- package/package.json +5 -5
- package/src/history.ts +8 -1
- package/src/index.ts +757 -386
- package/src/notify-completion.ts +7 -13
- package/src/session-manager.ts +11 -26
- package/src/state.ts +42 -2
- package/src/tui.ts +1 -1
- package/src/types.ts +21 -0
package/src/notify-completion.ts
CHANGED
|
@@ -26,6 +26,7 @@ import type {
|
|
|
26
26
|
import { truncateTail } from "./transcript-tail";
|
|
27
27
|
import { formatRunDuration } from "./history";
|
|
28
28
|
import { classifyTerminalOutcome } from "./types";
|
|
29
|
+
import { resolveTerminalRunEntry } from "./state";
|
|
29
30
|
|
|
30
31
|
/** Terminal statuses the completion message can carry. */
|
|
31
32
|
export type CompletionStatus = "completed" | "failed" | "stopped";
|
|
@@ -80,24 +81,17 @@ export function normalizeCompletionStatus(status: AgentStatus): CompletionStatus
|
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
/**
|
|
83
|
-
* Resolve the finalized entry for a run id among the raw
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* append order — the spawn entry comes FIRST (verified empirically) — so a
|
|
89
|
-
* plain `.find(r => r.id === id)` matches the spawn entry and never sees the
|
|
90
|
-
* stamped errorCategory/cost/duration/tokens. Prefer the terminal entry
|
|
91
|
-
* (status !== "running"); when none is terminal yet, fall back to the
|
|
92
|
-
* matching spawn entry (the pre-finalize stopped-run shape).
|
|
84
|
+
* Resolve the finalized (terminal-preferring) entry for a run id among the raw
|
|
85
|
+
* getRunEntries list. Thin delegate to the shared `resolveTerminalRunEntry`
|
|
86
|
+
* rule in src/state.ts (the core module owns the preference rule; see there
|
|
87
|
+
* for the two-entry spawn/final append order). Kept under this name for the
|
|
88
|
+
* completion wake path and its existing tests.
|
|
93
89
|
*/
|
|
94
90
|
export function resolveRunEntry(
|
|
95
91
|
entries: SubagentRun[],
|
|
96
92
|
id: string,
|
|
97
93
|
): SubagentRun | undefined {
|
|
98
|
-
|
|
99
|
-
if (matching.length === 0) return undefined;
|
|
100
|
-
return matching.find((r) => r.status !== "running") ?? matching[0];
|
|
94
|
+
return resolveTerminalRunEntry(entries, id);
|
|
101
95
|
}
|
|
102
96
|
|
|
103
97
|
/**
|
package/src/session-manager.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { randomUUID } from 'crypto';
|
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync } from 'fs';
|
|
4
4
|
import type { BackgroundAgent, AgentStatus, GitMode, SubagentResult, SubagentRun, ThinkingLevel, SubagentToolOptions, UsageStats, ErrorCategory } from './types';
|
|
5
|
-
import { EMPTY_USAGE, CUSTOM_ENTRY_TYPES, classifyError, classifyTerminalOutcome, isSubagentError, isProviderError, SUBAGENT_ABORTED_MESSAGE } from './types';
|
|
5
|
+
import { EMPTY_USAGE, CUSTOM_ENTRY_TYPES, classifyError, classifyTerminalOutcome, isSubagentError, isProviderError, coherentFailureReason, SUBAGENT_ABORTED_MESSAGE } from './types';
|
|
6
6
|
import { accumulateUsage } from './runner';
|
|
7
7
|
import * as eventBus from './event-bus';
|
|
8
8
|
import * as transcript from './transcript';
|
|
@@ -274,23 +274,6 @@ function defaultTerminalMessage(stopReason: string | undefined): string {
|
|
|
274
274
|
}
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
-
/**
|
|
278
|
-
* Issue #179 (C1 fix): a failed run entry/result must carry a terminal reason
|
|
279
|
-
* that AGREES with its status — never undefined, never "stop". Any reason the
|
|
280
|
-
* classifier already treats as a failure (error / length / toolUse / deferred
|
|
281
|
-
* / pending / aborted) is coherent and kept; a missing or success reason is
|
|
282
|
-
* coerced to the honest failure reason derived from the classified category.
|
|
283
|
-
*/
|
|
284
|
-
function coherentFailureReason(
|
|
285
|
-
stopReason: string | undefined,
|
|
286
|
-
errorCategory: ErrorCategory | undefined,
|
|
287
|
-
): string {
|
|
288
|
-
if (stopReason && classifyTerminalOutcome(stopReason).status !== 'completed') {
|
|
289
|
-
return stopReason;
|
|
290
|
-
}
|
|
291
|
-
return errorCategory === 'aborted' ? 'aborted' : 'error';
|
|
292
|
-
}
|
|
293
|
-
|
|
294
277
|
/**
|
|
295
278
|
* Extract the final assistant text from a session's messages.
|
|
296
279
|
* Falls back to the LAST assistant message with non-empty text content — the
|
|
@@ -558,7 +541,7 @@ export async function spawnBackgroundSession(
|
|
|
558
541
|
|
|
559
542
|
// Issue #98: background runs must be retry-able — persist a session run
|
|
560
543
|
// entry at spawn (id == agent id, so retryRunId: <agent-id> resolves via
|
|
561
|
-
// state.
|
|
544
|
+
// state.findSpawnRunById → getRunEntries). The entry lives in the same session
|
|
562
545
|
// custom-entry store the foreground path writes to (state.persistRun); the
|
|
563
546
|
// background path previously never wrote there, so retries silently no-oped.
|
|
564
547
|
const startedAtMs = agent.startedAt; // reuse the agent record's start clock
|
|
@@ -610,13 +593,15 @@ export async function spawnBackgroundSession(
|
|
|
610
593
|
})
|
|
611
594
|
: undefined)
|
|
612
595
|
: undefined;
|
|
596
|
+
// Issue #187: capture the RAW terminal reason BEFORE coercion. `finalTurnError`
|
|
597
|
+
// means "the final turn was a PROVIDER error" (isProviderError); deriving it
|
|
598
|
+
// from the COERCED reason would turn a missing/success reason into 'error' and
|
|
599
|
+
// mislabel a non-provider failure (hard-cap timeout, spawn throw) as one.
|
|
600
|
+
const rawStopReason = terminal?.stopReason ?? agent.result?.stopReason;
|
|
613
601
|
const stopReason =
|
|
614
602
|
status === "failed"
|
|
615
|
-
? coherentFailureReason(
|
|
616
|
-
|
|
617
|
-
errorCategory,
|
|
618
|
-
)
|
|
619
|
-
: terminal?.stopReason ?? agent.result?.stopReason;
|
|
603
|
+
? coherentFailureReason(rawStopReason, errorCategory)
|
|
604
|
+
: rawStopReason;
|
|
620
605
|
const entry: SubagentRun = {
|
|
621
606
|
...run,
|
|
622
607
|
status,
|
|
@@ -628,7 +613,7 @@ export async function spawnBackgroundSession(
|
|
|
628
613
|
// are already in the usage data; recording them here makes "did nothing"
|
|
629
614
|
// (turns 0) explicit versus "did work then died" (turns > 0, error).
|
|
630
615
|
turns: usage.turns,
|
|
631
|
-
finalTurnError: isProviderError(
|
|
616
|
+
finalTurnError: isProviderError(rawStopReason),
|
|
632
617
|
errorMessage: error,
|
|
633
618
|
finishedAt: new Date().toISOString(),
|
|
634
619
|
durationMs: Date.now() - startedAtMs,
|
|
@@ -944,7 +929,7 @@ export async function spawnBackgroundSession(
|
|
|
944
929
|
cleanupWorkBranch();
|
|
945
930
|
// Issue #98 (review F1): a synchronous prompt() throw never reaches the
|
|
946
931
|
// settle handlers — finalize the run entry so no zombie 'running' entry
|
|
947
|
-
// survives (
|
|
932
|
+
// survives (findSpawnRunById would otherwise resolve and retry a run that
|
|
948
933
|
// never started).
|
|
949
934
|
finalizeRunEntry('failed', sanitizeErrorMessage((err as Error).message, effectiveCwd));
|
|
950
935
|
throw err;
|
package/src/state.ts
CHANGED
|
@@ -62,6 +62,30 @@ const POLLER_TICK_MS = 2000;
|
|
|
62
62
|
*/
|
|
63
63
|
export const STALE_FINALIZE_GRACE_MS = POLLER_TICK_MS + 1000;
|
|
64
64
|
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Run-entry lookup contract (issue #185)
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Terminal-preferring run-entry lookup — the ONE implementation of the
|
|
71
|
+
* preference rule.
|
|
72
|
+
*
|
|
73
|
+
* Each run writes TWO custom entries sharing its id, in append order: a spawn
|
|
74
|
+
* entry (status "running", carrying `originalParams`) FIRST, then the final
|
|
75
|
+
* entry (status "done"/"failed", stamped with fullOutput/cost/duration) at
|
|
76
|
+
* settle. Prefer the terminal entry; when none is terminal yet, fall back to
|
|
77
|
+
* the first match (the pre-finalize spawn shape). Returns undefined when no
|
|
78
|
+
* entry matches.
|
|
79
|
+
*/
|
|
80
|
+
export function resolveTerminalRunEntry(
|
|
81
|
+
entries: SubagentRun[],
|
|
82
|
+
id: string,
|
|
83
|
+
): SubagentRun | undefined {
|
|
84
|
+
const matching = entries.filter((r) => r.id === id);
|
|
85
|
+
if (matching.length === 0) return undefined;
|
|
86
|
+
return matching.find((r) => r.status !== "running") ?? matching[0];
|
|
87
|
+
}
|
|
88
|
+
|
|
65
89
|
// ---------------------------------------------------------------------------
|
|
66
90
|
// SessionState — session-bound mutable state
|
|
67
91
|
// ---------------------------------------------------------------------------
|
|
@@ -282,10 +306,26 @@ export class SessionState {
|
|
|
282
306
|
return cleanupRuns(runs, this.config.maxHistoryEntries);
|
|
283
307
|
}
|
|
284
308
|
|
|
285
|
-
|
|
309
|
+
/**
|
|
310
|
+
* Spawn-oriented lookup: the FIRST entry matching `id`. Runs append the
|
|
311
|
+
* spawn entry (status "running", carrying `originalParams`) before the
|
|
312
|
+
* final entry, so retry resolution — which reads `originalParams` via
|
|
313
|
+
* resolveRetryParams — must use THIS lookup, not the terminal-preferring
|
|
314
|
+
* one.
|
|
315
|
+
*/
|
|
316
|
+
findSpawnRunById(ctx: ExtensionContext, id: string): SubagentRun | undefined {
|
|
286
317
|
return this.getRunEntries(ctx).find((r) => r.id === id);
|
|
287
318
|
}
|
|
288
319
|
|
|
320
|
+
/**
|
|
321
|
+
* Terminal-preferring lookup: the finalized entry (status !== "running") when
|
|
322
|
+
* present, else the first match. Use for fields stamped at settle — `status`,
|
|
323
|
+
* `fullOutput`, `cost`, `durationMs`, `errorCategory`.
|
|
324
|
+
*/
|
|
325
|
+
findTerminalRunById(ctx: ExtensionContext, id: string): SubagentRun | undefined {
|
|
326
|
+
return resolveTerminalRunEntry(this.getRunEntries(ctx), id);
|
|
327
|
+
}
|
|
328
|
+
|
|
289
329
|
// -------------------------------------------------------------------
|
|
290
330
|
// Live subagent tracking
|
|
291
331
|
// -------------------------------------------------------------------
|
|
@@ -569,7 +609,7 @@ export function sweepStaleLiveSubagents(
|
|
|
569
609
|
} else {
|
|
570
610
|
// No agent record — foreground run or a removed record. A foreground
|
|
571
611
|
// run is live only while its run entry says 'running'.
|
|
572
|
-
const runStatus = state.
|
|
612
|
+
const runStatus = state.findTerminalRunById(session.ctx, id)?.status;
|
|
573
613
|
if (runStatus !== "running" && state.finalizeStaleLiveSubagent(id)) {
|
|
574
614
|
finalized++;
|
|
575
615
|
}
|
package/src/tui.ts
CHANGED
|
@@ -1710,7 +1710,7 @@ export async function showAgentDetail(
|
|
|
1710
1710
|
// path persists it), mirroring the background path's
|
|
1711
1711
|
// `agent?.finalOutput` fallback.
|
|
1712
1712
|
const runOutput =
|
|
1713
|
-
cachedTail || state.
|
|
1713
|
+
cachedTail || state.findTerminalRunById(ctx, agentId)?.fullOutput || "";
|
|
1714
1714
|
const tailLines = runOutput.split("\n").filter(Boolean).slice(-10);
|
|
1715
1715
|
if (tailLines.length > 0) {
|
|
1716
1716
|
container.addChild(new Spacer(1));
|
package/src/types.ts
CHANGED
|
@@ -808,6 +808,27 @@ export function classifyTerminalOutcome(
|
|
|
808
808
|
}
|
|
809
809
|
}
|
|
810
810
|
|
|
811
|
+
/**
|
|
812
|
+
* Issue #179 (C1 fix): a failed run entry/result must carry a terminal reason
|
|
813
|
+
* that AGREES with its status — never undefined, never "stop". Any reason the
|
|
814
|
+
* classifier already treats as a failure (error / length / toolUse / deferred
|
|
815
|
+
* / pending / aborted) is coherent and kept; a missing or success reason is
|
|
816
|
+
* coerced to the honest failure reason derived from the classified category.
|
|
817
|
+
*
|
|
818
|
+
* Issue #187: lives here (not session-manager) so BOTH the background
|
|
819
|
+
* finalize (session-manager.ts) and the foreground finalize (history.ts) apply
|
|
820
|
+
* the SAME rule through one implementation instead of two copies.
|
|
821
|
+
*/
|
|
822
|
+
export function coherentFailureReason(
|
|
823
|
+
stopReason: string | undefined,
|
|
824
|
+
errorCategory: ErrorCategory | undefined,
|
|
825
|
+
): string {
|
|
826
|
+
if (stopReason && classifyTerminalOutcome(stopReason).status !== "completed") {
|
|
827
|
+
return stopReason;
|
|
828
|
+
}
|
|
829
|
+
return errorCategory === "aborted" ? "aborted" : "error";
|
|
830
|
+
}
|
|
831
|
+
|
|
811
832
|
export function formatModel(m: { provider: string; id: string } | undefined): string {
|
|
812
833
|
return m ? `${m.provider}/${m.id}` : "Not set (will use main agent\u2019s model)";
|
|
813
834
|
}
|