brl-subagent 2.3.6 → 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/src/logging.ts CHANGED
@@ -8,6 +8,9 @@
8
8
  * const log = createLogger("runner", cwd);
9
9
  * log.info("Subagent started", { model, thinkingLevel });
10
10
  * log.error("Subagent failed", { error: err.message });
11
+ *
12
+ * Module-load callers pass no cwd; they call setLogCwd(ctx.cwd) once the
13
+ * session starts and all loggers share <cwd>/.pi/subagent-logs/brl-subagent.log.
11
14
  */
12
15
 
13
16
  import * as fs from "node:fs";
@@ -24,6 +27,12 @@ const MAX_LOG_SIZE = 5 * 1024 * 1024;
24
27
  /** Maximum number of rotated log files to keep */
25
28
  const MAX_LOG_FILES = 5;
26
29
 
30
+ /**
31
+ * The single log file every logger writes to (issue #179). The JSON entry body
32
+ * already carries its `prefix`, so one shared file stays readable.
33
+ */
34
+ const LOG_FILE_NAME = "brl-subagent.log";
35
+
27
36
  /** Log levels in order of verbosity */
28
37
  const LOG_LEVELS: Record<LogLevel, number> = {
29
38
  debug: 0,
@@ -50,23 +59,33 @@ export function setLogLevel(level: LogLevel): void {
50
59
  minLevel = level;
51
60
  }
52
61
 
62
+ // Issue #179 (D6): createLogger() runs at MODULE LOAD (index.ts,
63
+ // session-manager.ts) where the session cwd does not exist yet. The cwd is set
64
+ // later from the session_start hook (ctx.cwd). All logger instances share this
65
+ // module-level value so every entry lands in ONE
66
+ // `<cwd>/.pi/subagent-logs/brl-subagent.log`.
67
+ let logCwd: string | undefined;
68
+
69
+ /**
70
+ * Point file logging at a session cwd (or clear it with undefined). Called from
71
+ * the session-start hook so module-load loggers can write once a cwd exists.
72
+ */
73
+ export function setLogCwd(cwd: string | undefined): void {
74
+ logCwd = cwd;
75
+ }
76
+
77
+ function resolveLogDir(): string | undefined {
78
+ return logCwd ? path.join(logCwd, ".pi", "subagent-logs") : undefined;
79
+ }
80
+
53
81
  /**
54
82
  * Create a logger for a specific module.
55
- * Logs are written to `.pi/subagent-logs/<prefix>.log` relative to `cwd`.
83
+ * Logs are written to `<cwd>/.pi/subagent-logs/brl-subagent.log` relative to the
84
+ * session cwd (set via setLogCwd or the optional `cwd` arg).
56
85
  */
57
86
  export function createLogger(prefix: string, cwd?: string): Logger {
58
- const logDir = cwd ? path.join(cwd, ".pi", "subagent-logs") : undefined;
59
-
60
- // Ensure log directory exists — owner-only (0o700) so other local users
61
- // cannot list log files (F6 / issue #29). File writes below already use
62
- // 0o600.
63
- if (logDir) {
64
- try {
65
- fs.mkdirSync(logDir, { recursive: true, mode: 0o700 });
66
- } catch {
67
- // Can't create log dir — fall back to console-only logging
68
- }
69
- }
87
+ // Compat: an explicit cwd argument still sets the shared location eagerly.
88
+ if (cwd) logCwd = cwd;
70
89
 
71
90
  function log(level: LogLevel, message: string, data?: Record<string, unknown>): void {
72
91
  if (LOG_LEVELS[level] < LOG_LEVELS[minLevel]) return;
@@ -86,10 +105,15 @@ export function createLogger(prefix: string, cwd?: string): Logger {
86
105
  const consoleMethod = level === "error" ? console.error : level === "warn" ? console.warn : console.log;
87
106
  consoleMethod(`[${prefix}] ${level.toUpperCase()}: ${message}`);
88
107
 
89
- // File output
108
+ // File output — resolved on each call because the cwd may be set after
109
+ // createLogger ran (module load) but before the first entry.
110
+ const logDir = resolveLogDir();
90
111
  if (logDir) {
91
112
  try {
92
- const logFile = path.join(logDir, `${prefix}.log`);
113
+ // Owner-only (0o700) so other local users cannot list log files
114
+ // (F6 / issue #29); the file writes below already use 0o600.
115
+ fs.mkdirSync(logDir, { recursive: true, mode: 0o700 });
116
+ const logFile = path.join(logDir, LOG_FILE_NAME);
93
117
  rotateIfNeeded(logFile);
94
118
  fs.appendFileSync(logFile, line + "\n", { encoding: "utf-8", mode: 0o600 });
95
119
  } catch {
@@ -25,6 +25,8 @@ import type {
25
25
  } from "./types";
26
26
  import { truncateTail } from "./transcript-tail";
27
27
  import { formatRunDuration } from "./history";
28
+ import { classifyTerminalOutcome } from "./types";
29
+ import { resolveTerminalRunEntry } from "./state";
28
30
 
29
31
  /** Terminal statuses the completion message can carry. */
30
32
  export type CompletionStatus = "completed" | "failed" | "stopped";
@@ -35,6 +37,8 @@ export interface CompletionMessageDetails {
35
37
  status: CompletionStatus;
36
38
  errorCategory: string;
37
39
  errorMessage?: string;
40
+ /** Issue #179 (D2): the terminal stopReason behind the classified status. */
41
+ stopReason?: string;
38
42
  costUsd?: number;
39
43
  tokensIn?: number;
40
44
  tokensOut?: number;
@@ -59,12 +63,12 @@ const DEDUPE_CAP = 200;
59
63
 
60
64
  /**
61
65
  * Soft directive appended to the content (D3): the conductor should process
62
- * the completion silently unless action is needed; on failure apply the retry
63
- * taxonomy (re-dispatch ≠ retry; Rule 18 governs terminations — ask the user
64
- * before terminating anything).
66
+ * the completion silently unless action is needed. If the run failed, it
67
+ * should investigate the cause before re-dispatching rather than re-issuing an
68
+ * identical task — any re-issue is confirmed with the user first.
65
69
  */
66
70
  const COMPLETION_DIRECTIVE =
67
- "Process this completion silently unless action is needed. If the run failed, apply the retry taxonomy: a re-dispatch is not a retry, and Rule 18 governs terminations — ask the user before terminating anything.";
71
+ "Process this completion silently unless action is needed. If the run failed, investigate before re-dispatching — do not re-issue an identical task without confirming the cause with the user.";
68
72
 
69
73
  /**
70
74
  * Normalize a BackgroundAgent status to a terminal status word. The subscriber
@@ -77,24 +81,17 @@ export function normalizeCompletionStatus(status: AgentStatus): CompletionStatus
77
81
  }
78
82
 
79
83
  /**
80
- * Resolve the finalized entry for a run id among the raw getRunEntries list.
81
- *
82
- * Each background run writes TWO custom entries sharing the same id and
83
- * startedAt: a spawn entry (status "running") and a final entry (status
84
- * "done"/"failed", stamped by finalizeRunRecord). The raw list preserves
85
- * append order — the spawn entry comes FIRST (verified empirically) — so a
86
- * plain `.find(r => r.id === id)` matches the spawn entry and never sees the
87
- * stamped errorCategory/cost/duration/tokens. Prefer the terminal entry
88
- * (status !== "running"); when none is terminal yet, fall back to the
89
- * 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.
90
89
  */
91
90
  export function resolveRunEntry(
92
91
  entries: SubagentRun[],
93
92
  id: string,
94
93
  ): SubagentRun | undefined {
95
- const matching = entries.filter((r) => r.id === id);
96
- if (matching.length === 0) return undefined;
97
- return matching.find((r) => r.status !== "running") ?? matching[0];
94
+ return resolveTerminalRunEntry(entries, id);
98
95
  }
99
96
 
100
97
  /**
@@ -128,23 +125,50 @@ export function buildCompletionMessage(
128
125
  agent: BackgroundAgent,
129
126
  run: SubagentRun | undefined,
130
127
  ): CompletionMessagePayload {
131
- const status = normalizeCompletionStatus(agent.status);
128
+ let status = normalizeCompletionStatus(agent.status);
129
+ // Issue #179 (D2): the notification category must NEVER default to success
130
+ // merely because `agent.status === "completed"`. The SDK resolves a mid-run
131
+ // provider death, so the run entry is the authoritative classified reason —
132
+ // downgrade a stale "completed" when the run says otherwise.
133
+ if (status === "completed" && run) {
134
+ const outcome = classifyTerminalOutcome(run.stopReason, run.errorMessage);
135
+ if (outcome.status !== "completed") {
136
+ status = outcome.status;
137
+ } else if (run.status === "failed") {
138
+ status = "failed";
139
+ }
140
+ }
132
141
  // BackgroundAgent has no `label` field — the caller's label is `description`
133
142
  // (set from params.description at spawn). Fall back to the id.
134
143
  const label = agent.description || agent.id;
135
144
  // completed → "success"; failed/stopped → classified category from the run
136
145
  // record (finalizeRunEntry stamps it), falling back to "unknown".
137
146
  const errorCategory =
138
- status === "completed" ? "success" : (run?.originalParams?.errorCategory ?? "unknown");
147
+ status === "completed"
148
+ ? "success"
149
+ : (run?.errorCategory ?? run?.originalParams?.errorCategory ?? "unknown");
139
150
  const durationMs = run?.durationMs;
140
151
  const costUsd = run?.cost;
141
152
  const tokensIn = run?.tokensIn;
142
153
  const tokensOut = run?.tokensOut;
154
+ const stopReason = run?.stopReason;
143
155
  const errorMessage = status === "completed" ? undefined : agent.error ?? run?.errorMessage;
144
156
 
145
157
  const summary = buildSummaryLine(label, agent.id, status, durationMs, costUsd, errorCategory);
146
- const tail = truncateTail(agent.finalOutput ?? run?.fullOutput ?? "", 15);
158
+ // Issue #179 (C1 fix): truncateTail keeps the LAST 15 lines, but the failure
159
+ // headline is PREPENDED to the stored output — long earlier-turn prose would
160
+ // otherwise push the failure reason out of the notified body. Peel it off
161
+ // before tailing and re-add it as its own part so the reason is never
162
+ // truncated away.
163
+ const stored = agent.finalOutput ?? run?.fullOutput ?? "";
164
+ const headline = status === "completed" ? undefined : errorMessage;
165
+ const body =
166
+ headline && stored.startsWith(headline)
167
+ ? stored.slice(headline.length).replace(/^\n+/, "")
168
+ : stored;
169
+ const tail = truncateTail(body, 15);
147
170
  const parts = [summary];
171
+ if (headline) parts.push(headline);
148
172
  if (tail) parts.push(tail);
149
173
  parts.push(COMPLETION_DIRECTIVE);
150
174
  const content = parts.join("\n\n");
@@ -154,6 +178,7 @@ export function buildCompletionMessage(
154
178
  status,
155
179
  errorCategory,
156
180
  ...(errorMessage ? { errorMessage } : {}),
181
+ ...(stopReason ? { stopReason } : {}),
157
182
  ...(costUsd !== undefined ? { costUsd } : {}),
158
183
  ...(tokensIn !== undefined ? { tokensIn } : {}),
159
184
  ...(tokensOut !== undefined ? { tokensOut } : {}),
package/src/runner.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  SIGKILL_GRACE_MS,
28
28
  TEMP_FILE_MODE,
29
29
  MAX_TEMP_DIR_AGE_MS,
30
+ isProviderError,
30
31
  classifyError,
31
32
  SUBAGENT_ABORTED_MESSAGE,
32
33
  } from "./types";
@@ -666,7 +667,7 @@ export function parseSubagentLine(
666
667
  function countModelErrorTurns(messages: Array<Record<string, unknown>>): number {
667
668
  let count = 0;
668
669
  for (const msg of messages) {
669
- if (msg.role === "assistant" && msg.stopReason === "error") count++;
670
+ if (msg.role === "assistant" && isProviderError(msg.stopReason as string | undefined)) count++;
670
671
  }
671
672
  return count;
672
673
  }
@@ -1,8 +1,8 @@
1
1
  import { randomUUID } from 'crypto';
2
2
  import { join } from 'path';
3
3
  import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync } from 'fs';
4
- import type { BackgroundAgent, AgentStatus, GitMode, SubagentResult, SubagentRun, ThinkingLevel, SubagentToolOptions, UsageStats } from './types';
5
- import { EMPTY_USAGE, CUSTOM_ENTRY_TYPES, classifyError, SUBAGENT_ABORTED_MESSAGE } from './types';
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, 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';
@@ -230,7 +230,10 @@ export function setAgentResult(id: string, result: SubagentResult): BackgroundAg
230
230
  if (!agent) return null;
231
231
 
232
232
  agent.result = result;
233
- agent.status = result.exitCode === 0 ? 'completed' : 'failed';
233
+ // Issue #179 (D1): honor the full terminal reason (stopReason error/aborted/
234
+ // length/incomplete), not exitCode alone — a mid-run provider death resolves
235
+ // with exitCode 0 and was previously recorded as 'completed'.
236
+ agent.status = isSubagentError(result) ? 'failed' : 'completed';
234
237
  agent.completedAt = Date.now();
235
238
 
236
239
  agents.set(id, agent);
@@ -238,6 +241,39 @@ export function setAgentResult(id: string, result: SubagentResult): BackgroundAg
238
241
  return agent;
239
242
  }
240
243
 
244
+ /**
245
+ * Issue #179 (D4): when a run ends in a failure, the prose extracted by
246
+ * extractFinalOutput may come from an EARLIER turn — the failing turn carries
247
+ * no text of its own. Prefixing the failure reason makes the output honest
248
+ * instead of a plausible-looking success narrative.
249
+ */
250
+ export function buildFailureHeadline(reason: string, output: string): string {
251
+ const trimmed = output.trim();
252
+ return trimmed ? `${reason}\n\n${trimmed}` : reason;
253
+ }
254
+
255
+ /**
256
+ * Issue #179 (D3/D4): a run that ended without a terminal answer must still
257
+ * record WHY. When the provider supplied no message, synthesize one from the
258
+ * stopReason so the run entry never claims a failure with an empty reason.
259
+ */
260
+ function defaultTerminalMessage(stopReason: string | undefined): string {
261
+ switch (stopReason) {
262
+ case 'error':
263
+ return 'Run ended with a provider error (stopReason "error")';
264
+ case 'length':
265
+ return 'Run ended early: output length limit reached (stopReason "length")';
266
+ case 'toolUse':
267
+ return 'Run ended mid-turn without a final answer (stopReason "toolUse")';
268
+ case 'deferred':
269
+ return 'Run ended deferred without a final answer (stopReason "deferred")';
270
+ case 'pending':
271
+ return 'Run ended while still pending (stopReason "pending")';
272
+ default:
273
+ return 'Run ended without completing';
274
+ }
275
+ }
276
+
241
277
  /**
242
278
  * Extract the final assistant text from a session's messages.
243
279
  * Falls back to the LAST assistant message with non-empty text content — the
@@ -505,7 +541,7 @@ export async function spawnBackgroundSession(
505
541
 
506
542
  // Issue #98: background runs must be retry-able — persist a session run
507
543
  // entry at spawn (id == agent id, so retryRunId: <agent-id> resolves via
508
- // state.findRunById → getRunEntries). The entry lives in the same session
544
+ // state.findSpawnRunById → getRunEntries). The entry lives in the same session
509
545
  // custom-entry store the foreground path writes to (state.persistRun); the
510
546
  // background path previously never wrote there, so retries silently no-oped.
511
547
  const startedAtMs = agent.startedAt; // reuse the agent record's start clock
@@ -531,29 +567,53 @@ export async function spawnBackgroundSession(
531
567
  // ("done"/"failed"). The guard keeps the entry single-finalized even when a
532
568
  // settle handler throws after finalizing (markTerminalBestEffort catch-all).
533
569
  let runFinalized = false;
534
- const finalizeRunEntry = (status: "done" | "failed", error?: string): void => {
570
+ const finalizeRunEntry = (
571
+ status: "done" | "failed",
572
+ error?: string,
573
+ terminal?: { stopReason?: string; errorCategory?: ErrorCategory },
574
+ ): void => {
535
575
  if (runFinalized) return;
536
576
  runFinalized = true;
537
577
  const usage = agent.result?.usage ?? EMPTY_USAGE;
538
578
  const finalOutput = agent.finalOutput ?? "";
539
- // Issue #120 (Track 1): stamp the honest errorCategory on the background run
540
- // record the same way the foreground path does (index.ts sets
541
- // run.originalParams.errorCategory = classifyError(result)). Previously the
542
- // background entry never carried a category, so background failures were
543
- // invisible to the SLA errorCategoryBreakdown and to failure diagnostics.
579
+ // Issue #179 (D1/D2): the classifier's explicit category wins (it knows the
580
+ // terminal stopReason); otherwise fall back to the legacy message-based
581
+ // classification (refused spawns, sync throws, preflight rejections, the
582
+ // catch-all) so no failure is ever left uncategorized.
544
583
  const errorCategory =
545
- status === "failed" && error
546
- ? classifyError({
547
- messages: [],
548
- usage: { ...EMPTY_USAGE },
549
- exitCode: 1,
550
- stderr: "",
551
- errorMessage: error,
552
- })
584
+ status === "failed"
585
+ ? terminal?.errorCategory ??
586
+ (error
587
+ ? classifyError({
588
+ messages: [],
589
+ usage: { ...EMPTY_USAGE },
590
+ exitCode: 1,
591
+ stderr: "",
592
+ errorMessage: error,
593
+ })
594
+ : undefined)
553
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;
601
+ const stopReason =
602
+ status === "failed"
603
+ ? coherentFailureReason(rawStopReason, errorCategory)
604
+ : rawStopReason;
554
605
  const entry: SubagentRun = {
555
606
  ...run,
556
607
  status,
608
+ // Issue #179 (D2): the run entry carries the CLASSIFIED reason, not just
609
+ // an opaque error string.
610
+ stopReason,
611
+ errorCategory,
612
+ // Issue #179 (D3): work volume + terminal-error signal. `turns`/`tokensOut`
613
+ // are already in the usage data; recording them here makes "did nothing"
614
+ // (turns 0) explicit versus "did work then died" (turns > 0, error).
615
+ turns: usage.turns,
616
+ finalTurnError: isProviderError(rawStopReason),
557
617
  errorMessage: error,
558
618
  finishedAt: new Date().toISOString(),
559
619
  durationMs: Date.now() - startedAtMs,
@@ -570,6 +630,17 @@ export async function spawnBackgroundSession(
570
630
  : run.originalParams,
571
631
  };
572
632
  pi.appendEntry(CUSTOM_ENTRY_TYPES.run, entry);
633
+ // Issue #179 (D6): the explicit settle line — the log's job is to record the
634
+ // CLASSIFIED OUTCOME, which cwd alone can never convey.
635
+ log.info("Background run settled", {
636
+ agentId: id,
637
+ status,
638
+ stopReason,
639
+ turns: usage.turns,
640
+ tokensOut: usage.output,
641
+ errorCategory,
642
+ error: error ?? null,
643
+ });
573
644
  };
574
645
 
575
646
  // Start transcript for this agent
@@ -582,8 +653,16 @@ export async function spawnBackgroundSession(
582
653
  // final output while the session is still live, THEN release the ref — the
583
654
  // capture must always precede the release or the output dies with the
584
655
  // session graph. Single helper for all four settlement paths.
585
- const captureAndReleaseSession = (): void => {
586
- setAgentFinalOutput(id, extractFinalOutput(session));
656
+ //
657
+ // Issue #179 (D4): an optional failureHeadline is prepended when the run did
658
+ // NOT complete — the extracted prose may come from an earlier turn, so it
659
+ // must not be presented as the run's output unqualified.
660
+ const captureAndReleaseSession = (failureHeadline?: string): void => {
661
+ const output = extractFinalOutput(session);
662
+ setAgentFinalOutput(
663
+ id,
664
+ failureHeadline ? buildFailureHeadline(failureHeadline, output) : output,
665
+ );
587
666
  agent._sessionRef = undefined;
588
667
  };
589
668
 
@@ -850,7 +929,7 @@ export async function spawnBackgroundSession(
850
929
  cleanupWorkBranch();
851
930
  // Issue #98 (review F1): a synchronous prompt() throw never reaches the
852
931
  // settle handlers — finalize the run entry so no zombie 'running' entry
853
- // survives (findRunById would otherwise resolve and retry a run that
932
+ // survives (findSpawnRunById would otherwise resolve and retry a run that
854
933
  // never started).
855
934
  finalizeRunEntry('failed', sanitizeErrorMessage((err as Error).message, effectiveCwd));
856
935
  throw err;
@@ -895,7 +974,8 @@ export async function spawnBackgroundSession(
895
974
  // 1. stopAgent pre-set status to 'stopped' before calling abort();
896
975
  // 2. the last assistant message carries stopReason "aborted".
897
976
  const lastAssistant = [...(session.messages ?? [])].reverse().find(m => m.role === 'assistant');
898
- const aborted = agent.status === 'stopped' || lastAssistant?.stopReason === 'aborted';
977
+ const terminalStopReason = lastAssistant?.stopReason;
978
+ const aborted = agent.status === 'stopped' || terminalStopReason === 'aborted';
899
979
  if (aborted) {
900
980
  // Session was aborted — mark stopped. If stopAgent already flipped the
901
981
  // status (pre-set 'stopped' + completedAt + event + transcript), only
@@ -931,13 +1011,70 @@ export async function spawnBackgroundSession(
931
1011
  // (stop_subagent) sets no error, so fall back to the honest user-abort
932
1012
  // stamp (→ 'aborted') instead of the old ambiguous 'Aborted or timed out'.
933
1013
  recordSessionUsage();
934
- finalizeRunEntry('failed', agent.error ?? SUBAGENT_ABORTED_MESSAGE);
1014
+ finalizeRunEntry('failed', agent.error ?? SUBAGENT_ABORTED_MESSAGE, {
1015
+ stopReason:
1016
+ terminalStopReason && terminalStopReason !== 'stop'
1017
+ ? terminalStopReason
1018
+ : 'aborted',
1019
+ });
935
1020
  // Issue #31: capture the final output while the session is still
936
1021
  // live, then release the ref on the terminal path (memory retention;
937
1022
  // the poller treats a nulled ref on a terminal agent as expected).
938
1023
  captureAndReleaseSession();
939
1024
  return;
940
1025
  }
1026
+ // Issue #179 (D1/D2): prompt() RESOLVED — but a resolved prompt is NOT
1027
+ // proof of success. The SDK's runWithLifecycle catches a mid-run provider
1028
+ // death internally and resolves with an assistant message carrying
1029
+ // stopReason "error"; the completion branch below would record it as a
1030
+ // false success (exitCode 0, no category). Classify the terminal outcome
1031
+ // from the last assistant message + the session error, reusing
1032
+ // classifyError for the provider-error case.
1033
+ const providerErrorMessage =
1034
+ lastAssistant?.errorMessage ?? session.state?.errorMessage;
1035
+ const outcome = classifyTerminalOutcome(terminalStopReason, providerErrorMessage);
1036
+
1037
+ if (outcome.status === 'failed') {
1038
+ // Mid-run provider death / truncation / incomplete turn.
1039
+ agent.status = 'failed';
1040
+ agent.completedAt = Date.now();
1041
+ const sanitizedError = sanitizeErrorMessage(
1042
+ providerErrorMessage ?? defaultTerminalMessage(terminalStopReason),
1043
+ effectiveCwd,
1044
+ );
1045
+ agent.error = sanitizedError;
1046
+ const gitInfo = cleanupWorkBranch();
1047
+ agent.result = {
1048
+ ...(agent.result ?? {}),
1049
+ exitCode: 1,
1050
+ messages: agent.result?.messages ?? [],
1051
+ stderr: agent.result?.stderr ?? '',
1052
+ usage: agent.result?.usage ?? EMPTY_USAGE,
1053
+ stopReason: terminalStopReason,
1054
+ errorMessage: sanitizedError,
1055
+ errorCategory: outcome.errorCategory,
1056
+ ...(gitInfo.gitBranch
1057
+ ? { gitBranch: gitInfo.gitBranch, gitDiff: gitInfo.gitDiff }
1058
+ : {}),
1059
+ } as SubagentResult;
1060
+ // Issue #31: capture the final output while the session is still live.
1061
+ // Issue #179 (D4): surface the failure as the HEADLINE — the extracted
1062
+ // prose may come from an earlier turn, so it must not be presented as an
1063
+ // unqualified success narrative.
1064
+ captureAndReleaseSession(sanitizedError);
1065
+ // Issue #122: fold the real usage (turns/tokens) the run burned.
1066
+ recordSessionUsage();
1067
+ agents.set(id, agent);
1068
+ persistAgent(agent);
1069
+ finalizeRunEntry('failed', sanitizedError, {
1070
+ stopReason: terminalStopReason,
1071
+ errorCategory: outcome.errorCategory,
1072
+ });
1073
+ transcript.completeTranscript(id, 'failed');
1074
+ eventBus.emit(eventBus.createEvent('subagent:failed', id, { error: sanitizedError }));
1075
+ return;
1076
+ }
1077
+
941
1078
  // Session completed
942
1079
  agent.status = 'completed';
943
1080
  agent.completedAt = Date.now();
@@ -951,6 +1088,7 @@ export async function spawnBackgroundSession(
951
1088
  messages: [],
952
1089
  stderr: '',
953
1090
  usage: agent.result?.usage ?? EMPTY_USAGE,
1091
+ stopReason: terminalStopReason ?? 'stop',
954
1092
  gitBranch: gitInfo.gitBranch,
955
1093
  gitDiff: gitInfo.gitDiff,
956
1094
  } as SubagentResult;
@@ -965,7 +1103,7 @@ export async function spawnBackgroundSession(
965
1103
  agents.set(id, agent);
966
1104
  persistAgent(agent);
967
1105
  // Issue #98: mirror the agent-record flip in the session run entry.
968
- finalizeRunEntry('done');
1106
+ finalizeRunEntry('done', undefined, { stopReason: terminalStopReason ?? 'stop' });
969
1107
  transcript.completeTranscript(id, 'completed');
970
1108
  eventBus.emit(eventBus.createEvent('subagent:completed', id, {}));
971
1109
  } catch (err) {
@@ -1009,7 +1147,9 @@ export async function spawnBackgroundSession(
1009
1147
  // agent.error ('Timed out...' → 'timeout'); a user cancel has no error
1010
1148
  // and stamps 'Subagent aborted by user' (→ 'aborted').
1011
1149
  recordSessionUsage();
1012
- finalizeRunEntry('failed', agent.error ?? SUBAGENT_ABORTED_MESSAGE);
1150
+ finalizeRunEntry('failed', agent.error ?? SUBAGENT_ABORTED_MESSAGE, {
1151
+ stopReason: 'aborted',
1152
+ });
1013
1153
  // Issue #31: the stopped path is terminal too — capture + release.
1014
1154
  captureAndReleaseSession();
1015
1155
  transcript.completeTranscript(id, 'stopped');
@@ -1022,29 +1162,36 @@ export async function spawnBackgroundSession(
1022
1162
  // result errorMessage, and the subagent:failed event (DRY, PR #63 review).
1023
1163
  const sanitizedError = sanitizeErrorMessage(err.message, effectiveCwd);
1024
1164
  agent.error = sanitizedError;
1025
- if (gitInfo.gitBranch) {
1026
- agent.result = {
1027
- ...(agent.result ?? {}),
1028
- exitCode: 1,
1029
- messages: [],
1030
- stderr: '',
1031
- usage: agent.result?.usage ?? EMPTY_USAGE,
1032
- errorMessage: sanitizedError,
1033
- gitBranch: gitInfo.gitBranch,
1034
- gitDiff: gitInfo.gitDiff,
1035
- } as SubagentResult;
1036
- }
1165
+ // Issue #179: record the failure on the result UNCONDITIONALLY (not only
1166
+ // on the git-diff path) so a failed run never persists exitCode 0.
1167
+ agent.result = {
1168
+ ...(agent.result ?? {}),
1169
+ exitCode: 1,
1170
+ messages: agent.result?.messages ?? [],
1171
+ stderr: agent.result?.stderr ?? '',
1172
+ usage: agent.result?.usage ?? EMPTY_USAGE,
1173
+ // Issue #179 (C1 fix): a genuine preflight/run failure always carries
1174
+ // the failure reason — never leave the result with an undefined
1175
+ // stopReason that downstream display/aggregate paths read as success.
1176
+ stopReason: 'error',
1177
+ errorMessage: sanitizedError,
1178
+ ...(gitInfo.gitBranch
1179
+ ? { gitBranch: gitInfo.gitBranch, gitDiff: gitInfo.gitDiff }
1180
+ : {}),
1181
+ } as SubagentResult;
1037
1182
  // Issue #31: capture the final output while the session is still live,
1038
1183
  // then release the ref before the branch's persist — the persisted
1039
1184
  // record is consistent and the live session graph is freed.
1040
- captureAndReleaseSession();
1185
+ // Issue #179 (D4): lead with the failure reason (the prose may be from an
1186
+ // earlier turn).
1187
+ captureAndReleaseSession(sanitizedError);
1041
1188
  // Issue #122: fold any assistant turns the preflight/run did produce —
1042
1189
  // usually none (auth/model rejection) → honest zeros, never a crash.
1043
1190
  recordSessionUsage();
1044
1191
  agents.set(id, agent);
1045
1192
  persistAgent(agent);
1046
1193
  // Issue #98: mirror the agent-record flip in the session run entry.
1047
- finalizeRunEntry('failed', sanitizedError);
1194
+ finalizeRunEntry('failed', sanitizedError, { stopReason: 'error' });
1048
1195
  transcript.completeTranscript(id, 'failed');
1049
1196
  eventBus.emit(eventBus.createEvent('subagent:failed', id, { error: sanitizedError }));
1050
1197
  } catch (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/templates.ts CHANGED
@@ -407,7 +407,7 @@ export function validateTemplatePresetRefs(
407
407
  dangling++;
408
408
  log?.warn(
409
409
  `Template "${template.name}" references preset "${template.preset}" which does not exist ` +
410
- "— delegation will run preset-less with auto-route suppressed (issue #81)",
410
+ "— delegation will run preset-less with auto-route suppressed",
411
411
  { template: template.name, preset: template.preset },
412
412
  );
413
413
  }