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.
- package/AGENT.md +21 -9
- package/README.md +50 -10
- package/package.json +5 -5
- package/presets/test-engineer.md +2 -0
- package/src/git.ts +15 -0
- package/src/history.ts +25 -1
- package/src/index.ts +981 -551
- package/src/notify-completion.ts +7 -13
- package/src/params.ts +31 -6
- package/src/prelude.ts +206 -0
- package/src/prompt.ts +1 -1
- package/src/router.ts +26 -5
- package/src/runner.ts +7 -1
- package/src/session-manager.ts +21 -31
- package/src/state.ts +42 -2
- package/src/tui.ts +1 -1
- package/src/types.ts +32 -0
- package/src/unit-run.ts +4 -0
- package/src/validate.ts +111 -10
- package/templates/code-review.md +1 -1
- package/templates/debug-issue.md +2 -1
- package/templates/write-tests.md +1 -1
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/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 {
|
|
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
|
|
150
|
-
if (
|
|
151
|
-
resolvedPreset =
|
|
172
|
+
const matched = classifyTask(params.task, allPresets);
|
|
173
|
+
if (matched) {
|
|
174
|
+
resolvedPreset = matched.preset;
|
|
152
175
|
wasAutoRouted = true;
|
|
153
|
-
|
|
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}/{
|
|
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
|
|
121
|
+
* Returns the first match, or undefined if no rule matches.
|
|
112
122
|
*/
|
|
113
|
-
export function
|
|
123
|
+
export function classifyTask(
|
|
114
124
|
task: string,
|
|
115
125
|
presets: SubagentPreset[],
|
|
116
|
-
):
|
|
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
|
-
|
|
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
|
|
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';
|
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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 (
|
|
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
|
-
|
|
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));
|