brl-subagent 2.3.4
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/LICENSE +21 -0
- package/README.md +257 -0
- package/package.json +49 -0
- package/presets/code-reviewer.md +37 -0
- package/presets/data-analyst.md +33 -0
- package/presets/debugger.md +36 -0
- package/presets/dev-agent.md +30 -0
- package/presets/rapid-prototyper.md +25 -0
- package/presets/refactorer.md +34 -0
- package/presets/security-auditor.md +39 -0
- package/presets/tech-writer.md +33 -0
- package/presets/test-engineer.md +34 -0
- package/src/concurrency.ts +186 -0
- package/src/diff.ts +102 -0
- package/src/event-bus.ts +86 -0
- package/src/git.ts +187 -0
- package/src/history.ts +177 -0
- package/src/index.ts +3824 -0
- package/src/logging.ts +135 -0
- package/src/messaging.ts +164 -0
- package/src/metrics.ts +221 -0
- package/src/model-availability.ts +73 -0
- package/src/notify-completion.ts +207 -0
- package/src/params.ts +225 -0
- package/src/preflight.ts +150 -0
- package/src/presets.ts +425 -0
- package/src/prompt.ts +169 -0
- package/src/reports.ts +325 -0
- package/src/router.ts +133 -0
- package/src/runner.ts +877 -0
- package/src/sanitize.ts +365 -0
- package/src/scheduler.ts +187 -0
- package/src/session-manager.ts +1065 -0
- package/src/state.ts +590 -0
- package/src/templates.ts +469 -0
- package/src/transcript-tail.ts +327 -0
- package/src/transcript.ts +119 -0
- package/src/tui-format.ts +51 -0
- package/src/tui.ts +3103 -0
- package/src/types.ts +803 -0
- package/src/unit-run.ts +269 -0
- package/src/update.ts +109 -0
- package/src/validate.ts +302 -0
- package/templates/analyze-data.md +9 -0
- package/templates/code-review.md +10 -0
- package/templates/debug-issue.md +9 -0
- package/templates/implement-feature.md +9 -0
- package/templates/prototype.md +9 -0
- package/templates/refactor.md +9 -0
- package/templates/security-audit.md +10 -0
- package/templates/write-docs.md +9 -0
- package/templates/write-tests.md +10 -0
package/src/state.ts
ADDED
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* brl-subagent — State Management (F5, F7, F9)
|
|
3
|
+
*
|
|
4
|
+
* Session-bound state container with validation and migration support.
|
|
5
|
+
*
|
|
6
|
+
* F5: Type-safe state restoration — no `as any` casts.
|
|
7
|
+
* F7: Session-bound state — initialized in session_start, cleaned in session_shutdown.
|
|
8
|
+
* F9: State schema validation — validates on load, migrates legacy formats.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import type {
|
|
13
|
+
SubagentState,
|
|
14
|
+
SubagentRun,
|
|
15
|
+
LiveSubagent,
|
|
16
|
+
ThinkingLevel,
|
|
17
|
+
ApprovalMode,
|
|
18
|
+
CompletionNotifyMode,
|
|
19
|
+
CircuitBreakerState,
|
|
20
|
+
Priority,
|
|
21
|
+
} from "./types";
|
|
22
|
+
import type { TranscriptMessage } from "./transcript-tail";
|
|
23
|
+
import {
|
|
24
|
+
isSubagentStateShape,
|
|
25
|
+
isSubagentRunShape,
|
|
26
|
+
CUSTOM_ENTRY_TYPES,
|
|
27
|
+
MAX_RUN_HISTORY_ENTRIES,
|
|
28
|
+
DEFAULT_MAX_SUBAGENT_DEPTH,
|
|
29
|
+
DEFAULT_SESSION_COST_LIMIT,
|
|
30
|
+
DEFAULT_COMPLETION_NOTIFY,
|
|
31
|
+
COMPLETION_NOTIFY_MODES,
|
|
32
|
+
MAX_CONSECUTIVE_FAILURES,
|
|
33
|
+
CIRCUIT_BREAKER_RESET_MS,
|
|
34
|
+
CIRCUIT_DEGRADED_THINKING,
|
|
35
|
+
} from "./types";
|
|
36
|
+
import { cleanupRuns } from "./history";
|
|
37
|
+
import type { Logger } from "./logging";
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Live-monitor staleness sweep (issue #52 part 2)
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* How long a finalized live entry stays visible in the monitor map before it
|
|
45
|
+
* is deleted (the poller's "brief reset window" — see finalizeLiveSubagent).
|
|
46
|
+
*/
|
|
47
|
+
export const FINALIZE_RESET_WINDOW_MS = 3000;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Background-progress poller tick interval (src/index.ts, `setInterval(..., 2000)`
|
|
51
|
+
* in the background spawn branch).
|
|
52
|
+
*/
|
|
53
|
+
const POLLER_TICK_MS = 2000;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Liveness boundary for the stale sweep. The poller finalizes a completed
|
|
57
|
+
* agent on its NEXT tick after `completedAt` is set, i.e. within at most one
|
|
58
|
+
* POLLER_TICK_MS. A terminal record that is OLDER than this boundary and
|
|
59
|
+
* still present in the live map therefore proves the poller can no longer be
|
|
60
|
+
* the one to finalize it — it died mid-run — so the sweep may claim the
|
|
61
|
+
* finalize (and its counter adjustment) without racing a live poller.
|
|
62
|
+
*/
|
|
63
|
+
export const STALE_FINALIZE_GRACE_MS = POLLER_TICK_MS + 1000;
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// SessionState — session-bound mutable state
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
export class SessionState {
|
|
70
|
+
/** Current configuration */
|
|
71
|
+
config: SubagentState;
|
|
72
|
+
|
|
73
|
+
/** Module-level progress counters (now session-bound) */
|
|
74
|
+
activeSubagents = 0;
|
|
75
|
+
completedSubagents = 0;
|
|
76
|
+
failedSubagents = 0;
|
|
77
|
+
unseenSubagents = 0;
|
|
78
|
+
|
|
79
|
+
/** Concurrency queue */
|
|
80
|
+
pendingQueue: Array<{
|
|
81
|
+
run: () => void;
|
|
82
|
+
signal: AbortSignal | undefined;
|
|
83
|
+
ctx: ExtensionContext;
|
|
84
|
+
priority: Priority;
|
|
85
|
+
}> = [];
|
|
86
|
+
|
|
87
|
+
/** Live subagent sessions for the monitor dashboard */
|
|
88
|
+
subagentSessions = new Map<string, LiveSubagent>();
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Issue #133: multi-unit mode breakdown for the status bar — set by
|
|
92
|
+
* runGraphMode / runChainMode (src/index.ts) while a dispatch is in
|
|
93
|
+
* flight, composed into the status text by updateProgressStatus
|
|
94
|
+
* (src/concurrency.ts), cleared in the mode's finally. Undefined when no
|
|
95
|
+
* graph/chain dispatch is active.
|
|
96
|
+
*/
|
|
97
|
+
modeContext?:
|
|
98
|
+
| { kind: "graph"; wave: number; totalWaves: number; node: number; totalNodes: number }
|
|
99
|
+
| { kind: "chain"; step: number; totalSteps: number };
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Ids whose live entry has been finalized (deferred delete pending).
|
|
103
|
+
* Makes finalizeLiveSubagent idempotent so the stale sweep and the poller
|
|
104
|
+
* can race the same id without double-finalizing (issue #52).
|
|
105
|
+
*/
|
|
106
|
+
private _finalizedLiveIds = new Set<string>();
|
|
107
|
+
|
|
108
|
+
/** Loaded built-in presets */
|
|
109
|
+
builtinPresets = new Array<import("./types").SubagentPreset>();
|
|
110
|
+
|
|
111
|
+
/** Loaded built-in task templates (companions to the builtin presets) */
|
|
112
|
+
builtinTemplates = new Array<import("./types").TaskTemplate>();
|
|
113
|
+
|
|
114
|
+
/** Loaded custom presets from user directories */
|
|
115
|
+
customPresets = new Array<import("./types").SubagentPreset>();
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Migration: session-persisted presets read during restoreFromSession.
|
|
119
|
+
* These are stored here for src/presets.ts:migrateSessionPresets() to
|
|
120
|
+
* write to files in a subsequent step, then cleared.
|
|
121
|
+
*/
|
|
122
|
+
_migratedPresets?: import("./types").SubagentPreset[];
|
|
123
|
+
|
|
124
|
+
/** Logger instance */
|
|
125
|
+
log: Logger | undefined;
|
|
126
|
+
|
|
127
|
+
constructor(log?: Logger) {
|
|
128
|
+
this.log = log;
|
|
129
|
+
this.config = {
|
|
130
|
+
maxThinkingLevel: "off",
|
|
131
|
+
maxParallel: 0,
|
|
132
|
+
maxSubagentDepth: DEFAULT_MAX_SUBAGENT_DEPTH,
|
|
133
|
+
gitMode: "none",
|
|
134
|
+
approvalMode: "writes",
|
|
135
|
+
completionNotify: DEFAULT_COMPLETION_NOTIFY,
|
|
136
|
+
maxHistoryEntries: MAX_RUN_HISTORY_ENTRIES,
|
|
137
|
+
sessionCostLimit: DEFAULT_SESSION_COST_LIMIT,
|
|
138
|
+
perTaskCostEstimate: 0,
|
|
139
|
+
seenRunIds: [],
|
|
140
|
+
presets: [],
|
|
141
|
+
templates: [],
|
|
142
|
+
circuitBreaker: this.defaultCircuitBreaker(),
|
|
143
|
+
slaTrackingEnabled: false,
|
|
144
|
+
slaWindowSize: 50,
|
|
145
|
+
updateCheckEnabled: true,
|
|
146
|
+
lastUpdateCheck: 0,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// -------------------------------------------------------------------
|
|
151
|
+
// State persistence
|
|
152
|
+
// -------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
persistState(pi: ExtensionAPI): void {
|
|
155
|
+
pi.appendEntry(CUSTOM_ENTRY_TYPES.state, {
|
|
156
|
+
model: this.config.model,
|
|
157
|
+
maxThinkingLevel: this.config.maxThinkingLevel,
|
|
158
|
+
maxParallel: this.config.maxParallel,
|
|
159
|
+
maxSubagentDepth: this.config.maxSubagentDepth,
|
|
160
|
+
gitMode: this.config.gitMode,
|
|
161
|
+
approvalMode: this.config.approvalMode,
|
|
162
|
+
completionNotify: this.config.completionNotify,
|
|
163
|
+
maxHistoryEntries: this.config.maxHistoryEntries,
|
|
164
|
+
sessionCostLimit: this.config.sessionCostLimit,
|
|
165
|
+
perTaskCostEstimate: this.config.perTaskCostEstimate,
|
|
166
|
+
seenRunIds: this.config.seenRunIds,
|
|
167
|
+
circuitBreaker: this.config.circuitBreaker,
|
|
168
|
+
slaTrackingEnabled: this.config.slaTrackingEnabled,
|
|
169
|
+
slaWindowSize: this.config.slaWindowSize,
|
|
170
|
+
lastSLAMetrics: this.config.lastSLAMetrics,
|
|
171
|
+
updateCheckEnabled: this.config.updateCheckEnabled,
|
|
172
|
+
lastUpdateCheck: this.config.lastUpdateCheck,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
persistRun(pi: ExtensionAPI, run: SubagentRun): void {
|
|
177
|
+
pi.appendEntry(CUSTOM_ENTRY_TYPES.run, run);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// -------------------------------------------------------------------
|
|
181
|
+
// F5/F9: Safe state restoration from session entries
|
|
182
|
+
// -------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Restore state from the last persisted state entry.
|
|
186
|
+
* Uses type guards instead of `as any` casts.
|
|
187
|
+
* Returns true if state was restored, false if defaults were used.
|
|
188
|
+
*/
|
|
189
|
+
restoreFromSession(ctx: ExtensionContext): boolean {
|
|
190
|
+
const entries = ctx.sessionManager.getEntries();
|
|
191
|
+
const stateEntry = entries
|
|
192
|
+
.filter(
|
|
193
|
+
(e: { type: string; customType?: string }) =>
|
|
194
|
+
e.type === "custom" && e.customType === CUSTOM_ENTRY_TYPES.state,
|
|
195
|
+
)
|
|
196
|
+
.pop() as { data?: unknown } | undefined;
|
|
197
|
+
|
|
198
|
+
if (!stateEntry?.data) {
|
|
199
|
+
this.log?.debug("No persisted state found, using defaults");
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const data = stateEntry.data;
|
|
204
|
+
|
|
205
|
+
if (!isSubagentStateShape(data)) {
|
|
206
|
+
this.log?.warn("Corrupted state entry detected, falling back to defaults", {
|
|
207
|
+
dataType: typeof data,
|
|
208
|
+
});
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Restore fields with fallback to defaults
|
|
213
|
+
if (data.model) this.config.model = data.model;
|
|
214
|
+
if (data.maxThinkingLevel) this.config.maxThinkingLevel = data.maxThinkingLevel;
|
|
215
|
+
if (data.maxParallel !== undefined) this.config.maxParallel = data.maxParallel;
|
|
216
|
+
if (data.maxSubagentDepth !== undefined) this.config.maxSubagentDepth = data.maxSubagentDepth;
|
|
217
|
+
this.config.gitMode = data.gitMode === "branch" ? "branch" : "none";
|
|
218
|
+
this.config.approvalMode =
|
|
219
|
+
data.approvalMode === "auto" || data.approvalMode === "writes" || data.approvalMode === "always"
|
|
220
|
+
? data.approvalMode
|
|
221
|
+
: "writes";
|
|
222
|
+
this.config.completionNotify =
|
|
223
|
+
COMPLETION_NOTIFY_MODES.includes(data.completionNotify as CompletionNotifyMode)
|
|
224
|
+
? data.completionNotify
|
|
225
|
+
: DEFAULT_COMPLETION_NOTIFY;
|
|
226
|
+
if (data.maxHistoryEntries !== undefined) this.config.maxHistoryEntries = data.maxHistoryEntries;
|
|
227
|
+
if (data.sessionCostLimit !== undefined) this.config.sessionCostLimit = data.sessionCostLimit;
|
|
228
|
+
if (data.perTaskCostEstimate !== undefined) this.config.perTaskCostEstimate = data.perTaskCostEstimate;
|
|
229
|
+
|
|
230
|
+
if (Array.isArray(data.seenRunIds)) this.config.seenRunIds = data.seenRunIds;
|
|
231
|
+
|
|
232
|
+
// Migration: old session-persisted presets — store for file-backed migration
|
|
233
|
+
if (Array.isArray(data.presets) && data.presets.length > 0) {
|
|
234
|
+
this._migratedPresets = data.presets;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Templates are file-backed only (issue #66) — never persisted to session state.
|
|
238
|
+
if (
|
|
239
|
+
data.circuitBreaker &&
|
|
240
|
+
typeof data.circuitBreaker === "object" &&
|
|
241
|
+
typeof (data.circuitBreaker as CircuitBreakerState).consecutiveFailures === "number"
|
|
242
|
+
) {
|
|
243
|
+
const cb = data.circuitBreaker as CircuitBreakerState;
|
|
244
|
+
this.config.circuitBreaker.consecutiveFailures = cb.consecutiveFailures;
|
|
245
|
+
this.config.circuitBreaker.lastFailureTime = cb.lastFailureTime;
|
|
246
|
+
this.config.circuitBreaker.circuitOpen = cb.circuitOpen;
|
|
247
|
+
if (cb.degradedThinkingLevel) {
|
|
248
|
+
this.config.circuitBreaker.degradedThinkingLevel = cb.degradedThinkingLevel;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Restore SLA fields (E4)
|
|
253
|
+
if (typeof data.slaTrackingEnabled === "boolean") this.config.slaTrackingEnabled = data.slaTrackingEnabled;
|
|
254
|
+
if (typeof data.slaWindowSize === "number" && data.slaWindowSize >= 10 && data.slaWindowSize <= 500) {
|
|
255
|
+
this.config.slaWindowSize = data.slaWindowSize;
|
|
256
|
+
}
|
|
257
|
+
if (data.lastSLAMetrics && typeof data.lastSLAMetrics === "object") {
|
|
258
|
+
this.config.lastSLAMetrics = data.lastSLAMetrics as import("./types").SLAMetrics;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Restore update check fields
|
|
262
|
+
if (typeof data.updateCheckEnabled === "boolean") this.config.updateCheckEnabled = data.updateCheckEnabled;
|
|
263
|
+
if (typeof data.lastUpdateCheck === "number" && data.lastUpdateCheck >= 0) this.config.lastUpdateCheck = data.lastUpdateCheck;
|
|
264
|
+
|
|
265
|
+
this.log?.info("State restored from session", {
|
|
266
|
+
model: data.model ? `${data.model.provider}/${data.model.id}` : "none",
|
|
267
|
+
thinkingLevel: data.maxThinkingLevel,
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// -------------------------------------------------------------------
|
|
274
|
+
// Run entry access
|
|
275
|
+
// -------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
getRunEntries(ctx: ExtensionContext): SubagentRun[] {
|
|
278
|
+
const runs = ctx.sessionManager
|
|
279
|
+
.getEntries()
|
|
280
|
+
.filter((e: { type: string; customType?: string }) =>
|
|
281
|
+
e.type === "custom" && e.customType === CUSTOM_ENTRY_TYPES.run,
|
|
282
|
+
)
|
|
283
|
+
.map((e: { type: string; data?: unknown }) => {
|
|
284
|
+
const data = e.data;
|
|
285
|
+
if (isSubagentRunShape(data)) return data;
|
|
286
|
+
this.log?.warn("Corrupted run entry skipped", { entryType: typeof data });
|
|
287
|
+
return undefined;
|
|
288
|
+
})
|
|
289
|
+
.filter((r): r is SubagentRun => r !== undefined);
|
|
290
|
+
return cleanupRuns(runs, this.config.maxHistoryEntries);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
findRunById(ctx: ExtensionContext, id: string): SubagentRun | undefined {
|
|
294
|
+
return this.getRunEntries(ctx).find((r) => r.id === id);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// -------------------------------------------------------------------
|
|
298
|
+
// Live subagent tracking
|
|
299
|
+
// -------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
registerLiveSubagent(id: string, data: Omit<LiveSubagent, "liveOutput" | "usage">): void {
|
|
302
|
+
this.subagentSessions.set(id, { ...data, liveOutput: "", usage: { input: 0, output: 0 } });
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
updateLiveSubagent(
|
|
306
|
+
id: string,
|
|
307
|
+
output: string,
|
|
308
|
+
input: number,
|
|
309
|
+
outputTokens: number,
|
|
310
|
+
transcript?: TranscriptMessage[],
|
|
311
|
+
): void {
|
|
312
|
+
const s = this.subagentSessions.get(id);
|
|
313
|
+
if (s) {
|
|
314
|
+
s.liveOutput = output;
|
|
315
|
+
s.usage = { input, output: outputTokens };
|
|
316
|
+
// Issue #105: foreground streaming transcript — stored by reference
|
|
317
|
+
// (the runner mutates the same tail array; the 200ms drill-in tick
|
|
318
|
+
// re-reads it, so no copy is needed).
|
|
319
|
+
if (transcript) s.transcript = transcript;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
finalizeLiveSubagent(id: string): boolean {
|
|
324
|
+
// Idempotent: the poller (completion/crash paths) and the stale sweep
|
|
325
|
+
// can both race to finalize the same id — the first call claims it,
|
|
326
|
+
// repeats are no-ops. Returns true when THIS call claimed the finalize,
|
|
327
|
+
// so callers that adjust counters can gate on it (issue #52, PR #71
|
|
328
|
+
// review: the poller must not decrement when the sweep already did).
|
|
329
|
+
if (this._finalizedLiveIds.has(id)) return false;
|
|
330
|
+
this._finalizedLiveIds.add(id);
|
|
331
|
+
setTimeout(() => {
|
|
332
|
+
this.subagentSessions.delete(id);
|
|
333
|
+
this._finalizedLiveIds.delete(id);
|
|
334
|
+
}, FINALIZE_RESET_WINDOW_MS);
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** True when the live entry has been finalized (deferred delete pending). */
|
|
339
|
+
isLiveEntryFinalized(id: string): boolean {
|
|
340
|
+
return this._finalizedLiveIds.has(id);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Race-safe stale-entry finalize used by the monitor's staleness sweep
|
|
345
|
+
* (issue #52 part 2). Claims the finalize for `id` exactly once and
|
|
346
|
+
* removes the entry from the live map IMMEDIATELY (stale entries must not
|
|
347
|
+
* linger — a stale-only map must render as empty right away).
|
|
348
|
+
*
|
|
349
|
+
* Returns false (no-op) when:
|
|
350
|
+
* - the entry is already finalized (poller or an earlier sweep pass), or
|
|
351
|
+
* - `completedAt` is set but too fresh: a live poller finalizes within one
|
|
352
|
+
* poller tick of completion, so a fresher record means the poller may
|
|
353
|
+
* still fire — finalizing here would double-decrement the counter.
|
|
354
|
+
*
|
|
355
|
+
* NOTE: this method does NOT touch activeSubagents — callers that know the
|
|
356
|
+
* entry was counted (background agents) must mirror the poller's
|
|
357
|
+
* decrement+clamp after a truthy return.
|
|
358
|
+
*/
|
|
359
|
+
finalizeStaleLiveSubagent(id: string, completedAt?: number): boolean {
|
|
360
|
+
if (this._finalizedLiveIds.has(id)) return false;
|
|
361
|
+
if (completedAt !== undefined && Date.now() - completedAt < STALE_FINALIZE_GRACE_MS) {
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
// Claim through finalizeLiveSubagent (the shared claim set makes this
|
|
365
|
+
// race-safe with the poller), then remove the entry synchronously —
|
|
366
|
+
// stale entries must not linger. Return the claim result explicitly so
|
|
367
|
+
// the sweep's caller knows WHO won the race (PR #71 review).
|
|
368
|
+
const claimed = this.finalizeLiveSubagent(id);
|
|
369
|
+
this.subagentSessions.delete(id);
|
|
370
|
+
return claimed;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Session-shutdown hygiene: drop all pending finalize claims so a fresh
|
|
375
|
+
* session can claim the same ids again. Claims are time-bounded anyway
|
|
376
|
+
* (FINALIZE_RESET_WINDOW_MS), but the shutdown handler clears the live
|
|
377
|
+
* map and counters too — the claim set must follow suit or a new session
|
|
378
|
+
* would no-op on an id a stale claim still remembers (PR #71 review).
|
|
379
|
+
*/
|
|
380
|
+
resetLiveFinalizeClaims(): void {
|
|
381
|
+
this._finalizedLiveIds.clear();
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// -------------------------------------------------------------------
|
|
385
|
+
// Seen/unseen tracking
|
|
386
|
+
// -------------------------------------------------------------------
|
|
387
|
+
|
|
388
|
+
markRunSeen(runId: string): boolean {
|
|
389
|
+
if (!this.config.seenRunIds.includes(runId)) {
|
|
390
|
+
this.config.seenRunIds.push(runId);
|
|
391
|
+
if (this.unseenSubagents > 0) this.unseenSubagents--;
|
|
392
|
+
return true;
|
|
393
|
+
}
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// -------------------------------------------------------------------
|
|
398
|
+
// Circuit breaker
|
|
399
|
+
// -------------------------------------------------------------------
|
|
400
|
+
|
|
401
|
+
private defaultCircuitBreaker(): CircuitBreakerState {
|
|
402
|
+
return {
|
|
403
|
+
consecutiveFailures: 0,
|
|
404
|
+
lastFailureTime: 0,
|
|
405
|
+
circuitOpen: false,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Reset consecutiveFailures to 0, close the circuit, and clear
|
|
411
|
+
* the degraded thinking level.
|
|
412
|
+
*/
|
|
413
|
+
recordSuccess(): void {
|
|
414
|
+
this.config.circuitBreaker.consecutiveFailures = 0;
|
|
415
|
+
this.config.circuitBreaker.circuitOpen = false;
|
|
416
|
+
this.config.circuitBreaker.degradedThinkingLevel = undefined;
|
|
417
|
+
this.config.circuitBreaker.lastFailureTime = 0;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Increment consecutiveFailures. If the threshold is reached,
|
|
422
|
+
* open the circuit, record the failure time, and set the
|
|
423
|
+
* degraded thinking level.
|
|
424
|
+
*/
|
|
425
|
+
recordFailure(): void {
|
|
426
|
+
this.config.circuitBreaker.consecutiveFailures++;
|
|
427
|
+
if (this.config.circuitBreaker.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
428
|
+
this.config.circuitBreaker.circuitOpen = true;
|
|
429
|
+
this.config.circuitBreaker.lastFailureTime = Date.now();
|
|
430
|
+
this.config.circuitBreaker.degradedThinkingLevel = CIRCUIT_DEGRADED_THINKING;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Check whether the circuit breaker is currently open.
|
|
436
|
+
* Applies auto-recovery if enough time has passed since
|
|
437
|
+
* the last failure. Returns the result with status info.
|
|
438
|
+
*/
|
|
439
|
+
checkCircuit(): { isOpen: boolean; message?: string; waitTimeRemaining?: number } {
|
|
440
|
+
const cb = this.config.circuitBreaker;
|
|
441
|
+
|
|
442
|
+
if (!cb.circuitOpen) {
|
|
443
|
+
return { isOpen: false };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const elapsed = Date.now() - cb.lastFailureTime;
|
|
447
|
+
|
|
448
|
+
// Auto-recover if the reset window has passed
|
|
449
|
+
if (elapsed >= CIRCUIT_BREAKER_RESET_MS) {
|
|
450
|
+
cb.consecutiveFailures = 0;
|
|
451
|
+
cb.circuitOpen = false;
|
|
452
|
+
cb.degradedThinkingLevel = undefined;
|
|
453
|
+
cb.lastFailureTime = 0;
|
|
454
|
+
return { isOpen: false };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const waitTimeRemaining = CIRCUIT_BREAKER_RESET_MS - elapsed;
|
|
458
|
+
return {
|
|
459
|
+
isOpen: true,
|
|
460
|
+
message:
|
|
461
|
+
`Circuit breaker is open: ${cb.consecutiveFailures} consecutive failures. ` +
|
|
462
|
+
`Auto-recovery in ${Math.ceil(waitTimeRemaining / 1000)}s. ` +
|
|
463
|
+
`Wait or reduce thinkingLevel to ${CIRCUIT_DEGRADED_THINKING} and try again.`,
|
|
464
|
+
waitTimeRemaining,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// -------------------------------------------------------------------
|
|
469
|
+
// Cost governance — R5
|
|
470
|
+
// -------------------------------------------------------------------
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Sum the cost of all completed runs in the current session.
|
|
474
|
+
*/
|
|
475
|
+
getSessionTotalCost(ctx: ExtensionContext): number {
|
|
476
|
+
const runs = this.getRunEntries(ctx);
|
|
477
|
+
return runs.reduce((acc, r) => acc + (r.cost ?? 0), 0);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Check if adding `cost` would exceed the session cost limit.
|
|
482
|
+
* Returns true if the limit would be exceeded (or is already exceeded).
|
|
483
|
+
* Returns false if the limit is 0 (unlimited) or the new total is within budget.
|
|
484
|
+
*/
|
|
485
|
+
checkCostLimit(cost: number, ctx: ExtensionContext): boolean {
|
|
486
|
+
if (this.config.sessionCostLimit === 0) return false;
|
|
487
|
+
const currentTotal = this.getSessionTotalCost(ctx);
|
|
488
|
+
return currentTotal + cost > this.config.sessionCostLimit;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// -------------------------------------------------------------------
|
|
492
|
+
// Reset
|
|
493
|
+
// -------------------------------------------------------------------
|
|
494
|
+
|
|
495
|
+
reset(): void {
|
|
496
|
+
this.config.model = undefined;
|
|
497
|
+
this.config.maxThinkingLevel = "off";
|
|
498
|
+
this.config.maxParallel = 0;
|
|
499
|
+
this.config.maxSubagentDepth = DEFAULT_MAX_SUBAGENT_DEPTH;
|
|
500
|
+
this.config.gitMode = "none";
|
|
501
|
+
this.config.approvalMode = "writes";
|
|
502
|
+
this.config.completionNotify = DEFAULT_COMPLETION_NOTIFY;
|
|
503
|
+
this.config.maxHistoryEntries = MAX_RUN_HISTORY_ENTRIES;
|
|
504
|
+
this.config.sessionCostLimit = DEFAULT_SESSION_COST_LIMIT;
|
|
505
|
+
this.config.perTaskCostEstimate = 0;
|
|
506
|
+
this.config.templates = [];
|
|
507
|
+
this.config.circuitBreaker = this.defaultCircuitBreaker();
|
|
508
|
+
this.config.slaTrackingEnabled = false;
|
|
509
|
+
this.config.slaWindowSize = 50;
|
|
510
|
+
this.config.lastSLAMetrics = undefined;
|
|
511
|
+
this.config.updateCheckEnabled = true;
|
|
512
|
+
this.config.lastUpdateCheck = 0;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// ---------------------------------------------------------------------------
|
|
517
|
+
// Factory
|
|
518
|
+
// ---------------------------------------------------------------------------
|
|
519
|
+
|
|
520
|
+
export function createSessionState(log?: Logger): SessionState {
|
|
521
|
+
return new SessionState(log);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// ---------------------------------------------------------------------------
|
|
525
|
+
// Live-monitor staleness sweep (issue #52 part 2)
|
|
526
|
+
// ---------------------------------------------------------------------------
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Self-healing sweep for stale live-monitor entries.
|
|
530
|
+
*
|
|
531
|
+
* Staleness vector: the live map (`state.subagentSessions`) is only emptied
|
|
532
|
+
* when the background poller's completion/crash paths call finalizeLiveSubagent
|
|
533
|
+
* or when session_shutdown clears the whole map. If the poller dies mid-run
|
|
534
|
+
* (extension reload, process kill, hard-cap crash), the map entry survives and
|
|
535
|
+
* the monitor shows "running" for an agent whose disk record is terminal.
|
|
536
|
+
*
|
|
537
|
+
* This sweep runs in the monitor/dashboard render path and finalizes every
|
|
538
|
+
* entry that is provably terminal:
|
|
539
|
+
* - `getAgent(id)` returns a record with `completedAt` set → the background
|
|
540
|
+
* agent is terminal (completed/failed/stopped) but the poller never
|
|
541
|
+
* finalized it → finalize + mirror the poller's activeSubagents bookkeeping
|
|
542
|
+
* (decrement + clamp at 0). The grace window inside
|
|
543
|
+
* finalizeStaleLiveSubagent guarantees the poller can no longer fire, so
|
|
544
|
+
* the counter cannot be double-decremented.
|
|
545
|
+
* - `getAgent(id)` returns null → no agent record. Background agents ALWAYS
|
|
546
|
+
* have a record (in-memory + disk), so a null record means either a
|
|
547
|
+
* FOREGROUND run (registered with a runId, never persisted as an agent) or
|
|
548
|
+
* a record removed out-of-band. Foreground runs persist run entries, so a
|
|
549
|
+
* 'running' run entry proves the entry is genuinely live; anything else
|
|
550
|
+
* (done/failed, or no run entry) is stale → finalize WITHOUT counter
|
|
551
|
+
* adjustment (foreground entries are never counted in activeSubagents).
|
|
552
|
+
*
|
|
553
|
+
* The sweep never touches `completedSubagents`/`failedSubagents`/
|
|
554
|
+
* `unseenSubagents`: those are incremented by the poller's own completion
|
|
555
|
+
* paths, and the sweep exists only to cover the poller-DEAD case.
|
|
556
|
+
*
|
|
557
|
+
* Returns the number of entries finalized (0 when nothing was stale).
|
|
558
|
+
*/
|
|
559
|
+
export function sweepStaleLiveSubagents(
|
|
560
|
+
state: SessionState,
|
|
561
|
+
getAgent: (id: string) => { completedAt?: number } | null,
|
|
562
|
+
): number {
|
|
563
|
+
let finalized = 0;
|
|
564
|
+
|
|
565
|
+
// Snapshot: sweep-finalized entries are deleted synchronously, so the map
|
|
566
|
+
// changes under iteration — iterate a copy to stay well-defined.
|
|
567
|
+
for (const [id, session] of Array.from(state.subagentSessions)) {
|
|
568
|
+
const agent = getAgent(id);
|
|
569
|
+
if (agent) {
|
|
570
|
+
// Background agent with a record. Terminal on disk but still in the
|
|
571
|
+
// live map = the poller died before its finalize tick.
|
|
572
|
+
if (agent.completedAt && state.finalizeStaleLiveSubagent(id, agent.completedAt)) {
|
|
573
|
+
// Mirror the poller's counter bookkeeping exactly (index.ts):
|
|
574
|
+
// finalize, decrement, clamp at 0.
|
|
575
|
+
state.activeSubagents--;
|
|
576
|
+
if (state.activeSubagents < 0) state.activeSubagents = 0;
|
|
577
|
+
finalized++;
|
|
578
|
+
}
|
|
579
|
+
} else {
|
|
580
|
+
// No agent record — foreground run or a removed record. A foreground
|
|
581
|
+
// run is live only while its run entry says 'running'.
|
|
582
|
+
const runStatus = state.getRunEntries(session.ctx).find((r) => r.id === id)?.status;
|
|
583
|
+
if (runStatus !== "running" && state.finalizeStaleLiveSubagent(id)) {
|
|
584
|
+
finalized++;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
return finalized;
|
|
590
|
+
}
|