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.
@@ -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 getRunEntries list.
84
- *
85
- * Each background run writes TWO custom entries sharing the same id and
86
- * startedAt: a spawn entry (status "running") and a final entry (status
87
- * "done"/"failed", stamped by finalizeRunRecord). The raw list preserves
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
- const matching = entries.filter((r) => r.id === id);
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
  /**
@@ -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.findRunById → getRunEntries). The entry lives in the same session
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
- terminal?.stopReason ?? agent.result?.stopReason,
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(stopReason),
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 (findRunById would otherwise resolve and retry a run that
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
- findRunById(ctx: ExtensionContext, id: string): SubagentRun | undefined {
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.getRunEntries(session.ctx).find((r) => r.id === id)?.status;
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.findRunById(ctx, agentId)?.fullOutput || "";
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
  }