@dreb/dashboard 2.43.3 → 2.44.0
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/README.md +41 -0
- package/dist/server/runtime-pool.d.ts +27 -2
- package/dist/server/runtime-pool.d.ts.map +1 -1
- package/dist/server/runtime-pool.js +181 -21
- package/dist/server/runtime-pool.js.map +1 -1
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +44 -8
- package/dist/server/server.js.map +1 -1
- package/dist/shared/protocol.d.ts +34 -2
- package/dist/shared/protocol.d.ts.map +1 -1
- package/dist/shared/protocol.js.map +1 -1
- package/dist/static/assets/index-BrWSaUHE.js +79 -0
- package/dist/static/index.html +1 -1
- package/dist/static/sw.js +1 -1
- package/package.json +2 -1
- package/dist/static/assets/index-Cjkdv_Fx.js +0 -79
package/README.md
CHANGED
|
@@ -72,6 +72,33 @@ Open `http://127.0.0.1:5343`.
|
|
|
72
72
|
and paired-devices management.
|
|
73
73
|
- **Pairing** — remote first-login rotating-code flow.
|
|
74
74
|
|
|
75
|
+
## Fleet transport and freshness
|
|
76
|
+
|
|
77
|
+
A normal dashboard load makes one authoritative `GET /api/fleet`; exceptional
|
|
78
|
+
recovery includes the fleet in its ordered `/api/resync` snapshot. After that,
|
|
79
|
+
live runtime cards are updated by global, event-derived `fleet_snapshot` SSE
|
|
80
|
+
frames, debounced by 200 ms. Those frames are built from the pool's in-memory
|
|
81
|
+
runtime state, so they do not trigger child RPC calls or a disk inventory scan.
|
|
82
|
+
|
|
83
|
+
Disk inventory is separate from live-runtime state. The client narrowly refreshes
|
|
84
|
+
it with `GET /api/sessions` after create, resume, stop, or delete, rather than
|
|
85
|
+
reloading the whole fleet. While the Fleet screen is visible, it refreshes
|
|
86
|
+
per-runtime stats no more often than every 30 seconds; the refresh is
|
|
87
|
+
single-flight, preserves each card's last good values, and exposes refresh
|
|
88
|
+
failures in the UI.
|
|
89
|
+
|
|
90
|
+
Cards use the latest assistant text in hydrated client transcript entries for
|
|
91
|
+
their activity preview. The authoritative initial-load or resync fleet value is
|
|
92
|
+
the fallback until transcript entries are available. Likewise, `ctx%` is always
|
|
93
|
+
copied from authoritative session state or stats, never calculated in the
|
|
94
|
+
browser. Card position remains deterministic: project path, then session start
|
|
95
|
+
time.
|
|
96
|
+
|
|
97
|
+
Opening a session uses one `GET /api/runtimes/:key/hydrate` request. It is backed
|
|
98
|
+
by one `getDashboardSnapshot` RPC call and its matching ordering barrier, instead
|
|
99
|
+
of separately fetching state, messages, and background agents. The existing
|
|
100
|
+
replay/resync ordering contract still applies.
|
|
101
|
+
|
|
75
102
|
## Live connection and recovery
|
|
76
103
|
|
|
77
104
|
The accessible text indicator in the top bar and persistent session header reports
|
|
@@ -246,5 +273,19 @@ npm run build # server (tsgo) + client (vite) → dist/
|
|
|
246
273
|
npm test # server, reducer, and screen smoke tests
|
|
247
274
|
```
|
|
248
275
|
|
|
276
|
+
### Mobile transport profiling
|
|
277
|
+
|
|
278
|
+
Run the opt-in local profiler on the dashboard host:
|
|
279
|
+
|
|
280
|
+
```bash
|
|
281
|
+
npm run --workspace @dreb/dashboard profile:mobile -- http://127.0.0.1:5343
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
It emits aggregate, payload-free HTTP/SSE timing, size, event-type, and burst
|
|
285
|
+
metrics; it does not save fleet or event contents. Capture the default 60
|
|
286
|
+
seconds against a realistic workload of at least five live runtimes. For browser
|
|
287
|
+
acceptance, use Chromium network throttling at 100 ms RTT and 1.5 Mbps; HTTP
|
|
288
|
+
packet loss is not emulated.
|
|
289
|
+
|
|
249
290
|
See `packages/coding-agent/docs/dashboard.md` in the repo for the full
|
|
250
291
|
product documentation.
|
|
@@ -6,10 +6,12 @@
|
|
|
6
6
|
* by an opaque runtime key. The telegram bridge is the in-repo precedent.
|
|
7
7
|
*/
|
|
8
8
|
import { RpcClient } from "@dreb/coding-agent/rpc";
|
|
9
|
-
import { type BackgroundAgentDto, MAX_COMPLETED_BACKGROUND_AGENTS, type RuntimeInfoDto, type SessionStateDto } from "../shared/protocol.js";
|
|
9
|
+
import { type BackgroundAgentDto, type FleetRuntimeSnapshotDto, type FleetSnapshotEventDto, MAX_COMPLETED_BACKGROUND_AGENTS, type RuntimeInfoDto, type SessionStateDto } from "../shared/protocol.js";
|
|
10
10
|
/** Resolve the absolute path to the dreb CLI (RpcClient defaults to a cwd-relative path). */
|
|
11
11
|
export declare function resolveDrebCliPath(): string;
|
|
12
12
|
export type RuntimeEventListener = (key: string, event: Record<string, unknown>) => void;
|
|
13
|
+
/** Listener for coalesced, synchronous fleet runtime snapshots. */
|
|
14
|
+
export type FleetSnapshotListener = (event: FleetSnapshotEventDto) => void;
|
|
13
15
|
export { MAX_COMPLETED_BACKGROUND_AGENTS };
|
|
14
16
|
interface RpcDashboardSnapshot {
|
|
15
17
|
snapshotId: string;
|
|
@@ -33,13 +35,16 @@ export interface RuntimeHandle {
|
|
|
33
35
|
attention: Map<string, string>;
|
|
34
36
|
/** Last runtime-level error, persisted server-side so fleet refreshes stay honest. */
|
|
35
37
|
error?: string;
|
|
36
|
-
/** Last
|
|
38
|
+
/** Last authoritative state, patched only with event-derivable fields between RPC reads. */
|
|
37
39
|
lastState?: SessionStateDto;
|
|
40
|
+
/** Resume-path fallback; events must never invent or overwrite session identity. */
|
|
41
|
+
sessionFileFallback?: string;
|
|
38
42
|
/** Background agents seen via events (agentId → latest info). */
|
|
39
43
|
backgroundAgents: Map<string, BackgroundAgentDto>;
|
|
40
44
|
}
|
|
41
45
|
export declare const DEFAULT_DASHBOARD_BARRIER_TTL_MS: number;
|
|
42
46
|
export declare const DEFAULT_DASHBOARD_BARRIER_LIMIT = 1000;
|
|
47
|
+
export declare const DEFAULT_FLEET_SNAPSHOT_DEBOUNCE_MS = 200;
|
|
43
48
|
export interface RuntimePoolOptions {
|
|
44
49
|
cliPath?: string;
|
|
45
50
|
/** Extra args for every runtime (e.g. --provider). */
|
|
@@ -56,10 +61,13 @@ export interface RuntimePoolOptions {
|
|
|
56
61
|
dashboardBarrierLimit?: number;
|
|
57
62
|
/** Injectable clock for deterministic barrier-expiry tests. */
|
|
58
63
|
now?: () => number;
|
|
64
|
+
/** Coalescing delay for event-derived fleet snapshot emissions. */
|
|
65
|
+
fleetSnapshotDebounceMs?: number;
|
|
59
66
|
}
|
|
60
67
|
export declare class RuntimePool {
|
|
61
68
|
private readonly runtimes;
|
|
62
69
|
private readonly listeners;
|
|
70
|
+
private readonly fleetSnapshotListeners;
|
|
63
71
|
private readonly cliPath;
|
|
64
72
|
private readonly baseArgs;
|
|
65
73
|
private readonly clientFactory;
|
|
@@ -79,11 +87,20 @@ export declare class RuntimePool {
|
|
|
79
87
|
private readonly dashboardBarrierTtlMs;
|
|
80
88
|
private readonly dashboardBarrierLimit;
|
|
81
89
|
private readonly now;
|
|
90
|
+
private readonly fleetSnapshotDebounceMs;
|
|
82
91
|
private dashboardBarrierPruneTimer;
|
|
92
|
+
private fleetSnapshotTimer;
|
|
83
93
|
private closing;
|
|
84
94
|
constructor(options?: RuntimePoolOptions);
|
|
85
95
|
/** Subscribe to events from every runtime, tagged with the runtime key. */
|
|
86
96
|
onEvent(listener: RuntimeEventListener): () => void;
|
|
97
|
+
/** Subscribe to debounced, in-memory fleet snapshots. */
|
|
98
|
+
onFleetSnapshot(listener: FleetSnapshotListener): () => void;
|
|
99
|
+
/**
|
|
100
|
+
* Build the fleet's live-runtime view without RPC or disk access. Map
|
|
101
|
+
* insertion order is retained intentionally; the UI owns presentation order.
|
|
102
|
+
*/
|
|
103
|
+
fleetSnapshot(): FleetRuntimeSnapshotDto[];
|
|
87
104
|
list(): RuntimeHandle[];
|
|
88
105
|
get(key: string): RuntimeHandle | undefined;
|
|
89
106
|
/**
|
|
@@ -101,6 +118,7 @@ export declare class RuntimePool {
|
|
|
101
118
|
private dashboardBarrierKey;
|
|
102
119
|
private pruneDashboardBarriers;
|
|
103
120
|
private scheduleDashboardBarrierPrune;
|
|
121
|
+
private scheduleFleetSnapshot;
|
|
104
122
|
/** Spawn a new runtime in `cwd`, optionally opening an existing session file. */
|
|
105
123
|
create(cwd: string, sessionPath?: string): Promise<RuntimeHandle>;
|
|
106
124
|
private startSessionRuntime;
|
|
@@ -118,9 +136,16 @@ export declare class RuntimePool {
|
|
|
118
136
|
private handleRuntimeExit;
|
|
119
137
|
private isLiveHandle;
|
|
120
138
|
private handleEvent;
|
|
139
|
+
/**
|
|
140
|
+
* Events intentionally patch only fields they can prove. Session identity,
|
|
141
|
+
* session file, configuration, and context usage remain from the last RPC
|
|
142
|
+
* baseline (or the stable creation fallback) until a later reconciliation.
|
|
143
|
+
*/
|
|
144
|
+
private updateStateFromEvent;
|
|
121
145
|
private recordRuntimeError;
|
|
122
146
|
private pruneCompletedBackgroundAgents;
|
|
123
147
|
private fallbackState;
|
|
148
|
+
private describeFleetRuntime;
|
|
124
149
|
private seedBackgroundAgents;
|
|
125
150
|
/** Snapshot a runtime for the fleet endpoint. */
|
|
126
151
|
describe(handle: RuntimeHandle): Promise<RuntimeInfoDto>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"runtime-pool.d.ts","sourceRoot":"","sources":["../../src/server/runtime-pool.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * RPC runtime pool — one `dreb --mode rpc` child process per live session.\n *\n * dreb's RPC mode is strictly one-session-per-process (switch_session repoints\n * the same process; it never multiplexes), so the pool spawns N children keyed\n * by an opaque runtime key. The telegram bridge is the in-repo precedent.\n */\n\nimport { randomBytes } from \"node:crypto\";\nimport { homedir } from \"node:os\";\nimport { dirname, join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { RpcClient, type RpcExitInfo } from \"@dreb/coding-agent/rpc\";\nimport {\n\ttype BackgroundAgentDto,\n\tMAX_COMPLETED_BACKGROUND_AGENTS,\n\ttype RuntimeInfoDto,\n\ttype RuntimeStatsSummaryDto,\n\ttype SessionStateDto,\n} from \"../shared/protocol.js\";\n\n/** Resolve the absolute path to the dreb CLI (RpcClient defaults to a cwd-relative path). */\nexport function resolveDrebCliPath(): string {\n\tconst resolved = import.meta.resolve(\"@dreb/coding-agent\");\n\treturn join(dirname(fileURLToPath(resolved)), \"cli.js\");\n}\n\nfunction formatRpcExit(info: RpcExitInfo): string {\n\tif (info.error) return `RPC process failed: ${info.error.message}`;\n\treturn `RPC process exited (code ${info.code}, signal ${info.signal})`;\n}\n\nexport type RuntimeEventListener = (key: string, event: Record<string, unknown>) => void;\n\nexport { MAX_COMPLETED_BACKGROUND_AGENTS };\n\ninterface RpcDashboardSnapshot {\n\tsnapshotId: string;\n\tstate: SessionStateDto;\n\tmessages: unknown[];\n\tbackgroundAgents: BackgroundAgentDto[];\n}\n\ntype DashboardSnapshotClient = RpcClient & { getDashboardSnapshot(): Promise<RpcDashboardSnapshot> };\n\nexport interface DashboardRuntimeSnapshot {\n\tkey: string;\n\tbarrierSeq: number;\n\tsnapshot: RpcDashboardSnapshot;\n}\n\nexport interface RuntimeHandle {\n\tkey: string;\n\tcwd: string;\n\tclient: RpcClient;\n\t/** Session start time (ms epoch) — stable tiebreak for deterministic fleet ordering. */\n\tcreatedAt: number;\n\tlastActivity: number;\n\t/** Needs-attention sources, keyed so they can be cleared independently. */\n\tattention: Map<string, string>;\n\t/** Last runtime-level error, persisted server-side so fleet refreshes stay honest. */\n\terror?: string;\n\t/** Last known state, used to keep failed runtime cards renderable. */\n\tlastState?: SessionStateDto;\n\t/** Background agents seen via events (agentId → latest info). */\n\tbackgroundAgents: Map<string, BackgroundAgentDto>;\n}\n\nexport const DEFAULT_DASHBOARD_BARRIER_TTL_MS = 5 * 60_000;\nexport const DEFAULT_DASHBOARD_BARRIER_LIMIT = 1000;\n\nexport interface RuntimePoolOptions {\n\tcliPath?: string;\n\t/** Extra args for every runtime (e.g. --provider). */\n\tbaseArgs?: string[];\n\t/** RpcClient factory override for tests. */\n\tclientFactory?: (options: { cliPath: string; cwd: string; args: string[] }) => RpcClient;\n\tlogger?: (line: string) => void;\n\t/** Bounds unclaimed RPC snapshot ordering records. */\n\tdashboardBarrierTtlMs?: number;\n\tdashboardBarrierLimit?: number;\n\t/** Injectable clock for deterministic barrier-expiry tests. */\n\tnow?: () => number;\n}\n\ninterface DashboardBarrier {\n\tseq: number;\n\trecordedAt: number;\n}\n\nexport class RuntimePool {\n\tprivate readonly runtimes = new Map<string, RuntimeHandle>();\n\tprivate readonly listeners: RuntimeEventListener[] = [];\n\tprivate readonly cliPath: string;\n\tprivate readonly baseArgs: string[];\n\tprivate readonly clientFactory: (options: { cliPath: string; cwd: string; args: string[] }) => RpcClient;\n\tprivate readonly logger: (line: string) => void;\n\t/**\n\t * A single lazily-spawned utility runtime used to service settings/model/\n\t * agent-type endpoints when no user session is live. Kept out of `runtimes`\n\t * (and therefore out of the fleet) so it never shows as a session card.\n\t */\n\tprivate readonly utilities = new Map<string, RuntimeHandle>();\n\tprivate readonly utilityPromises = new Map<string, Promise<RuntimeHandle>>();\n\tprivate readonly starting = new Set<RuntimeHandle>();\n\tprivate readonly startupPromises = new Set<Promise<unknown>>();\n\tprivate readonly exitedHandles = new WeakSet<RuntimeHandle>();\n\t/** Snapshot ordering records observed synchronously from RpcClient stdout. */\n\tprivate readonly dashboardBarriers = new Map<string, DashboardBarrier>();\n\tprivate readonly dashboardBarrierTtlMs: number;\n\tprivate readonly dashboardBarrierLimit: number;\n\tprivate readonly now: () => number;\n\tprivate dashboardBarrierPruneTimer: ReturnType<typeof setTimeout> | undefined;\n\tprivate closing = false;\n\n\tconstructor(options: RuntimePoolOptions = {}) {\n\t\tthis.cliPath = options.cliPath ?? resolveDrebCliPath();\n\t\tthis.baseArgs = options.baseArgs ?? [];\n\t\tthis.clientFactory =\n\t\t\toptions.clientFactory ?? ((o) => new RpcClient({ cliPath: o.cliPath, cwd: o.cwd, args: o.args }));\n\t\tthis.logger = options.logger ?? ((line) => console.warn(`[dashboard] ${line}`));\n\t\tthis.dashboardBarrierTtlMs = options.dashboardBarrierTtlMs ?? DEFAULT_DASHBOARD_BARRIER_TTL_MS;\n\t\tthis.dashboardBarrierLimit = options.dashboardBarrierLimit ?? DEFAULT_DASHBOARD_BARRIER_LIMIT;\n\t\tthis.now = options.now ?? Date.now;\n\t}\n\n\t/** Subscribe to events from every runtime, tagged with the runtime key. */\n\tonEvent(listener: RuntimeEventListener): () => void {\n\t\tthis.listeners.push(listener);\n\t\treturn () => {\n\t\t\tconst i = this.listeners.indexOf(listener);\n\t\t\tif (i !== -1) this.listeners.splice(i, 1);\n\t\t};\n\t}\n\n\tlist(): RuntimeHandle[] {\n\t\treturn [...this.runtimes.values()];\n\t}\n\n\tget(key: string): RuntimeHandle | undefined {\n\t\treturn this.runtimes.get(key);\n\t}\n\n\t/**\n\t * Record the EventHub sequence synchronously when the RPC snapshot marker\n\t * arrives. The marker line precedes its response on stdout, so this runs\n\t * before the RpcClient response continuation even across separate chunks.\n\t */\n\trecordDashboardBarrier(runtimeKey: string, snapshotId: string, seq: number): void {\n\t\tthis.pruneDashboardBarriers();\n\t\tthis.dashboardBarriers.set(this.dashboardBarrierKey(runtimeKey, snapshotId), { seq, recordedAt: this.now() });\n\t\tthis.pruneDashboardBarriers();\n\t}\n\n\t/**\n\t * Capture a parent-session recovery snapshot and pair it with the sequence\n\t * captured at its RPC marker. This deliberately does not infer ordering from\n\t * await: later EventHub publications naturally have higher sequence numbers.\n\t */\n\tasync snapshotDashboard(handle: RuntimeHandle): Promise<DashboardRuntimeSnapshot> {\n\t\tconst snapshot = await (handle.client as DashboardSnapshotClient).getDashboardSnapshot();\n\t\tthis.pruneDashboardBarriers();\n\t\tconst barrierKey = this.dashboardBarrierKey(handle.key, snapshot.snapshotId);\n\t\tconst barrier = this.dashboardBarriers.get(barrierKey);\n\t\tthis.dashboardBarriers.delete(barrierKey);\n\t\tthis.scheduleDashboardBarrierPrune();\n\t\tif (!barrier) {\n\t\t\tthrow new Error(`Dashboard snapshot ${snapshot.snapshotId} arrived without its ordering barrier`);\n\t\t}\n\t\treturn { key: handle.key, barrierSeq: barrier.seq, snapshot };\n\t}\n\n\tprivate dashboardBarrierKey(runtimeKey: string, snapshotId: string): string {\n\t\treturn `${runtimeKey}\\0${snapshotId}`;\n\t}\n\n\tprivate pruneDashboardBarriers(): void {\n\t\tconst oldestAllowed = this.now() - this.dashboardBarrierTtlMs;\n\t\tfor (const [snapshotId, barrier] of this.dashboardBarriers) {\n\t\t\tif (barrier.recordedAt < oldestAllowed) this.dashboardBarriers.delete(snapshotId);\n\t\t}\n\t\twhile (this.dashboardBarriers.size > this.dashboardBarrierLimit) {\n\t\t\tconst oldest = this.dashboardBarriers.keys().next().value;\n\t\t\tif (oldest === undefined) break;\n\t\t\tthis.dashboardBarriers.delete(oldest);\n\t\t}\n\t\tthis.scheduleDashboardBarrierPrune();\n\t}\n\n\tprivate scheduleDashboardBarrierPrune(): void {\n\t\tif (this.dashboardBarrierPruneTimer) clearTimeout(this.dashboardBarrierPruneTimer);\n\t\tthis.dashboardBarrierPruneTimer = undefined;\n\t\tlet oldest: DashboardBarrier | undefined;\n\t\tfor (const barrier of this.dashboardBarriers.values()) {\n\t\t\tif (!oldest || barrier.recordedAt < oldest.recordedAt) oldest = barrier;\n\t\t}\n\t\tif (!oldest) return;\n\t\tconst delay = Math.max(1, oldest.recordedAt + this.dashboardBarrierTtlMs - this.now() + 1);\n\t\tthis.dashboardBarrierPruneTimer = setTimeout(() => {\n\t\t\tthis.dashboardBarrierPruneTimer = undefined;\n\t\t\tthis.pruneDashboardBarriers();\n\t\t}, delay);\n\t\tthis.dashboardBarrierPruneTimer.unref?.();\n\t}\n\n\t/** Spawn a new runtime in `cwd`, optionally opening an existing session file. */\n\tasync create(cwd: string, sessionPath?: string): Promise<RuntimeHandle> {\n\t\tif (this.closing) throw new Error(\"Runtime pool is closing\");\n\t\tconst key = randomBytes(6).toString(\"hex\");\n\t\tconst args = [\"--ui\", \"dashboard\", ...this.baseArgs];\n\t\tif (sessionPath) args.push(\"--session\", sessionPath);\n\t\tconst client = this.clientFactory({ cliPath: this.cliPath, cwd, args });\n\t\tconst handle: RuntimeHandle = {\n\t\t\tkey,\n\t\t\tcwd,\n\t\t\tclient,\n\t\t\tcreatedAt: Date.now(),\n\t\t\tlastActivity: Date.now(),\n\t\t\tattention: new Map(),\n\t\t\tbackgroundAgents: new Map(),\n\t\t};\n\t\tclient.onEvent((event) => this.handleEvent(handle, event as unknown as Record<string, unknown>));\n\t\tclient.onExit((info) => this.handleRuntimeExit(handle, info));\n\n\t\tconst startup = this.startSessionRuntime(handle);\n\t\tthis.startupPromises.add(startup);\n\t\ttry {\n\t\t\treturn await startup;\n\t\t} finally {\n\t\t\tthis.startupPromises.delete(startup);\n\t\t}\n\t}\n\n\tprivate async startSessionRuntime(handle: RuntimeHandle): Promise<RuntimeHandle> {\n\t\tthis.starting.add(handle);\n\t\ttry {\n\t\t\tif (this.closing) throw new Error(\"Runtime pool is closing\");\n\t\t\tawait handle.client.start();\n\t\t\tif (this.closing) {\n\t\t\t\tawait handle.client.stop();\n\t\t\t\tthrow new Error(\"Runtime pool is closing\");\n\t\t\t}\n\t\t\tawait this.seedBackgroundAgents(handle);\n\t\t\tif (this.closing) {\n\t\t\t\tawait handle.client.stop();\n\t\t\t\tthrow new Error(\"Runtime pool is closing\");\n\t\t\t}\n\t\t\tthis.runtimes.set(handle.key, handle);\n\t\t\treturn handle;\n\t\t} finally {\n\t\t\tthis.starting.delete(handle);\n\t\t}\n\t}\n\n\t/** Stop a runtime and remove it from the pool. */\n\tasync stop(key: string): Promise<boolean> {\n\t\tconst handle = this.runtimes.get(key);\n\t\tif (!handle) return false;\n\t\tthis.handleEvent(handle, { type: \"runtime_removed\" });\n\t\tthis.runtimes.delete(key);\n\t\tawait handle.client.stop();\n\t\treturn true;\n\t}\n\n\t/**\n\t * Return any live runtime suitable for process-global settings work, spawning\n\t * a hidden utility runtime (in the home directory) if no user session exists.\n\t * This is what lets the settings page — models, agent types, defaults — work\n\t * with zero sessions open, instead of 503-ing.\n\t */\n\tasync ensureUtilityRuntime(cwd = homedir()): Promise<RuntimeHandle> {\n\t\tif (this.closing) throw new Error(\"Runtime pool is closing\");\n\t\tconst existing = this.utilities.get(cwd);\n\t\tif (existing) return existing;\n\t\tlet promise = this.utilityPromises.get(cwd);\n\t\tif (!promise) {\n\t\t\tconst args = [\"--ui\", \"dashboard\", ...this.baseArgs];\n\t\t\tconst client = this.clientFactory({ cliPath: this.cliPath, cwd, args });\n\t\t\tconst handle: RuntimeHandle = {\n\t\t\t\tkey: `utility:${cwd}`,\n\t\t\t\tcwd,\n\t\t\t\tclient,\n\t\t\t\tcreatedAt: Date.now(),\n\t\t\t\tlastActivity: Date.now(),\n\t\t\t\tattention: new Map(),\n\t\t\t\tbackgroundAgents: new Map(),\n\t\t\t};\n\t\t\tconst startup = this.startUtilityRuntime(cwd, handle);\n\t\t\tthis.startupPromises.add(startup);\n\t\t\tpromise = startup\n\t\t\t\t.catch((err) => {\n\t\t\t\t\t// Allow a retry on the next request instead of caching the failure.\n\t\t\t\t\tthis.utilityPromises.delete(cwd);\n\t\t\t\t\tthrow err;\n\t\t\t\t})\n\t\t\t\t.finally(() => {\n\t\t\t\t\tthis.startupPromises.delete(startup);\n\t\t\t\t});\n\t\t\tthis.utilityPromises.set(cwd, promise);\n\t\t}\n\t\treturn promise;\n\t}\n\n\tprivate async startUtilityRuntime(cwd: string, handle: RuntimeHandle): Promise<RuntimeHandle> {\n\t\tthis.starting.add(handle);\n\t\ttry {\n\t\t\tif (this.closing) throw new Error(\"Runtime pool is closing\");\n\t\t\tawait handle.client.start();\n\t\t\tif (this.closing) {\n\t\t\t\tawait handle.client.stop();\n\t\t\t\tthrow new Error(\"Runtime pool is closing\");\n\t\t\t}\n\t\t\tthis.utilities.set(cwd, handle);\n\t\t\treturn handle;\n\t\t} finally {\n\t\t\tthis.starting.delete(handle);\n\t\t}\n\t}\n\n\tasync stopAll(): Promise<void> {\n\t\tthis.closing = true;\n\t\tif (this.dashboardBarrierPruneTimer) clearTimeout(this.dashboardBarrierPruneTimer);\n\t\tthis.dashboardBarrierPruneTimer = undefined;\n\t\tthis.dashboardBarriers.clear();\n\t\tconst handles = new Set<RuntimeHandle>([\n\t\t\t...this.runtimes.values(),\n\t\t\t...this.utilities.values(),\n\t\t\t...this.starting.values(),\n\t\t]);\n\t\tconst startupPromises = new Set<Promise<unknown>>([...this.startupPromises, ...this.utilityPromises.values()]);\n\t\tthis.runtimes.clear();\n\t\tthis.utilities.clear();\n\t\tthis.utilityPromises.clear();\n\t\tawait Promise.allSettled([...handles].map((handle) => handle.client.stop()));\n\t\tawait Promise.allSettled([...startupPromises]);\n\t}\n\n\tprivate handleRuntimeExit(handle: RuntimeHandle, info: RpcExitInfo): void {\n\t\tif (this.closing || this.exitedHandles.has(handle) || !this.isLiveHandle(handle)) return;\n\t\tthis.exitedHandles.add(handle);\n\t\tconst message = formatRpcExit(info);\n\t\tthis.recordRuntimeError(handle, message);\n\t\tthis.logger(`runtime ${handle.key} ${message}`);\n\t\tthis.handleEvent(handle, { type: \"agent_end\", messages: [], aborted: true, errorMessage: message });\n\t}\n\n\tprivate isLiveHandle(handle: RuntimeHandle): boolean {\n\t\treturn (\n\t\t\tthis.runtimes.get(handle.key) === handle ||\n\t\t\tthis.utilities.get(handle.cwd) === handle ||\n\t\t\tthis.starting.has(handle)\n\t\t);\n\t}\n\n\tprivate handleEvent(handle: RuntimeHandle, event: Record<string, unknown>): void {\n\t\thandle.lastActivity = Date.now();\n\t\tconst type = event.type as string;\n\n\t\t// Track needs-attention sources: extension UI requests, parent\n\t\t// paused, error states.\n\t\tif (type === \"extension_ui_request\") {\n\t\t\tconst method = event.method as string;\n\t\t\tif (method === \"select\" || method === \"confirm\" || method === \"input\" || method === \"editor\") {\n\t\t\t\thandle.attention.set(`ui:${event.id}`, `extension ${method} awaiting response`);\n\t\t\t}\n\t\t}\n\t\tif (type === \"extension_ui_response_handled\") {\n\t\t\thandle.attention.delete(`ui:${event.id}`);\n\t\t}\n\t\tif (type === \"agent_start\") {\n\t\t\t// A new turn clears prior UI-request attention (requests were resolved or timed out).\n\t\t\tfor (const k of [...handle.attention.keys()]) {\n\t\t\t\tif (k.startsWith(\"ui:\")) handle.attention.delete(k);\n\t\t\t}\n\t\t\thandle.attention.delete(\"paused\");\n\t\t\thandle.attention.delete(\"suggest\");\n\t\t\thandle.attention.delete(\"error\");\n\t\t\thandle.error = undefined;\n\t\t}\n\t\tif (type === \"suggest_next\") {\n\t\t\t// suggest_next as the ending action = \"your move\": mark needs-attention\n\t\t\t// so the fleet card doesn't read idle. Cleared on the next agent_start.\n\t\t\thandle.attention.set(\"suggest\", \"suggested command awaiting\");\n\t\t}\n\t\tif (type === \"parent_paused_for_background_agents\") {\n\t\t\thandle.attention.set(\"paused\", `paused — ${event.runningAgentCount} background agents running`);\n\t\t}\n\t\tif (type === \"agent_end\") {\n\t\t\thandle.attention.delete(\"paused\");\n\t\t}\n\t\tif (type === \"auto_retry_end\" && event.success === false && event.finalError) {\n\t\t\tthis.recordRuntimeError(handle, String(event.finalError));\n\t\t}\n\t\tif (type === \"auto_compaction_end\" && event.errorMessage) {\n\t\t\tthis.recordRuntimeError(handle, String(event.errorMessage));\n\t\t}\n\n\t\t// Track background agents from lifecycle events.\n\t\tif (type === \"background_agent_start\") {\n\t\t\thandle.backgroundAgents.set(event.agentId as string, {\n\t\t\t\tagentId: event.agentId as string,\n\t\t\t\tagentType: event.agentType as string,\n\t\t\t\ttaskSummary: event.taskSummary as string,\n\t\t\t\tstartedAt: new Date().toISOString(),\n\t\t\t\tstatus: \"running\",\n\t\t\t\tsessionDir: event.sessionDir as string | undefined,\n\t\t\t});\n\t\t}\n\t\tif (type === \"background_agent_end\") {\n\t\t\tconst existing = handle.backgroundAgents.get(event.agentId as string);\n\t\t\tif (existing) {\n\t\t\t\texisting.status = event.success ? \"completed\" : \"failed\";\n\t\t\t\texisting.sessionFile = (event.sessionFile as string | undefined) ?? existing.sessionFile;\n\t\t\t\tthis.pruneCompletedBackgroundAgents(handle);\n\t\t\t}\n\t\t}\n\n\t\tfor (const listener of this.listeners) {\n\t\t\ttry {\n\t\t\t\tlistener(handle.key, event);\n\t\t\t} catch {\n\t\t\t\t// A broken SSE subscriber must not break event distribution.\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate recordRuntimeError(handle: RuntimeHandle, message: string): void {\n\t\thandle.error = message;\n\t\thandle.attention.set(\"error\", message);\n\t}\n\n\tprivate pruneCompletedBackgroundAgents(handle: RuntimeHandle): void {\n\t\tconst evictable = [...handle.backgroundAgents.values()]\n\t\t\t.map((agent, index) => {\n\t\t\t\tconst startedAtMs = Date.parse(agent.startedAt);\n\t\t\t\treturn { agent, index, startedAtMs: Number.isFinite(startedAtMs) ? startedAtMs : 0 };\n\t\t\t})\n\t\t\t.filter(({ agent }) => agent.status !== \"running\")\n\t\t\t.sort((a, b) => a.startedAtMs - b.startedAtMs || a.index - b.index);\n\t\tconst excess = evictable.length - MAX_COMPLETED_BACKGROUND_AGENTS;\n\t\tif (excess <= 0) return;\n\t\tfor (const { agent } of evictable.slice(0, excess)) {\n\t\t\thandle.backgroundAgents.delete(agent.agentId);\n\t\t}\n\t}\n\n\tprivate fallbackState(handle: RuntimeHandle): SessionStateDto {\n\t\treturn {\n\t\t\tsessionId: handle.lastState?.sessionId ?? handle.key,\n\t\t\tsessionName: handle.lastState?.sessionName,\n\t\t\ttasks: handle.lastState?.tasks ?? [],\n\t\t\tthinkingLevel: handle.lastState?.thinkingLevel ?? \"off\",\n\t\t\tisStreaming: false,\n\t\t\tisCompacting: false,\n\t\t\tsteeringMode: handle.lastState?.steeringMode ?? \"all\",\n\t\t\tfollowUpMode: handle.lastState?.followUpMode ?? \"all\",\n\t\t\tsessionFile: handle.lastState?.sessionFile,\n\t\t\tautoCompactionEnabled: handle.lastState?.autoCompactionEnabled ?? false,\n\t\t\tmessageCount: handle.lastState?.messageCount ?? 0,\n\t\t\tpendingMessageCount: handle.lastState?.pendingMessageCount ?? 0,\n\t\t\tcontextUsage: handle.lastState?.contextUsage,\n\t\t\tmodel: handle.lastState?.model,\n\t\t\tmodelFallbackMessage: handle.lastState?.modelFallbackMessage,\n\t\t};\n\t}\n\n\tprivate async seedBackgroundAgents(handle: RuntimeHandle): Promise<void> {\n\t\ttry {\n\t\t\tconst agents = (await handle.client.listBackgroundAgents()) as unknown as BackgroundAgentDto[];\n\t\t\tfor (const agent of agents) {\n\t\t\t\thandle.backgroundAgents.set(agent.agentId, agent);\n\t\t\t}\n\t\t\tthis.pruneCompletedBackgroundAgents(handle);\n\t\t} catch (err) {\n\t\t\tthis.logger(\n\t\t\t\t`runtime ${handle.key} background-agent registry unavailable: ${err instanceof Error ? err.message : String(err)}`,\n\t\t\t);\n\t\t}\n\t}\n\n\t/** Snapshot a runtime for the fleet endpoint. */\n\tasync describe(handle: RuntimeHandle): Promise<RuntimeInfoDto> {\n\t\tlet state: SessionStateDto;\n\t\ttry {\n\t\t\tstate = (await handle.client.getState()) as unknown as SessionStateDto;\n\t\t\thandle.lastState = state;\n\t\t\tif (handle.error?.startsWith(\"RPC process\")) {\n\t\t\t\thandle.error = undefined;\n\t\t\t\thandle.attention.delete(\"error\");\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tconst message = err instanceof Error ? err.message : String(err);\n\t\t\tthis.recordRuntimeError(handle, message);\n\t\t\tthis.logger(`runtime ${handle.key} state unavailable for fleet card: ${message}`);\n\t\t\tstate = this.fallbackState(handle);\n\t\t}\n\t\tlet stats: RuntimeStatsSummaryDto | undefined;\n\t\ttry {\n\t\t\tconst sessionStats = await handle.client.getSessionStats();\n\t\t\tstats = { tokensTotal: sessionStats.tokens.total, cost: sessionStats.cost };\n\t\t} catch (err) {\n\t\t\tthis.logger(\n\t\t\t\t`runtime ${handle.key} stats unavailable for fleet card: ${err instanceof Error ? err.message : String(err)}`,\n\t\t\t);\n\t\t}\n\t\tlet lastAssistantText: string | undefined;\n\t\ttry {\n\t\t\tconst text = await handle.client.getLastAssistantText();\n\t\t\tlastAssistantText = text ? text.slice(0, 200) : undefined;\n\t\t} catch (err) {\n\t\t\tthis.logger(\n\t\t\t\t`runtime ${handle.key} last assistant text unavailable for fleet card: ${err instanceof Error ? err.message : String(err)}`,\n\t\t\t);\n\t\t}\n\t\treturn {\n\t\t\tkey: handle.key,\n\t\t\tcwd: handle.cwd,\n\t\t\tstate,\n\t\t\tstats,\n\t\t\tbackgroundAgents: [...handle.backgroundAgents.values()],\n\t\t\tneedsAttention: handle.attention.size > 0,\n\t\t\terror: handle.error,\n\t\t\tlastAssistantText,\n\t\t\tcreatedAt: new Date(handle.createdAt).toISOString(),\n\t\t\tlastActivity: new Date(handle.lastActivity).toISOString(),\n\t\t};\n\t}\n}\n"]}
|
|
1
|
+
{"version":3,"file":"runtime-pool.d.ts","sourceRoot":"","sources":["../../src/server/runtime-pool.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * RPC runtime pool — one `dreb --mode rpc` child process per live session.\n *\n * dreb's RPC mode is strictly one-session-per-process (switch_session repoints\n * the same process; it never multiplexes), so the pool spawns N children keyed\n * by an opaque runtime key. The telegram bridge is the in-repo precedent.\n */\n\nimport { randomBytes } from \"node:crypto\";\nimport { homedir } from \"node:os\";\nimport { dirname, join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { isDeepStrictEqual } from \"node:util\";\nimport { RpcClient, type RpcExitInfo } from \"@dreb/coding-agent/rpc\";\nimport {\n\ttype BackgroundAgentDto,\n\ttype FleetRuntimeSnapshotDto,\n\ttype FleetSnapshotEventDto,\n\tMAX_COMPLETED_BACKGROUND_AGENTS,\n\ttype RuntimeInfoDto,\n\ttype RuntimeStatsSummaryDto,\n\ttype SessionStateDto,\n} from \"../shared/protocol.js\";\n\n/** Resolve the absolute path to the dreb CLI (RpcClient defaults to a cwd-relative path). */\nexport function resolveDrebCliPath(): string {\n\tconst resolved = import.meta.resolve(\"@dreb/coding-agent\");\n\treturn join(dirname(fileURLToPath(resolved)), \"cli.js\");\n}\n\nfunction formatRpcExit(info: RpcExitInfo): string {\n\tif (info.error) return `RPC process failed: ${info.error.message}`;\n\treturn `RPC process exited (code ${info.code}, signal ${info.signal})`;\n}\n\nexport type RuntimeEventListener = (key: string, event: Record<string, unknown>) => void;\n\n/** Listener for coalesced, synchronous fleet runtime snapshots. */\nexport type FleetSnapshotListener = (event: FleetSnapshotEventDto) => void;\n\nexport { MAX_COMPLETED_BACKGROUND_AGENTS };\n\ninterface RpcDashboardSnapshot {\n\tsnapshotId: string;\n\tstate: SessionStateDto;\n\tmessages: unknown[];\n\tbackgroundAgents: BackgroundAgentDto[];\n}\n\ntype DashboardSnapshotClient = RpcClient & { getDashboardSnapshot(): Promise<RpcDashboardSnapshot> };\n\nexport interface DashboardRuntimeSnapshot {\n\tkey: string;\n\tbarrierSeq: number;\n\tsnapshot: RpcDashboardSnapshot;\n}\n\nexport interface RuntimeHandle {\n\tkey: string;\n\tcwd: string;\n\tclient: RpcClient;\n\t/** Session start time (ms epoch) — stable tiebreak for deterministic fleet ordering. */\n\tcreatedAt: number;\n\tlastActivity: number;\n\t/** Needs-attention sources, keyed so they can be cleared independently. */\n\tattention: Map<string, string>;\n\t/** Last runtime-level error, persisted server-side so fleet refreshes stay honest. */\n\terror?: string;\n\t/** Last authoritative state, patched only with event-derivable fields between RPC reads. */\n\tlastState?: SessionStateDto;\n\t/** Resume-path fallback; events must never invent or overwrite session identity. */\n\tsessionFileFallback?: string;\n\t/** Background agents seen via events (agentId → latest info). */\n\tbackgroundAgents: Map<string, BackgroundAgentDto>;\n}\n\nexport const DEFAULT_DASHBOARD_BARRIER_TTL_MS = 5 * 60_000;\nexport const DEFAULT_DASHBOARD_BARRIER_LIMIT = 1000;\nexport const DEFAULT_FLEET_SNAPSHOT_DEBOUNCE_MS = 200;\n\n/** Events that mutate a field carried by the lightweight fleet snapshot. */\nconst FLEET_SNAPSHOT_EVENT_TYPES = new Set([\n\t\"agent_start\",\n\t\"agent_end\",\n\t\"auto_compaction_start\",\n\t\"auto_compaction_end\",\n\t\"auto_retry_end\",\n\t\"background_agent_start\",\n\t\"background_agent_end\",\n\t\"extension_ui_request\",\n\t\"extension_ui_response_handled\",\n\t\"message_start\",\n\t\"parent_paused_for_background_agents\",\n\t\"runtime_removed\",\n\t\"session_name_changed\",\n\t\"suggest_next\",\n\t\"tasks_update\",\n]);\n\nexport interface RuntimePoolOptions {\n\tcliPath?: string;\n\t/** Extra args for every runtime (e.g. --provider). */\n\tbaseArgs?: string[];\n\t/** RpcClient factory override for tests. */\n\tclientFactory?: (options: { cliPath: string; cwd: string; args: string[] }) => RpcClient;\n\tlogger?: (line: string) => void;\n\t/** Bounds unclaimed RPC snapshot ordering records. */\n\tdashboardBarrierTtlMs?: number;\n\tdashboardBarrierLimit?: number;\n\t/** Injectable clock for deterministic barrier-expiry tests. */\n\tnow?: () => number;\n\t/** Coalescing delay for event-derived fleet snapshot emissions. */\n\tfleetSnapshotDebounceMs?: number;\n}\n\ninterface DashboardBarrier {\n\tseq: number;\n\trecordedAt: number;\n}\n\nexport class RuntimePool {\n\tprivate readonly runtimes = new Map<string, RuntimeHandle>();\n\tprivate readonly listeners: RuntimeEventListener[] = [];\n\tprivate readonly fleetSnapshotListeners: FleetSnapshotListener[] = [];\n\tprivate readonly cliPath: string;\n\tprivate readonly baseArgs: string[];\n\tprivate readonly clientFactory: (options: { cliPath: string; cwd: string; args: string[] }) => RpcClient;\n\tprivate readonly logger: (line: string) => void;\n\t/**\n\t * A single lazily-spawned utility runtime used to service settings/model/\n\t * agent-type endpoints when no user session is live. Kept out of `runtimes`\n\t * (and therefore out of the fleet) so it never shows as a session card.\n\t */\n\tprivate readonly utilities = new Map<string, RuntimeHandle>();\n\tprivate readonly utilityPromises = new Map<string, Promise<RuntimeHandle>>();\n\tprivate readonly starting = new Set<RuntimeHandle>();\n\tprivate readonly startupPromises = new Set<Promise<unknown>>();\n\tprivate readonly exitedHandles = new WeakSet<RuntimeHandle>();\n\t/** Snapshot ordering records observed synchronously from RpcClient stdout. */\n\tprivate readonly dashboardBarriers = new Map<string, DashboardBarrier>();\n\tprivate readonly dashboardBarrierTtlMs: number;\n\tprivate readonly dashboardBarrierLimit: number;\n\tprivate readonly now: () => number;\n\tprivate readonly fleetSnapshotDebounceMs: number;\n\tprivate dashboardBarrierPruneTimer: ReturnType<typeof setTimeout> | undefined;\n\tprivate fleetSnapshotTimer: ReturnType<typeof setTimeout> | undefined;\n\tprivate closing = false;\n\n\tconstructor(options: RuntimePoolOptions = {}) {\n\t\tthis.cliPath = options.cliPath ?? resolveDrebCliPath();\n\t\tthis.baseArgs = options.baseArgs ?? [];\n\t\tthis.clientFactory =\n\t\t\toptions.clientFactory ?? ((o) => new RpcClient({ cliPath: o.cliPath, cwd: o.cwd, args: o.args }));\n\t\tthis.logger = options.logger ?? ((line) => console.warn(`[dashboard] ${line}`));\n\t\tthis.dashboardBarrierTtlMs = options.dashboardBarrierTtlMs ?? DEFAULT_DASHBOARD_BARRIER_TTL_MS;\n\t\tthis.dashboardBarrierLimit = options.dashboardBarrierLimit ?? DEFAULT_DASHBOARD_BARRIER_LIMIT;\n\t\tthis.now = options.now ?? Date.now;\n\t\tthis.fleetSnapshotDebounceMs = options.fleetSnapshotDebounceMs ?? DEFAULT_FLEET_SNAPSHOT_DEBOUNCE_MS;\n\t}\n\n\t/** Subscribe to events from every runtime, tagged with the runtime key. */\n\tonEvent(listener: RuntimeEventListener): () => void {\n\t\tthis.listeners.push(listener);\n\t\treturn () => {\n\t\t\tconst i = this.listeners.indexOf(listener);\n\t\t\tif (i !== -1) this.listeners.splice(i, 1);\n\t\t};\n\t}\n\n\t/** Subscribe to debounced, in-memory fleet snapshots. */\n\tonFleetSnapshot(listener: FleetSnapshotListener): () => void {\n\t\tthis.fleetSnapshotListeners.push(listener);\n\t\treturn () => {\n\t\t\tconst i = this.fleetSnapshotListeners.indexOf(listener);\n\t\t\tif (i !== -1) this.fleetSnapshotListeners.splice(i, 1);\n\t\t};\n\t}\n\n\t/**\n\t * Build the fleet's live-runtime view without RPC or disk access. Map\n\t * insertion order is retained intentionally; the UI owns presentation order.\n\t */\n\tfleetSnapshot(): FleetRuntimeSnapshotDto[] {\n\t\treturn [...this.runtimes.values()].map((handle) => this.describeFleetRuntime(handle));\n\t}\n\n\tlist(): RuntimeHandle[] {\n\t\treturn [...this.runtimes.values()];\n\t}\n\n\tget(key: string): RuntimeHandle | undefined {\n\t\treturn this.runtimes.get(key);\n\t}\n\n\t/**\n\t * Record the EventHub sequence synchronously when the RPC snapshot marker\n\t * arrives. The marker line precedes its response on stdout, so this runs\n\t * before the RpcClient response continuation even across separate chunks.\n\t */\n\trecordDashboardBarrier(runtimeKey: string, snapshotId: string, seq: number): void {\n\t\tthis.pruneDashboardBarriers();\n\t\tthis.dashboardBarriers.set(this.dashboardBarrierKey(runtimeKey, snapshotId), { seq, recordedAt: this.now() });\n\t\tthis.pruneDashboardBarriers();\n\t}\n\n\t/**\n\t * Capture a parent-session recovery snapshot and pair it with the sequence\n\t * captured at its RPC marker. This deliberately does not infer ordering from\n\t * await: later EventHub publications naturally have higher sequence numbers.\n\t */\n\tasync snapshotDashboard(handle: RuntimeHandle): Promise<DashboardRuntimeSnapshot> {\n\t\tconst snapshot = await (handle.client as DashboardSnapshotClient).getDashboardSnapshot();\n\t\tthis.pruneDashboardBarriers();\n\t\tconst barrierKey = this.dashboardBarrierKey(handle.key, snapshot.snapshotId);\n\t\tconst barrier = this.dashboardBarriers.get(barrierKey);\n\t\tthis.dashboardBarriers.delete(barrierKey);\n\t\tthis.scheduleDashboardBarrierPrune();\n\t\tif (!barrier) {\n\t\t\tthrow new Error(`Dashboard snapshot ${snapshot.snapshotId} arrived without its ordering barrier`);\n\t\t}\n\t\t// This is an authoritative RPC baseline, so it may legitimately lower the\n\t\t// count after a fork/rewind instead of retaining an event-derived maximum.\n\t\thandle.lastState = snapshot.state;\n\t\tthis.scheduleFleetSnapshot();\n\t\treturn { key: handle.key, barrierSeq: barrier.seq, snapshot };\n\t}\n\n\tprivate dashboardBarrierKey(runtimeKey: string, snapshotId: string): string {\n\t\treturn `${runtimeKey}\\0${snapshotId}`;\n\t}\n\n\tprivate pruneDashboardBarriers(): void {\n\t\tconst oldestAllowed = this.now() - this.dashboardBarrierTtlMs;\n\t\tfor (const [snapshotId, barrier] of this.dashboardBarriers) {\n\t\t\tif (barrier.recordedAt < oldestAllowed) this.dashboardBarriers.delete(snapshotId);\n\t\t}\n\t\twhile (this.dashboardBarriers.size > this.dashboardBarrierLimit) {\n\t\t\tconst oldest = this.dashboardBarriers.keys().next().value;\n\t\t\tif (oldest === undefined) break;\n\t\t\tthis.dashboardBarriers.delete(oldest);\n\t\t}\n\t\tthis.scheduleDashboardBarrierPrune();\n\t}\n\n\tprivate scheduleDashboardBarrierPrune(): void {\n\t\tif (this.dashboardBarrierPruneTimer) clearTimeout(this.dashboardBarrierPruneTimer);\n\t\tthis.dashboardBarrierPruneTimer = undefined;\n\t\tlet oldest: DashboardBarrier | undefined;\n\t\tfor (const barrier of this.dashboardBarriers.values()) {\n\t\t\tif (!oldest || barrier.recordedAt < oldest.recordedAt) oldest = barrier;\n\t\t}\n\t\tif (!oldest) return;\n\t\tconst delay = Math.max(1, oldest.recordedAt + this.dashboardBarrierTtlMs - this.now() + 1);\n\t\tthis.dashboardBarrierPruneTimer = setTimeout(() => {\n\t\t\tthis.dashboardBarrierPruneTimer = undefined;\n\t\t\tthis.pruneDashboardBarriers();\n\t\t}, delay);\n\t\tthis.dashboardBarrierPruneTimer.unref?.();\n\t}\n\n\tprivate scheduleFleetSnapshot(): void {\n\t\tif (this.closing) return;\n\t\tif (this.fleetSnapshotTimer) clearTimeout(this.fleetSnapshotTimer);\n\t\tthis.fleetSnapshotTimer = setTimeout(() => {\n\t\t\tthis.fleetSnapshotTimer = undefined;\n\t\t\tif (this.closing) return;\n\t\t\tconst event: FleetSnapshotEventDto = { type: \"fleet_snapshot\", runtimes: this.fleetSnapshot() };\n\t\t\tfor (const listener of this.fleetSnapshotListeners) {\n\t\t\t\ttry {\n\t\t\t\t\tlistener(event);\n\t\t\t\t} catch {\n\t\t\t\t\t// An SSE bridge subscriber must not break the pool's event loop.\n\t\t\t\t}\n\t\t\t}\n\t\t}, this.fleetSnapshotDebounceMs);\n\t\tthis.fleetSnapshotTimer.unref?.();\n\t}\n\n\t/** Spawn a new runtime in `cwd`, optionally opening an existing session file. */\n\tasync create(cwd: string, sessionPath?: string): Promise<RuntimeHandle> {\n\t\tif (this.closing) throw new Error(\"Runtime pool is closing\");\n\t\tconst key = randomBytes(6).toString(\"hex\");\n\t\tconst args = [\"--ui\", \"dashboard\", ...this.baseArgs];\n\t\tif (sessionPath) args.push(\"--session\", sessionPath);\n\t\tconst client = this.clientFactory({ cliPath: this.cliPath, cwd, args });\n\t\tconst handle: RuntimeHandle = {\n\t\t\tkey,\n\t\t\tcwd,\n\t\t\tclient,\n\t\t\tcreatedAt: this.now(),\n\t\t\tlastActivity: this.now(),\n\t\t\tattention: new Map(),\n\t\t\tsessionFileFallback: sessionPath,\n\t\t\tbackgroundAgents: new Map(),\n\t\t};\n\t\tclient.onEvent((event) => this.handleEvent(handle, event as unknown as Record<string, unknown>));\n\t\tclient.onExit((info) => this.handleRuntimeExit(handle, info));\n\n\t\tconst startup = this.startSessionRuntime(handle);\n\t\tthis.startupPromises.add(startup);\n\t\ttry {\n\t\t\treturn await startup;\n\t\t} finally {\n\t\t\tthis.startupPromises.delete(startup);\n\t\t}\n\t}\n\n\tprivate async startSessionRuntime(handle: RuntimeHandle): Promise<RuntimeHandle> {\n\t\tthis.starting.add(handle);\n\t\ttry {\n\t\t\tif (this.closing) throw new Error(\"Runtime pool is closing\");\n\t\t\tawait handle.client.start();\n\t\t\tif (this.closing) {\n\t\t\t\tawait handle.client.stop();\n\t\t\t\tthrow new Error(\"Runtime pool is closing\");\n\t\t\t}\n\t\t\tawait this.seedBackgroundAgents(handle);\n\t\t\tif (this.closing) {\n\t\t\t\tawait handle.client.stop();\n\t\t\t\tthrow new Error(\"Runtime pool is closing\");\n\t\t\t}\n\t\t\tthis.runtimes.set(handle.key, handle);\n\t\t\tthis.scheduleFleetSnapshot();\n\t\t\treturn handle;\n\t\t} finally {\n\t\t\tthis.starting.delete(handle);\n\t\t}\n\t}\n\n\t/** Stop a runtime and remove it from the pool. */\n\tasync stop(key: string): Promise<boolean> {\n\t\tconst handle = this.runtimes.get(key);\n\t\tif (!handle) return false;\n\t\tthis.handleEvent(handle, { type: \"runtime_removed\" });\n\t\tthis.runtimes.delete(key);\n\t\tthis.scheduleFleetSnapshot();\n\t\tawait handle.client.stop();\n\t\treturn true;\n\t}\n\n\t/**\n\t * Return any live runtime suitable for process-global settings work, spawning\n\t * a hidden utility runtime (in the home directory) if no user session exists.\n\t * This is what lets the settings page — models, agent types, defaults — work\n\t * with zero sessions open, instead of 503-ing.\n\t */\n\tasync ensureUtilityRuntime(cwd = homedir()): Promise<RuntimeHandle> {\n\t\tif (this.closing) throw new Error(\"Runtime pool is closing\");\n\t\tconst existing = this.utilities.get(cwd);\n\t\tif (existing) return existing;\n\t\tlet promise = this.utilityPromises.get(cwd);\n\t\tif (!promise) {\n\t\t\tconst args = [\"--ui\", \"dashboard\", ...this.baseArgs];\n\t\t\tconst client = this.clientFactory({ cliPath: this.cliPath, cwd, args });\n\t\t\tconst handle: RuntimeHandle = {\n\t\t\t\tkey: `utility:${cwd}`,\n\t\t\t\tcwd,\n\t\t\t\tclient,\n\t\t\t\tcreatedAt: this.now(),\n\t\t\t\tlastActivity: this.now(),\n\t\t\t\tattention: new Map(),\n\t\t\t\tbackgroundAgents: new Map(),\n\t\t\t};\n\t\t\tconst startup = this.startUtilityRuntime(cwd, handle);\n\t\t\tthis.startupPromises.add(startup);\n\t\t\tpromise = startup\n\t\t\t\t.catch((err) => {\n\t\t\t\t\t// Allow a retry on the next request instead of caching the failure.\n\t\t\t\t\tthis.utilityPromises.delete(cwd);\n\t\t\t\t\tthrow err;\n\t\t\t\t})\n\t\t\t\t.finally(() => {\n\t\t\t\t\tthis.startupPromises.delete(startup);\n\t\t\t\t});\n\t\t\tthis.utilityPromises.set(cwd, promise);\n\t\t}\n\t\treturn promise;\n\t}\n\n\tprivate async startUtilityRuntime(cwd: string, handle: RuntimeHandle): Promise<RuntimeHandle> {\n\t\tthis.starting.add(handle);\n\t\ttry {\n\t\t\tif (this.closing) throw new Error(\"Runtime pool is closing\");\n\t\t\tawait handle.client.start();\n\t\t\tif (this.closing) {\n\t\t\t\tawait handle.client.stop();\n\t\t\t\tthrow new Error(\"Runtime pool is closing\");\n\t\t\t}\n\t\t\tthis.utilities.set(cwd, handle);\n\t\t\treturn handle;\n\t\t} finally {\n\t\t\tthis.starting.delete(handle);\n\t\t}\n\t}\n\n\tasync stopAll(): Promise<void> {\n\t\tthis.closing = true;\n\t\tif (this.dashboardBarrierPruneTimer) clearTimeout(this.dashboardBarrierPruneTimer);\n\t\tthis.dashboardBarrierPruneTimer = undefined;\n\t\tif (this.fleetSnapshotTimer) clearTimeout(this.fleetSnapshotTimer);\n\t\tthis.fleetSnapshotTimer = undefined;\n\t\tthis.fleetSnapshotListeners.length = 0;\n\t\tthis.dashboardBarriers.clear();\n\t\tconst handles = new Set<RuntimeHandle>([\n\t\t\t...this.runtimes.values(),\n\t\t\t...this.utilities.values(),\n\t\t\t...this.starting.values(),\n\t\t]);\n\t\tconst startupPromises = new Set<Promise<unknown>>([...this.startupPromises, ...this.utilityPromises.values()]);\n\t\tthis.runtimes.clear();\n\t\tthis.utilities.clear();\n\t\tthis.utilityPromises.clear();\n\t\tawait Promise.allSettled([...handles].map((handle) => handle.client.stop()));\n\t\tawait Promise.allSettled([...startupPromises]);\n\t}\n\n\tprivate handleRuntimeExit(handle: RuntimeHandle, info: RpcExitInfo): void {\n\t\tif (this.closing || this.exitedHandles.has(handle) || !this.isLiveHandle(handle)) return;\n\t\tthis.exitedHandles.add(handle);\n\t\tconst message = formatRpcExit(info);\n\t\tthis.recordRuntimeError(handle, message);\n\t\tthis.logger(`runtime ${handle.key} ${message}`);\n\t\tthis.handleEvent(handle, { type: \"agent_end\", messages: [], aborted: true, errorMessage: message });\n\t}\n\n\tprivate isLiveHandle(handle: RuntimeHandle): boolean {\n\t\treturn (\n\t\t\tthis.runtimes.get(handle.key) === handle ||\n\t\t\tthis.utilities.get(handle.cwd) === handle ||\n\t\t\tthis.starting.has(handle)\n\t\t);\n\t}\n\n\tprivate handleEvent(handle: RuntimeHandle, event: Record<string, unknown>): void {\n\t\tconst type = event.type as string;\n\t\tif (type !== \"dashboard_snapshot_barrier\") {\n\t\t\thandle.lastActivity = this.now();\n\t\t\tthis.updateStateFromEvent(handle, event, type);\n\t\t}\n\n\t\t// Track needs-attention sources: extension UI requests, parent\n\t\t// paused, error states.\n\t\tif (type === \"extension_ui_request\") {\n\t\t\tconst method = event.method as string;\n\t\t\tif (method === \"select\" || method === \"confirm\" || method === \"input\" || method === \"editor\") {\n\t\t\t\thandle.attention.set(`ui:${event.id}`, `extension ${method} awaiting response`);\n\t\t\t}\n\t\t}\n\t\tif (type === \"extension_ui_response_handled\") {\n\t\t\thandle.attention.delete(`ui:${event.id}`);\n\t\t}\n\t\tif (type === \"agent_start\") {\n\t\t\t// A new turn clears prior UI-request attention (requests were resolved or timed out).\n\t\t\tfor (const k of [...handle.attention.keys()]) {\n\t\t\t\tif (k.startsWith(\"ui:\")) handle.attention.delete(k);\n\t\t\t}\n\t\t\thandle.attention.delete(\"paused\");\n\t\t\thandle.attention.delete(\"suggest\");\n\t\t\thandle.attention.delete(\"error\");\n\t\t\thandle.error = undefined;\n\t\t}\n\t\tif (type === \"suggest_next\") {\n\t\t\t// suggest_next as the ending action = \"your move\": mark needs-attention\n\t\t\t// so the fleet card doesn't read idle. Cleared on the next agent_start.\n\t\t\thandle.attention.set(\"suggest\", \"suggested command awaiting\");\n\t\t}\n\t\tif (type === \"parent_paused_for_background_agents\") {\n\t\t\thandle.attention.set(\"paused\", `paused — ${event.runningAgentCount} background agents running`);\n\t\t}\n\t\tif (type === \"agent_end\") {\n\t\t\thandle.attention.delete(\"paused\");\n\t\t\tif (event.errorMessage) this.recordRuntimeError(handle, String(event.errorMessage));\n\t\t}\n\t\tif (type === \"auto_retry_end\" && event.success === false && event.finalError) {\n\t\t\tthis.recordRuntimeError(handle, String(event.finalError));\n\t\t}\n\t\tif (type === \"auto_compaction_end\" && event.errorMessage) {\n\t\t\tthis.recordRuntimeError(handle, String(event.errorMessage));\n\t\t}\n\n\t\t// Track background agents from lifecycle events.\n\t\tif (type === \"background_agent_start\") {\n\t\t\thandle.backgroundAgents.set(event.agentId as string, {\n\t\t\t\tagentId: event.agentId as string,\n\t\t\t\tagentType: event.agentType as string,\n\t\t\t\ttaskSummary: event.taskSummary as string,\n\t\t\t\tstartedAt: new Date().toISOString(),\n\t\t\t\tstatus: \"running\",\n\t\t\t\tsessionDir: event.sessionDir as string | undefined,\n\t\t\t});\n\t\t}\n\t\tif (type === \"background_agent_end\") {\n\t\t\tconst existing = handle.backgroundAgents.get(event.agentId as string);\n\t\t\tif (existing) {\n\t\t\t\texisting.status = event.success ? \"completed\" : \"failed\";\n\t\t\t\texisting.sessionFile = (event.sessionFile as string | undefined) ?? existing.sessionFile;\n\t\t\t\tthis.pruneCompletedBackgroundAgents(handle);\n\t\t\t}\n\t\t}\n\n\t\tfor (const listener of this.listeners) {\n\t\t\ttry {\n\t\t\t\tlistener(handle.key, event);\n\t\t\t} catch {\n\t\t\t\t// A broken SSE subscriber must not break event distribution.\n\t\t\t}\n\t\t}\n\t\tif (this.runtimes.get(handle.key) === handle && FLEET_SNAPSHOT_EVENT_TYPES.has(type)) {\n\t\t\tthis.scheduleFleetSnapshot();\n\t\t}\n\t}\n\n\t/**\n\t * Events intentionally patch only fields they can prove. Session identity,\n\t * session file, configuration, and context usage remain from the last RPC\n\t * baseline (or the stable creation fallback) until a later reconciliation.\n\t */\n\tprivate updateStateFromEvent(handle: RuntimeHandle, event: Record<string, unknown>, type: string): void {\n\t\tconst state = this.fallbackState(handle);\n\t\tswitch (type) {\n\t\t\tcase \"agent_start\": {\n\t\t\t\tconst model = event.model;\n\t\t\t\tif (\n\t\t\t\t\tmodel &&\n\t\t\t\t\ttypeof model === \"object\" &&\n\t\t\t\t\ttypeof (model as Record<string, unknown>).provider === \"string\" &&\n\t\t\t\t\ttypeof (model as Record<string, unknown>).id === \"string\"\n\t\t\t\t) {\n\t\t\t\t\tconst nextModel = model as { provider: string; id: string };\n\t\t\t\t\tstate.model =\n\t\t\t\t\t\tstate.model?.provider === nextModel.provider && state.model.id === nextModel.id\n\t\t\t\t\t\t\t? { ...state.model, ...nextModel }\n\t\t\t\t\t\t\t: nextModel;\n\t\t\t\t}\n\t\t\t\tstate.isStreaming = true;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"agent_end\":\n\t\t\t\tstate.isStreaming = false;\n\t\t\t\tbreak;\n\t\t\tcase \"auto_compaction_start\":\n\t\t\t\tstate.isCompacting = true;\n\t\t\t\tbreak;\n\t\t\tcase \"auto_compaction_end\":\n\t\t\t\tstate.isCompacting = false;\n\t\t\t\tbreak;\n\t\t\tcase \"tasks_update\":\n\t\t\t\tif (Array.isArray(event.tasks)) state.tasks = [...event.tasks] as SessionStateDto[\"tasks\"];\n\t\t\t\tbreak;\n\t\t\tcase \"session_name_changed\":\n\t\t\t\tif (typeof event.name === \"string\") state.sessionName = event.name;\n\t\t\t\tbreak;\n\t\t\tcase \"message_start\":\n\t\t\t\tstate.messageCount += 1;\n\t\t\t\tbreak;\n\t\t}\n\t\thandle.lastState = state;\n\t}\n\n\tprivate recordRuntimeError(handle: RuntimeHandle, message: string): void {\n\t\thandle.error = message;\n\t\thandle.attention.set(\"error\", message);\n\t\tif (this.runtimes.get(handle.key) === handle) this.scheduleFleetSnapshot();\n\t}\n\n\tprivate pruneCompletedBackgroundAgents(handle: RuntimeHandle): void {\n\t\tconst evictable = [...handle.backgroundAgents.values()]\n\t\t\t.map((agent, index) => {\n\t\t\t\tconst startedAtMs = Date.parse(agent.startedAt);\n\t\t\t\treturn { agent, index, startedAtMs: Number.isFinite(startedAtMs) ? startedAtMs : 0 };\n\t\t\t})\n\t\t\t.filter(({ agent }) => agent.status !== \"running\")\n\t\t\t.sort((a, b) => a.startedAtMs - b.startedAtMs || a.index - b.index);\n\t\tconst excess = evictable.length - MAX_COMPLETED_BACKGROUND_AGENTS;\n\t\tif (excess <= 0) return;\n\t\tfor (const { agent } of evictable.slice(0, excess)) {\n\t\t\thandle.backgroundAgents.delete(agent.agentId);\n\t\t}\n\t}\n\n\tprivate fallbackState(handle: RuntimeHandle): SessionStateDto {\n\t\tconst previous = handle.lastState;\n\t\treturn {\n\t\t\t...previous,\n\t\t\tsessionId: previous?.sessionId ?? handle.key,\n\t\t\ttasks: previous?.tasks ? [...previous.tasks] : [],\n\t\t\tthinkingLevel: previous?.thinkingLevel ?? \"off\",\n\t\t\tisStreaming: previous?.isStreaming ?? false,\n\t\t\tisCompacting: previous?.isCompacting ?? false,\n\t\t\tsteeringMode: previous?.steeringMode ?? \"all\",\n\t\t\tfollowUpMode: previous?.followUpMode ?? \"all\",\n\t\t\tsessionFile: previous?.sessionFile ?? handle.sessionFileFallback,\n\t\t\tautoCompactionEnabled: previous?.autoCompactionEnabled ?? false,\n\t\t\tmessageCount: previous?.messageCount ?? 0,\n\t\t\tpendingMessageCount: previous?.pendingMessageCount ?? 0,\n\t\t};\n\t}\n\n\tprivate describeFleetRuntime(handle: RuntimeHandle): FleetRuntimeSnapshotDto {\n\t\tconst state = this.fallbackState(handle);\n\t\treturn {\n\t\t\tkey: handle.key,\n\t\t\tcwd: handle.cwd,\n\t\t\tstate,\n\t\t\tbackgroundAgents: [...handle.backgroundAgents.values()].map((agent) => ({ ...agent })),\n\t\t\tneedsAttention: handle.attention.size > 0,\n\t\t\terror: handle.error,\n\t\t\tcreatedAt: new Date(handle.createdAt).toISOString(),\n\t\t\tlastActivity: new Date(handle.lastActivity).toISOString(),\n\t\t};\n\t}\n\n\tprivate async seedBackgroundAgents(handle: RuntimeHandle): Promise<void> {\n\t\ttry {\n\t\t\tconst agents = (await handle.client.listBackgroundAgents()) as unknown as BackgroundAgentDto[];\n\t\t\tfor (const agent of agents) {\n\t\t\t\thandle.backgroundAgents.set(agent.agentId, agent);\n\t\t\t}\n\t\t\tthis.pruneCompletedBackgroundAgents(handle);\n\t\t} catch (err) {\n\t\t\tthis.logger(\n\t\t\t\t`runtime ${handle.key} background-agent registry unavailable: ${err instanceof Error ? err.message : String(err)}`,\n\t\t\t);\n\t\t}\n\t}\n\n\t/** Snapshot a runtime for the fleet endpoint. */\n\tasync describe(handle: RuntimeHandle): Promise<RuntimeInfoDto> {\n\t\tconst previousFleetRuntime =\n\t\t\tthis.runtimes.get(handle.key) === handle ? this.describeFleetRuntime(handle) : undefined;\n\t\tlet fleetRuntimeEnriched = false;\n\t\tlet state: SessionStateDto;\n\t\ttry {\n\t\t\tconst authoritative = (await handle.client.getState()) as unknown as SessionStateDto;\n\t\t\tconst fallback = this.fallbackState(handle);\n\t\t\tstate = {\n\t\t\t\t...fallback,\n\t\t\t\t...authoritative,\n\t\t\t\tsessionId: authoritative.sessionId ?? fallback.sessionId,\n\t\t\t\tsessionFile: authoritative.sessionFile ?? fallback.sessionFile,\n\t\t\t\ttasks: authoritative.tasks ?? fallback.tasks,\n\t\t\t};\n\t\t\thandle.lastState = state;\n\t\t\tfleetRuntimeEnriched = true;\n\t\t\tif (handle.error?.startsWith(\"RPC process\")) {\n\t\t\t\thandle.error = undefined;\n\t\t\t\thandle.attention.delete(\"error\");\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tconst message = err instanceof Error ? err.message : String(err);\n\t\t\tthis.recordRuntimeError(handle, message);\n\t\t\tthis.logger(`runtime ${handle.key} state unavailable for fleet card: ${message}`);\n\t\t\tstate = this.fallbackState(handle);\n\t\t}\n\t\tlet stats: RuntimeStatsSummaryDto | undefined;\n\t\ttry {\n\t\t\tconst sessionStats = await handle.client.getSessionStats();\n\t\t\tstats = { tokensTotal: sessionStats.tokens.total, cost: sessionStats.cost };\n\t\t\tif (sessionStats.contextUsage) {\n\t\t\t\thandle.lastState = { ...this.fallbackState(handle), contextUsage: sessionStats.contextUsage };\n\t\t\t\tfleetRuntimeEnriched = true;\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tthis.logger(\n\t\t\t\t`runtime ${handle.key} stats unavailable for fleet card: ${err instanceof Error ? err.message : String(err)}`,\n\t\t\t);\n\t\t}\n\t\tlet lastAssistantText: string | undefined;\n\t\ttry {\n\t\t\tconst text = await handle.client.getLastAssistantText();\n\t\t\tlastAssistantText = text ? text.slice(0, 200) : undefined;\n\t\t} catch (err) {\n\t\t\tthis.logger(\n\t\t\t\t`runtime ${handle.key} last assistant text unavailable for fleet card: ${err instanceof Error ? err.message : String(err)}`,\n\t\t\t);\n\t\t}\n\t\tif (\n\t\t\tfleetRuntimeEnriched &&\n\t\t\tpreviousFleetRuntime &&\n\t\t\tthis.runtimes.get(handle.key) === handle &&\n\t\t\t!isDeepStrictEqual(previousFleetRuntime, this.describeFleetRuntime(handle))\n\t\t) {\n\t\t\tthis.scheduleFleetSnapshot();\n\t\t}\n\t\treturn {\n\t\t\tkey: handle.key,\n\t\t\tcwd: handle.cwd,\n\t\t\tstate,\n\t\t\tstats,\n\t\t\tbackgroundAgents: [...handle.backgroundAgents.values()],\n\t\t\tneedsAttention: handle.attention.size > 0,\n\t\t\terror: handle.error,\n\t\t\tlastAssistantText,\n\t\t\tcreatedAt: new Date(handle.createdAt).toISOString(),\n\t\t\tlastActivity: new Date(handle.lastActivity).toISOString(),\n\t\t};\n\t}\n}\n"]}
|
|
@@ -9,6 +9,7 @@ import { randomBytes } from "node:crypto";
|
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
10
|
import { dirname, join } from "node:path";
|
|
11
11
|
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { isDeepStrictEqual } from "node:util";
|
|
12
13
|
import { RpcClient } from "@dreb/coding-agent/rpc";
|
|
13
14
|
import { MAX_COMPLETED_BACKGROUND_AGENTS, } from "../shared/protocol.js";
|
|
14
15
|
/** Resolve the absolute path to the dreb CLI (RpcClient defaults to a cwd-relative path). */
|
|
@@ -24,9 +25,29 @@ function formatRpcExit(info) {
|
|
|
24
25
|
export { MAX_COMPLETED_BACKGROUND_AGENTS };
|
|
25
26
|
export const DEFAULT_DASHBOARD_BARRIER_TTL_MS = 5 * 60_000;
|
|
26
27
|
export const DEFAULT_DASHBOARD_BARRIER_LIMIT = 1000;
|
|
28
|
+
export const DEFAULT_FLEET_SNAPSHOT_DEBOUNCE_MS = 200;
|
|
29
|
+
/** Events that mutate a field carried by the lightweight fleet snapshot. */
|
|
30
|
+
const FLEET_SNAPSHOT_EVENT_TYPES = new Set([
|
|
31
|
+
"agent_start",
|
|
32
|
+
"agent_end",
|
|
33
|
+
"auto_compaction_start",
|
|
34
|
+
"auto_compaction_end",
|
|
35
|
+
"auto_retry_end",
|
|
36
|
+
"background_agent_start",
|
|
37
|
+
"background_agent_end",
|
|
38
|
+
"extension_ui_request",
|
|
39
|
+
"extension_ui_response_handled",
|
|
40
|
+
"message_start",
|
|
41
|
+
"parent_paused_for_background_agents",
|
|
42
|
+
"runtime_removed",
|
|
43
|
+
"session_name_changed",
|
|
44
|
+
"suggest_next",
|
|
45
|
+
"tasks_update",
|
|
46
|
+
]);
|
|
27
47
|
export class RuntimePool {
|
|
28
48
|
runtimes = new Map();
|
|
29
49
|
listeners = [];
|
|
50
|
+
fleetSnapshotListeners = [];
|
|
30
51
|
cliPath;
|
|
31
52
|
baseArgs;
|
|
32
53
|
clientFactory;
|
|
@@ -46,7 +67,9 @@ export class RuntimePool {
|
|
|
46
67
|
dashboardBarrierTtlMs;
|
|
47
68
|
dashboardBarrierLimit;
|
|
48
69
|
now;
|
|
70
|
+
fleetSnapshotDebounceMs;
|
|
49
71
|
dashboardBarrierPruneTimer;
|
|
72
|
+
fleetSnapshotTimer;
|
|
50
73
|
closing = false;
|
|
51
74
|
constructor(options = {}) {
|
|
52
75
|
this.cliPath = options.cliPath ?? resolveDrebCliPath();
|
|
@@ -57,6 +80,7 @@ export class RuntimePool {
|
|
|
57
80
|
this.dashboardBarrierTtlMs = options.dashboardBarrierTtlMs ?? DEFAULT_DASHBOARD_BARRIER_TTL_MS;
|
|
58
81
|
this.dashboardBarrierLimit = options.dashboardBarrierLimit ?? DEFAULT_DASHBOARD_BARRIER_LIMIT;
|
|
59
82
|
this.now = options.now ?? Date.now;
|
|
83
|
+
this.fleetSnapshotDebounceMs = options.fleetSnapshotDebounceMs ?? DEFAULT_FLEET_SNAPSHOT_DEBOUNCE_MS;
|
|
60
84
|
}
|
|
61
85
|
/** Subscribe to events from every runtime, tagged with the runtime key. */
|
|
62
86
|
onEvent(listener) {
|
|
@@ -67,6 +91,22 @@ export class RuntimePool {
|
|
|
67
91
|
this.listeners.splice(i, 1);
|
|
68
92
|
};
|
|
69
93
|
}
|
|
94
|
+
/** Subscribe to debounced, in-memory fleet snapshots. */
|
|
95
|
+
onFleetSnapshot(listener) {
|
|
96
|
+
this.fleetSnapshotListeners.push(listener);
|
|
97
|
+
return () => {
|
|
98
|
+
const i = this.fleetSnapshotListeners.indexOf(listener);
|
|
99
|
+
if (i !== -1)
|
|
100
|
+
this.fleetSnapshotListeners.splice(i, 1);
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Build the fleet's live-runtime view without RPC or disk access. Map
|
|
105
|
+
* insertion order is retained intentionally; the UI owns presentation order.
|
|
106
|
+
*/
|
|
107
|
+
fleetSnapshot() {
|
|
108
|
+
return [...this.runtimes.values()].map((handle) => this.describeFleetRuntime(handle));
|
|
109
|
+
}
|
|
70
110
|
list() {
|
|
71
111
|
return [...this.runtimes.values()];
|
|
72
112
|
}
|
|
@@ -98,6 +138,10 @@ export class RuntimePool {
|
|
|
98
138
|
if (!barrier) {
|
|
99
139
|
throw new Error(`Dashboard snapshot ${snapshot.snapshotId} arrived without its ordering barrier`);
|
|
100
140
|
}
|
|
141
|
+
// This is an authoritative RPC baseline, so it may legitimately lower the
|
|
142
|
+
// count after a fork/rewind instead of retaining an event-derived maximum.
|
|
143
|
+
handle.lastState = snapshot.state;
|
|
144
|
+
this.scheduleFleetSnapshot();
|
|
101
145
|
return { key: handle.key, barrierSeq: barrier.seq, snapshot };
|
|
102
146
|
}
|
|
103
147
|
dashboardBarrierKey(runtimeKey, snapshotId) {
|
|
@@ -135,6 +179,27 @@ export class RuntimePool {
|
|
|
135
179
|
}, delay);
|
|
136
180
|
this.dashboardBarrierPruneTimer.unref?.();
|
|
137
181
|
}
|
|
182
|
+
scheduleFleetSnapshot() {
|
|
183
|
+
if (this.closing)
|
|
184
|
+
return;
|
|
185
|
+
if (this.fleetSnapshotTimer)
|
|
186
|
+
clearTimeout(this.fleetSnapshotTimer);
|
|
187
|
+
this.fleetSnapshotTimer = setTimeout(() => {
|
|
188
|
+
this.fleetSnapshotTimer = undefined;
|
|
189
|
+
if (this.closing)
|
|
190
|
+
return;
|
|
191
|
+
const event = { type: "fleet_snapshot", runtimes: this.fleetSnapshot() };
|
|
192
|
+
for (const listener of this.fleetSnapshotListeners) {
|
|
193
|
+
try {
|
|
194
|
+
listener(event);
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// An SSE bridge subscriber must not break the pool's event loop.
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}, this.fleetSnapshotDebounceMs);
|
|
201
|
+
this.fleetSnapshotTimer.unref?.();
|
|
202
|
+
}
|
|
138
203
|
/** Spawn a new runtime in `cwd`, optionally opening an existing session file. */
|
|
139
204
|
async create(cwd, sessionPath) {
|
|
140
205
|
if (this.closing)
|
|
@@ -148,9 +213,10 @@ export class RuntimePool {
|
|
|
148
213
|
key,
|
|
149
214
|
cwd,
|
|
150
215
|
client,
|
|
151
|
-
createdAt:
|
|
152
|
-
lastActivity:
|
|
216
|
+
createdAt: this.now(),
|
|
217
|
+
lastActivity: this.now(),
|
|
153
218
|
attention: new Map(),
|
|
219
|
+
sessionFileFallback: sessionPath,
|
|
154
220
|
backgroundAgents: new Map(),
|
|
155
221
|
};
|
|
156
222
|
client.onEvent((event) => this.handleEvent(handle, event));
|
|
@@ -180,6 +246,7 @@ export class RuntimePool {
|
|
|
180
246
|
throw new Error("Runtime pool is closing");
|
|
181
247
|
}
|
|
182
248
|
this.runtimes.set(handle.key, handle);
|
|
249
|
+
this.scheduleFleetSnapshot();
|
|
183
250
|
return handle;
|
|
184
251
|
}
|
|
185
252
|
finally {
|
|
@@ -193,6 +260,7 @@ export class RuntimePool {
|
|
|
193
260
|
return false;
|
|
194
261
|
this.handleEvent(handle, { type: "runtime_removed" });
|
|
195
262
|
this.runtimes.delete(key);
|
|
263
|
+
this.scheduleFleetSnapshot();
|
|
196
264
|
await handle.client.stop();
|
|
197
265
|
return true;
|
|
198
266
|
}
|
|
@@ -216,8 +284,8 @@ export class RuntimePool {
|
|
|
216
284
|
key: `utility:${cwd}`,
|
|
217
285
|
cwd,
|
|
218
286
|
client,
|
|
219
|
-
createdAt:
|
|
220
|
-
lastActivity:
|
|
287
|
+
createdAt: this.now(),
|
|
288
|
+
lastActivity: this.now(),
|
|
221
289
|
attention: new Map(),
|
|
222
290
|
backgroundAgents: new Map(),
|
|
223
291
|
};
|
|
@@ -258,6 +326,10 @@ export class RuntimePool {
|
|
|
258
326
|
if (this.dashboardBarrierPruneTimer)
|
|
259
327
|
clearTimeout(this.dashboardBarrierPruneTimer);
|
|
260
328
|
this.dashboardBarrierPruneTimer = undefined;
|
|
329
|
+
if (this.fleetSnapshotTimer)
|
|
330
|
+
clearTimeout(this.fleetSnapshotTimer);
|
|
331
|
+
this.fleetSnapshotTimer = undefined;
|
|
332
|
+
this.fleetSnapshotListeners.length = 0;
|
|
261
333
|
this.dashboardBarriers.clear();
|
|
262
334
|
const handles = new Set([
|
|
263
335
|
...this.runtimes.values(),
|
|
@@ -286,8 +358,11 @@ export class RuntimePool {
|
|
|
286
358
|
this.starting.has(handle));
|
|
287
359
|
}
|
|
288
360
|
handleEvent(handle, event) {
|
|
289
|
-
handle.lastActivity = Date.now();
|
|
290
361
|
const type = event.type;
|
|
362
|
+
if (type !== "dashboard_snapshot_barrier") {
|
|
363
|
+
handle.lastActivity = this.now();
|
|
364
|
+
this.updateStateFromEvent(handle, event, type);
|
|
365
|
+
}
|
|
291
366
|
// Track needs-attention sources: extension UI requests, parent
|
|
292
367
|
// paused, error states.
|
|
293
368
|
if (type === "extension_ui_request") {
|
|
@@ -320,6 +395,8 @@ export class RuntimePool {
|
|
|
320
395
|
}
|
|
321
396
|
if (type === "agent_end") {
|
|
322
397
|
handle.attention.delete("paused");
|
|
398
|
+
if (event.errorMessage)
|
|
399
|
+
this.recordRuntimeError(handle, String(event.errorMessage));
|
|
323
400
|
}
|
|
324
401
|
if (type === "auto_retry_end" && event.success === false && event.finalError) {
|
|
325
402
|
this.recordRuntimeError(handle, String(event.finalError));
|
|
@@ -354,10 +431,61 @@ export class RuntimePool {
|
|
|
354
431
|
// A broken SSE subscriber must not break event distribution.
|
|
355
432
|
}
|
|
356
433
|
}
|
|
434
|
+
if (this.runtimes.get(handle.key) === handle && FLEET_SNAPSHOT_EVENT_TYPES.has(type)) {
|
|
435
|
+
this.scheduleFleetSnapshot();
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Events intentionally patch only fields they can prove. Session identity,
|
|
440
|
+
* session file, configuration, and context usage remain from the last RPC
|
|
441
|
+
* baseline (or the stable creation fallback) until a later reconciliation.
|
|
442
|
+
*/
|
|
443
|
+
updateStateFromEvent(handle, event, type) {
|
|
444
|
+
const state = this.fallbackState(handle);
|
|
445
|
+
switch (type) {
|
|
446
|
+
case "agent_start": {
|
|
447
|
+
const model = event.model;
|
|
448
|
+
if (model &&
|
|
449
|
+
typeof model === "object" &&
|
|
450
|
+
typeof model.provider === "string" &&
|
|
451
|
+
typeof model.id === "string") {
|
|
452
|
+
const nextModel = model;
|
|
453
|
+
state.model =
|
|
454
|
+
state.model?.provider === nextModel.provider && state.model.id === nextModel.id
|
|
455
|
+
? { ...state.model, ...nextModel }
|
|
456
|
+
: nextModel;
|
|
457
|
+
}
|
|
458
|
+
state.isStreaming = true;
|
|
459
|
+
break;
|
|
460
|
+
}
|
|
461
|
+
case "agent_end":
|
|
462
|
+
state.isStreaming = false;
|
|
463
|
+
break;
|
|
464
|
+
case "auto_compaction_start":
|
|
465
|
+
state.isCompacting = true;
|
|
466
|
+
break;
|
|
467
|
+
case "auto_compaction_end":
|
|
468
|
+
state.isCompacting = false;
|
|
469
|
+
break;
|
|
470
|
+
case "tasks_update":
|
|
471
|
+
if (Array.isArray(event.tasks))
|
|
472
|
+
state.tasks = [...event.tasks];
|
|
473
|
+
break;
|
|
474
|
+
case "session_name_changed":
|
|
475
|
+
if (typeof event.name === "string")
|
|
476
|
+
state.sessionName = event.name;
|
|
477
|
+
break;
|
|
478
|
+
case "message_start":
|
|
479
|
+
state.messageCount += 1;
|
|
480
|
+
break;
|
|
481
|
+
}
|
|
482
|
+
handle.lastState = state;
|
|
357
483
|
}
|
|
358
484
|
recordRuntimeError(handle, message) {
|
|
359
485
|
handle.error = message;
|
|
360
486
|
handle.attention.set("error", message);
|
|
487
|
+
if (this.runtimes.get(handle.key) === handle)
|
|
488
|
+
this.scheduleFleetSnapshot();
|
|
361
489
|
}
|
|
362
490
|
pruneCompletedBackgroundAgents(handle) {
|
|
363
491
|
const evictable = [...handle.backgroundAgents.values()]
|
|
@@ -375,22 +503,33 @@ export class RuntimePool {
|
|
|
375
503
|
}
|
|
376
504
|
}
|
|
377
505
|
fallbackState(handle) {
|
|
506
|
+
const previous = handle.lastState;
|
|
378
507
|
return {
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
tasks:
|
|
382
|
-
thinkingLevel:
|
|
383
|
-
isStreaming: false,
|
|
384
|
-
isCompacting: false,
|
|
385
|
-
steeringMode:
|
|
386
|
-
followUpMode:
|
|
387
|
-
sessionFile: handle.
|
|
388
|
-
autoCompactionEnabled:
|
|
389
|
-
messageCount:
|
|
390
|
-
pendingMessageCount:
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
508
|
+
...previous,
|
|
509
|
+
sessionId: previous?.sessionId ?? handle.key,
|
|
510
|
+
tasks: previous?.tasks ? [...previous.tasks] : [],
|
|
511
|
+
thinkingLevel: previous?.thinkingLevel ?? "off",
|
|
512
|
+
isStreaming: previous?.isStreaming ?? false,
|
|
513
|
+
isCompacting: previous?.isCompacting ?? false,
|
|
514
|
+
steeringMode: previous?.steeringMode ?? "all",
|
|
515
|
+
followUpMode: previous?.followUpMode ?? "all",
|
|
516
|
+
sessionFile: previous?.sessionFile ?? handle.sessionFileFallback,
|
|
517
|
+
autoCompactionEnabled: previous?.autoCompactionEnabled ?? false,
|
|
518
|
+
messageCount: previous?.messageCount ?? 0,
|
|
519
|
+
pendingMessageCount: previous?.pendingMessageCount ?? 0,
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
describeFleetRuntime(handle) {
|
|
523
|
+
const state = this.fallbackState(handle);
|
|
524
|
+
return {
|
|
525
|
+
key: handle.key,
|
|
526
|
+
cwd: handle.cwd,
|
|
527
|
+
state,
|
|
528
|
+
backgroundAgents: [...handle.backgroundAgents.values()].map((agent) => ({ ...agent })),
|
|
529
|
+
needsAttention: handle.attention.size > 0,
|
|
530
|
+
error: handle.error,
|
|
531
|
+
createdAt: new Date(handle.createdAt).toISOString(),
|
|
532
|
+
lastActivity: new Date(handle.lastActivity).toISOString(),
|
|
394
533
|
};
|
|
395
534
|
}
|
|
396
535
|
async seedBackgroundAgents(handle) {
|
|
@@ -407,10 +546,21 @@ export class RuntimePool {
|
|
|
407
546
|
}
|
|
408
547
|
/** Snapshot a runtime for the fleet endpoint. */
|
|
409
548
|
async describe(handle) {
|
|
549
|
+
const previousFleetRuntime = this.runtimes.get(handle.key) === handle ? this.describeFleetRuntime(handle) : undefined;
|
|
550
|
+
let fleetRuntimeEnriched = false;
|
|
410
551
|
let state;
|
|
411
552
|
try {
|
|
412
|
-
|
|
553
|
+
const authoritative = (await handle.client.getState());
|
|
554
|
+
const fallback = this.fallbackState(handle);
|
|
555
|
+
state = {
|
|
556
|
+
...fallback,
|
|
557
|
+
...authoritative,
|
|
558
|
+
sessionId: authoritative.sessionId ?? fallback.sessionId,
|
|
559
|
+
sessionFile: authoritative.sessionFile ?? fallback.sessionFile,
|
|
560
|
+
tasks: authoritative.tasks ?? fallback.tasks,
|
|
561
|
+
};
|
|
413
562
|
handle.lastState = state;
|
|
563
|
+
fleetRuntimeEnriched = true;
|
|
414
564
|
if (handle.error?.startsWith("RPC process")) {
|
|
415
565
|
handle.error = undefined;
|
|
416
566
|
handle.attention.delete("error");
|
|
@@ -426,6 +576,10 @@ export class RuntimePool {
|
|
|
426
576
|
try {
|
|
427
577
|
const sessionStats = await handle.client.getSessionStats();
|
|
428
578
|
stats = { tokensTotal: sessionStats.tokens.total, cost: sessionStats.cost };
|
|
579
|
+
if (sessionStats.contextUsage) {
|
|
580
|
+
handle.lastState = { ...this.fallbackState(handle), contextUsage: sessionStats.contextUsage };
|
|
581
|
+
fleetRuntimeEnriched = true;
|
|
582
|
+
}
|
|
429
583
|
}
|
|
430
584
|
catch (err) {
|
|
431
585
|
this.logger(`runtime ${handle.key} stats unavailable for fleet card: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -438,6 +592,12 @@ export class RuntimePool {
|
|
|
438
592
|
catch (err) {
|
|
439
593
|
this.logger(`runtime ${handle.key} last assistant text unavailable for fleet card: ${err instanceof Error ? err.message : String(err)}`);
|
|
440
594
|
}
|
|
595
|
+
if (fleetRuntimeEnriched &&
|
|
596
|
+
previousFleetRuntime &&
|
|
597
|
+
this.runtimes.get(handle.key) === handle &&
|
|
598
|
+
!isDeepStrictEqual(previousFleetRuntime, this.describeFleetRuntime(handle))) {
|
|
599
|
+
this.scheduleFleetSnapshot();
|
|
600
|
+
}
|
|
441
601
|
return {
|
|
442
602
|
key: handle.key,
|
|
443
603
|
cwd: handle.cwd,
|