brl-subagent 2.3.7 → 2.3.9

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
  /**
package/src/params.ts CHANGED
@@ -23,7 +23,7 @@ import type {
23
23
  } from "./types";
24
24
  import { resolveThinkingLevel } from "./types";
25
25
  import { getAllPresets, getPreset } from "./presets";
26
- import { autoRoutePreset } from "./router";
26
+ import { classifyTask } from "./router";
27
27
  import { normalizeTimeout } from "./validate";
28
28
  import type { SessionState } from "./state";
29
29
  import type { Logger } from "./logging";
@@ -49,7 +49,7 @@ export const KNOWN_DELEGATE_KEYS = new Set([
49
49
  "outputFile", "label", "model", "timeout", "cwd", "tools",
50
50
  "excludeTools", "noBuiltinTools", "preset", "template", "params",
51
51
  "retryRunId", "gitMode", "retryOnTimeout", "approvalMode", "background",
52
- "priority", "chain", "tasks", "graph",
52
+ "priority", "chain", "tasks", "graph", "force",
53
53
  ] as const);
54
54
 
55
55
  /**
@@ -76,6 +76,11 @@ export function findUnknownParams(
76
76
  * degrade retries of that path.
77
77
  * NOTE: the schema's `params` (template slots) key is intentionally NOT
78
78
  * snapshotted — it is a template-resolution input, not a retry override.
79
+ * The execution-shape keys (background, gitMode, approvalMode, force) ARE
80
+ * snapshotted: omitting them is the known failure mode of this design, since
81
+ * resolveRetryParams rebuilds a fresh literal and a key missing here is both
82
+ * unrestored AND discarded when passed explicitly on a retry call. Records
83
+ * written before this change simply lack the keys (additive, undefined-safe).
79
84
  */
80
85
  export function snapshotOriginalParams(params: {
81
86
  systemPrompt?: string;
@@ -90,6 +95,10 @@ export function snapshotOriginalParams(params: {
90
95
  excludeTools?: string[];
91
96
  noBuiltinTools?: boolean;
92
97
  preset?: string;
98
+ background?: boolean;
99
+ gitMode?: string;
100
+ approvalMode?: string;
101
+ force?: boolean;
93
102
  }): Record<string, unknown> {
94
103
  return {
95
104
  systemPrompt: params.systemPrompt,
@@ -104,6 +113,10 @@ export function snapshotOriginalParams(params: {
104
113
  excludeTools: params.excludeTools,
105
114
  noBuiltinTools: params.noBuiltinTools,
106
115
  preset: params.preset,
116
+ background: params.background,
117
+ gitMode: params.gitMode,
118
+ approvalMode: params.approvalMode,
119
+ force: params.force,
107
120
  };
108
121
  }
109
122
 
@@ -124,6 +137,12 @@ export function resolveSubagentParams(
124
137
  gitMode?: string;
125
138
  approvalMode?: string;
126
139
  template?: string;
140
+ /**
141
+ * Capability-block override: carried here so the validation call sites
142
+ * (which receive the resolved params) can forward it to validatePreTask.
143
+ * Resolution itself ignores it.
144
+ */
145
+ force?: boolean;
127
146
  },
128
147
  state: SessionState,
129
148
  ctx: ExtensionContext,
@@ -133,6 +152,9 @@ export function resolveSubagentParams(
133
152
  resolvedApprovalMode: ApprovalMode;
134
153
  resolvedPreset?: SubagentPreset;
135
154
  autoRoutedPreset?: SubagentPreset; // set only when autoRoutePreset chose it
155
+ /** Set only when auto-route chose the preset: the keyword that matched —
156
+ * the evidence the dispatch result surfaces (auto-route transparency). */
157
+ autoRouteKeyword?: string;
136
158
  } {
137
159
  // E2: Auto-route to best preset only when the conductor expressed NO
138
160
  // explicit preference — an explicit preset, template, or tool
@@ -140,17 +162,19 @@ export function resolveSubagentParams(
140
162
  // that must win over keyword-based routing (issue #57).
141
163
  let resolvedPreset = params.preset;
142
164
  let wasAutoRouted = false;
165
+ let routeKeyword: string | undefined;
143
166
  const hasExplicitToolPreference =
144
167
  params.tools !== undefined ||
145
168
  params.excludeTools !== undefined ||
146
169
  params.noBuiltinTools !== undefined;
147
170
  if (!resolvedPreset && !params.template && !hasExplicitToolPreference) {
148
171
  const allPresets = getAllPresets(state.builtinPresets, state.customPresets);
149
- const suggested = autoRoutePreset(params.task, allPresets);
150
- if (suggested) {
151
- resolvedPreset = suggested;
172
+ const matched = classifyTask(params.task, allPresets);
173
+ if (matched) {
174
+ resolvedPreset = matched.preset;
152
175
  wasAutoRouted = true;
153
- log.info("Auto-routed task to preset", { preset: suggested });
176
+ routeKeyword = matched.keyword;
177
+ log.info("Auto-routed task to preset", { preset: matched.preset, keyword: matched.keyword });
154
178
  }
155
179
  }
156
180
 
@@ -221,5 +245,6 @@ export function resolveSubagentParams(
221
245
  resolvedApprovalMode,
222
246
  resolvedPreset: preset,
223
247
  autoRoutedPreset,
248
+ autoRouteKeyword: autoRoutedPreset ? routeKeyword : undefined,
224
249
  };
225
250
  }
package/src/prelude.ts ADDED
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Shared guard/validation prelude for delegation modes (issue #201).
3
+ *
4
+ * delegate_task's mode handlers each open with the same prelude shape:
5
+ *
6
+ * 1. session cost gate — perTaskEstimate fallback ($0.05) →
7
+ * checkCostLimit → the verbatim "Cannot delegate: session cost limit
8
+ * reached …" rejection;
9
+ * 2. approvalMode 'always' rejection (background paths only) — the verbatim
10
+ * "Cannot spawn background agent with approvalMode 'always' …";
11
+ * 3. cwd → outputFile → H1 pre-task validation — the same "Invalid cwd:" /
12
+ * "Invalid outputFile:" / joined-errors messages, optionally
13
+ * unit-prefixed (`Task 2 ("two"): …`) for per-unit loops.
14
+ *
15
+ * Each helper returns the rejection ToolResult when the gate trips and
16
+ * `undefined` when it passes, so call sites keep their original gate order:
17
+ *
18
+ * const err = gateSessionCost({ … });
19
+ * if (err) return err;
20
+ *
21
+ * Exception: runPreTaskValidation returns a { error?, warnings } outcome —
22
+ * `error` follows the same `if (outcome.error) return outcome.error` shape,
23
+ * and `warnings` must be surfaced in the returned tool result:
24
+ *
25
+ * const pre = runPreTaskValidation({ … });
26
+ * if (pre.error) return pre.error;
27
+ *
28
+ * Messages are byte-identical to the pre-extraction copies (the suites pin
29
+ * them); the parameters absorb only the real differences between sites — the
30
+ * cost-gate log label and unit count, and the H1 log label / unit prefix /
31
+ * extra log fields.
32
+ */
33
+
34
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
35
+ import type { ApprovalMode, ToolResult } from "./types";
36
+ import type { SessionState } from "./state";
37
+ import type { Logger } from "./logging";
38
+ import { validateCwd, validateOutputFile } from "./sanitize";
39
+ import { validatePreTask, type ValidateConfig } from "./validate";
40
+
41
+ /** The standard rejection result shape shared by these gates. */
42
+ function rejection(text: string): ToolResult<undefined> {
43
+ return {
44
+ content: [{ type: "text" as const, text }],
45
+ details: undefined,
46
+ isError: true,
47
+ };
48
+ }
49
+
50
+ // ── Gate 1: session cost ────────────────────────────────────────────────
51
+
52
+ /**
53
+ * Session cost gate (R5 / W6): falls back to the $0.05 per-unit estimate,
54
+ * checks the whole dispatch (units × estimate) against the session limit, and
55
+ * returns the verbatim rejection result when it would be exceeded.
56
+ *
57
+ * This module holds the codebase's SINGLE `checkCostLimit(perTaskEstimate * …)`
58
+ * site — the duplication that shape caused a mutation-target-drift incident
59
+ * (issue #201).
60
+ */
61
+ export function gateSessionCost(input: {
62
+ state: SessionState;
63
+ ctx: ExtensionContext;
64
+ log: Logger;
65
+ /** Number of units this dispatch would spawn (1 for single-task modes). */
66
+ units: number;
67
+ /** e.g. "Parallel delegation" → log line "Parallel delegation rejected: session cost limit reached". */
68
+ label: string;
69
+ }): ToolResult<undefined> | undefined {
70
+ const { state, ctx, log, units, label } = input;
71
+ const perTaskEstimate =
72
+ state.config.perTaskCostEstimate > 0
73
+ ? state.config.perTaskCostEstimate
74
+ : 0.05;
75
+ if (state.checkCostLimit(perTaskEstimate * units, ctx)) {
76
+ const currentTotal = state.getSessionTotalCost(ctx);
77
+ const limit = state.config.sessionCostLimit;
78
+ log.warn(`${label}: session cost limit reached`, {
79
+ currentTotal,
80
+ estimatedCost: perTaskEstimate * units,
81
+ limit,
82
+ });
83
+ return rejection(
84
+ `Cannot delegate: session cost limit reached ` +
85
+ `($${currentTotal.toFixed(4)} spent of $${limit.toFixed(2)} limit). ` +
86
+ `Increase the limit via /brl-subagent costlimit or set to 0 for unlimited.`,
87
+ );
88
+ }
89
+ return undefined;
90
+ }
91
+
92
+ // ── Gate 2: approvalMode 'always' in background ─────────────────────────
93
+
94
+ /**
95
+ * Background paths (single spawn and fan-out) reject approvalMode 'always'
96
+ * before spawning: there is no interactive dialog to approve the diff, so
97
+ * 'always' cannot work — reject loudly instead of running unattended with
98
+ * write access (W5, issue #28). Verbatim message, pinned by the suites.
99
+ */
100
+ export function rejectApprovalAlwaysInBackground(
101
+ approvalMode: ApprovalMode,
102
+ ): ToolResult<undefined> | undefined {
103
+ if (approvalMode !== "always") return undefined;
104
+ return rejection(
105
+ `Cannot spawn background agent with approvalMode 'always': background agents run unattended ` +
106
+ `and cannot present the approval dialog. Use approvalMode 'auto' (default) or 'writes'.`,
107
+ );
108
+ }
109
+
110
+ // ── Gate 3a: cwd (+ outputFile) target validation ───────────────────────
111
+
112
+ export type DelegationTargetsResult =
113
+ | { ok: true; cwd: string; outputFile: string | undefined }
114
+ | { ok: false; error: ToolResult<undefined> };
115
+
116
+ /**
117
+ * Validate the dispatch's target cwd, then — when `outputFile` is given — the
118
+ * outputFile path (F1), returning both resolved values on success. `prefix`
119
+ * (a per-unit label like `Task 2 ("two")`) is prepended as `${prefix}: ` to
120
+ * the rejection messages; omit it for the unprefixed single-unit messages.
121
+ *
122
+ * Note: the foreground parallel mode historically validates only the cwd
123
+ * here (no outputFile step) — it simply passes no outputFile.
124
+ */
125
+ export function validateDelegationTargets(input: {
126
+ ctx: ExtensionContext;
127
+ effectiveCwd: string;
128
+ /** Raw outputFile to path-validate; the step is skipped when omitted. */
129
+ outputFile?: string;
130
+ /** Unit prefix for the rejection messages, e.g. `Task 2 ("two")`. */
131
+ prefix?: string;
132
+ }): DelegationTargetsResult {
133
+ const prefix = input.prefix ? `${input.prefix}: ` : "";
134
+
135
+ const cwdResult = validateCwd(input.effectiveCwd, input.ctx.cwd);
136
+ if (!cwdResult.ok) {
137
+ return { ok: false, error: rejection(`${prefix}Invalid cwd: ${cwdResult.error}`) };
138
+ }
139
+
140
+ let outputFile: string | undefined;
141
+ if (input.outputFile) {
142
+ const ofResult = validateOutputFile(input.outputFile, cwdResult.value);
143
+ if (!ofResult.ok) {
144
+ return { ok: false, error: rejection(`${prefix}Invalid outputFile: ${ofResult.error}`) };
145
+ }
146
+ outputFile = ofResult.value;
147
+ }
148
+
149
+ return { ok: true, cwd: cwdResult.value, outputFile };
150
+ }
151
+
152
+ // ── Gate 3b: H1 pre-task validation ─────────────────────────────────────
153
+
154
+ /** Outcome of one pre-task validation: the rejection (hard failure) and/or
155
+ * the warnings the caller must surface in the tool result. */
156
+ export interface PreTaskValidationOutcome {
157
+ /** Rejection result when validation failed (hard errors); undefined when it passed. */
158
+ error?: ToolResult<undefined>;
159
+ /** Validation warnings — surfaced in the returned tool result, not just logged. */
160
+ warnings: string[];
161
+ }
162
+
163
+ /**
164
+ * Render validation warnings for a tool result: one labelled, bulleted
165
+ * block. Empty input renders the empty string so results stay
166
+ * byte-identical when there is nothing to say.
167
+ */
168
+ export function formatValidationWarnings(warnings: string[]): string {
169
+ if (warnings.length === 0) return "";
170
+ return `\n\n[pre-task validation warnings]\n` + warnings.map((w) => `- ${w}`).join("\n");
171
+ }
172
+
173
+ /**
174
+ * Run validatePreTask (H1) with the site's exact input, warn on warnings,
175
+ * and return the rejection outcome (joined errors) on hard failure.
176
+ * `label` names the site in the warn logs ("Parallel pre-task validation
177
+ * warnings" / "Background fan-out …" / "Background …"); `prefix` is
178
+ * prepended as `${prefix}: ` to the hard-failure message; `logContext` adds
179
+ * site fields to both warn entries (the fan-out adds the task index).
180
+ * The returned warnings are for RESULT surfacing — the log.warn calls stay
181
+ * regardless (a warning must reach both the log and the conductor).
182
+ */
183
+ export function runPreTaskValidation(input: {
184
+ log: Logger;
185
+ preTask: ValidateConfig;
186
+ label: string;
187
+ /** Unit prefix for the rejection message, e.g. `Task 2 ("two")`. */
188
+ prefix?: string;
189
+ /** Extra structured fields merged into the warn entries. */
190
+ logContext?: Record<string, unknown>;
191
+ }): PreTaskValidationOutcome {
192
+ const { log, preTask, label, prefix, logContext } = input;
193
+ const validation = validatePreTask(preTask);
194
+ if (validation.warnings.length > 0) {
195
+ log.warn(`${label} pre-task validation warnings`, { ...logContext, warnings: validation.warnings });
196
+ }
197
+ if (!validation.valid) {
198
+ const errText = validation.errors.join("; ");
199
+ log.warn(`${label} pre-task validation failed`, { ...logContext, errors: validation.errors });
200
+ return {
201
+ error: rejection(prefix ? `${prefix}: ${errText}` : errText),
202
+ warnings: validation.warnings,
203
+ };
204
+ }
205
+ return { warnings: validation.warnings };
206
+ }
package/src/prompt.ts CHANGED
@@ -52,7 +52,7 @@ export const SUBAGENT_INSTRUCTIONS =
52
52
  * - runner.ts (foreground subprocess: `-p` argument)
53
53
  * - session-manager.ts (background session: `session.prompt`)
54
54
  *
55
- * Deliberately applied AFTER {previous}/{otherId} substitution so substituted
55
+ * Deliberately applied AFTER {previous}/{<nodeId>} substitution so substituted
56
56
  * output (which may itself contain untrusted text) lands inside the fence.
57
57
  *
58
58
  * FIDELITY TRADEOFF (accepted, documented per PR #46 review M2): embedded
package/src/router.ts CHANGED
@@ -105,15 +105,25 @@ function escapeRegExp(s: string): string {
105
105
  // Router
106
106
  // ---------------------------------------------------------------------------
107
107
 
108
+ /** One auto-route decision: the selected preset AND the keyword that
109
+ * matched — the evidence the dispatch result must show (transparency). */
110
+ export interface AutoRouteDecision {
111
+ /** The preset name selected. */
112
+ preset: string;
113
+ /** The classification keyword whose match selected the preset. */
114
+ keyword: string;
115
+ }
116
+
108
117
  /**
109
- * Auto-classify a task description to the best preset personality.
118
+ * Auto-classify a task description to the best preset personality, returning
119
+ * the preset AND the matched keyword (evidence for the dispatch result).
110
120
  * Uses case-insensitive keyword matching, checked in priority order.
111
- * Returns the first matching preset name, or undefined if no match.
121
+ * Returns the first match, or undefined if no rule matches.
112
122
  */
113
- export function autoRoutePreset(
123
+ export function classifyTask(
114
124
  task: string,
115
125
  presets: SubagentPreset[],
116
- ): string | undefined {
126
+ ): AutoRouteDecision | undefined {
117
127
  if (!task || task.trim().length === 0) return undefined;
118
128
 
119
129
  const lower = task.toLowerCase();
@@ -124,10 +134,21 @@ export function autoRoutePreset(
124
134
 
125
135
  for (const keyword of rule.keywords) {
126
136
  if (keywordMatches(lower, keyword)) {
127
- return rule.preset;
137
+ return { preset: rule.preset, keyword };
128
138
  }
129
139
  }
130
140
  }
131
141
 
132
142
  return undefined;
133
143
  }
144
+
145
+ /**
146
+ * Auto-classify a task description to the best preset personality.
147
+ * Thin wrapper over classifyTask — preset name only, no evidence.
148
+ */
149
+ export function autoRoutePreset(
150
+ task: string,
151
+ presets: SubagentPreset[],
152
+ ): string | undefined {
153
+ return classifyTask(task, presets)?.preset;
154
+ }
package/src/runner.ts CHANGED
@@ -245,7 +245,13 @@ function emitSubagentUpdate(
245
245
  text: getFinalOutputFn(result.messages) || "(running...)",
246
246
  },
247
247
  ],
248
- details: { ...result },
248
+ // Issue #206: this is a STREAMING partial, not a verdict —
249
+ // isSubagentError is a settled-result predicate (a mid-run stopReason
250
+ // such as "toolUse" is not an error until the run actually ends), so
251
+ // stamping the unsettled sentinel (exitCode: -1) keeps the renderer on
252
+ // its running branch until the real result lands. The accumulator
253
+ // (`result` itself) is untouched — its exitCode stays the real code.
254
+ details: { ...result, exitCode: -1 },
249
255
  });
250
256
  }
251
257
 
@@ -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';
@@ -10,7 +10,7 @@ import { createEvent } from './event-bus';
10
10
  import { assertSafeAgentId, sanitizeErrorMessage } from './sanitize';
11
11
  import { wrapTask } from './prompt';
12
12
  import { createLogger } from './logging';
13
- import { getCurrentBranch, createWorkBranch, captureDiff, switchToBranch, deleteBranch, hasUncommittedChanges, commitAll, captureWorkingDiff } from './git';
13
+ import { getCurrentBranch, createWorkBranch, captureDiff, switchToBranch, deleteBranch, hasUncommittedChanges, getRepoRoot, commitAll, captureWorkingDiff } from './git';
14
14
 
15
15
  const log = createLogger('brl-subagent');
16
16
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
@@ -20,7 +20,10 @@ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-a
20
20
  var spawnQueue: Promise<void> = Promise.resolve();
21
21
 
22
22
  // C2: per-repo locks serializing the FULL gitMode=branch lifecycle (setup →
23
- // settle → teardown). Keyed by cwd because different repos don't conflict.
23
+ // settle → teardown). Keyed by the resolved repository root, falling back to
24
+ // the cwd path for directories that are not inside a repository — different
25
+ // repos don't conflict, but different directories of one repo DO share a
26
+ // working tree and must serialize (issue #224).
24
27
  const gitBranchLocks = new Map<string, Promise<void>>();
25
28
 
26
29
  /**
@@ -274,23 +277,6 @@ function defaultTerminalMessage(stopReason: string | undefined): string {
274
277
  }
275
278
  }
276
279
 
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
280
  /**
295
281
  * Extract the final assistant text from a session's messages.
296
282
  * Falls back to the LAST assistant message with non-empty text content — the
@@ -558,7 +544,7 @@ export async function spawnBackgroundSession(
558
544
 
559
545
  // Issue #98: background runs must be retry-able — persist a session run
560
546
  // entry at spawn (id == agent id, so retryRunId: <agent-id> resolves via
561
- // state.findRunById → getRunEntries). The entry lives in the same session
547
+ // state.findSpawnRunById → getRunEntries). The entry lives in the same session
562
548
  // custom-entry store the foreground path writes to (state.persistRun); the
563
549
  // background path previously never wrote there, so retries silently no-oped.
564
550
  const startedAtMs = agent.startedAt; // reuse the agent record's start clock
@@ -610,13 +596,15 @@ export async function spawnBackgroundSession(
610
596
  })
611
597
  : undefined)
612
598
  : undefined;
599
+ // Issue #187: capture the RAW terminal reason BEFORE coercion. `finalTurnError`
600
+ // means "the final turn was a PROVIDER error" (isProviderError); deriving it
601
+ // from the COERCED reason would turn a missing/success reason into 'error' and
602
+ // mislabel a non-provider failure (hard-cap timeout, spawn throw) as one.
603
+ const rawStopReason = terminal?.stopReason ?? agent.result?.stopReason;
613
604
  const stopReason =
614
605
  status === "failed"
615
- ? coherentFailureReason(
616
- terminal?.stopReason ?? agent.result?.stopReason,
617
- errorCategory,
618
- )
619
- : terminal?.stopReason ?? agent.result?.stopReason;
606
+ ? coherentFailureReason(rawStopReason, errorCategory)
607
+ : rawStopReason;
620
608
  const entry: SubagentRun = {
621
609
  ...run,
622
610
  status,
@@ -628,7 +616,7 @@ export async function spawnBackgroundSession(
628
616
  // are already in the usage data; recording them here makes "did nothing"
629
617
  // (turns 0) explicit versus "did work then died" (turns > 0, error).
630
618
  turns: usage.turns,
631
- finalTurnError: isProviderError(stopReason),
619
+ finalTurnError: isProviderError(rawStopReason),
632
620
  errorMessage: error,
633
621
  finishedAt: new Date().toISOString(),
634
622
  durationMs: Date.now() - startedAtMs,
@@ -739,16 +727,18 @@ export async function spawnBackgroundSession(
739
727
  // would read the first's work branch as its base and both teardowns would
740
728
  // fight over the shared working tree (stranding the repo on an orphan
741
729
  // branch). The lock is released in cleanupWorkBranch (or on setup throw).
742
- const prev = gitBranchLocks.get(gitCwd) ?? Promise.resolve();
730
+ // Key: resolved repository root, cwd fallback for non-repo paths (#224).
731
+ const lockKey = getRepoRoot(gitCwd) ?? gitCwd;
732
+ const prev = gitBranchLocks.get(lockKey) ?? Promise.resolve();
743
733
  let release!: () => void;
744
734
  const gate = new Promise<void>(r => { release = r; });
745
735
  const entry = prev.then(() => gate);
746
- gitBranchLocks.set(gitCwd, entry);
736
+ gitBranchLocks.set(lockKey, entry);
747
737
  await prev;
748
738
  releaseGitLock = () => {
749
739
  // Delete the map entry ONLY if we are still the head of the chain — a
750
740
  // queued spawn behind us replaced the entry with its own.
751
- if (gitBranchLocks.get(gitCwd) === entry) gitBranchLocks.delete(gitCwd);
741
+ if (gitBranchLocks.get(lockKey) === entry) gitBranchLocks.delete(lockKey);
752
742
  release();
753
743
  };
754
744
 
@@ -944,7 +934,7 @@ export async function spawnBackgroundSession(
944
934
  cleanupWorkBranch();
945
935
  // Issue #98 (review F1): a synchronous prompt() throw never reaches the
946
936
  // settle handlers — finalize the run entry so no zombie 'running' entry
947
- // survives (findRunById would otherwise resolve and retry a run that
937
+ // survives (findSpawnRunById would otherwise resolve and retry a run that
948
938
  // never started).
949
939
  finalizeRunEntry('failed', sanitizeErrorMessage((err as Error).message, effectiveCwd));
950
940
  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));