@khorsheed/dsh-local-agent-dsh 0.1.0-rc.6
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.en.md +150 -0
- package/README.i18n.yaml +6 -0
- package/README.md +151 -0
- package/cordis.patch.yml +24 -0
- package/lib/client.js +1115 -0
- package/lib/dsh-cli-provider-UhWvivte.js +1701 -0
- package/lib/dsh-cli-provider.js +3 -0
- package/lib/index.js +624 -0
- package/lib/invariant.js +33 -0
- package/lib/provision.js +602 -0
- package/lib/records.js +109 -0
- package/lib/session-log-DOsSz56-.js +88 -0
- package/lib/types/client/SettingsCard.d.ts +78 -0
- package/lib/types/client/SettingsCard.js +200 -0
- package/lib/types/client/icons.d.ts +18 -0
- package/lib/types/client/icons.js +2 -0
- package/lib/types/client/index.d.ts +45 -0
- package/lib/types/client/index.js +88 -0
- package/lib/types/client/locales.d.ts +76 -0
- package/lib/types/client/locales.js +67 -0
- package/lib/types/dsh-cli-provider.d.ts +213 -0
- package/lib/types/dsh-cli-provider.js +733 -0
- package/lib/types/index.d.ts +124 -0
- package/lib/types/index.js +347 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/invariant.js +37 -0
- package/lib/types/live-driver.d.ts +180 -0
- package/lib/types/live-driver.js +887 -0
- package/lib/types/live-switch.d.ts +76 -0
- package/lib/types/live-switch.js +100 -0
- package/lib/types/model-broker.d.ts +86 -0
- package/lib/types/model-broker.js +149 -0
- package/lib/types/model-catalog.d.ts +27 -0
- package/lib/types/model-catalog.js +71 -0
- package/lib/types/provision.d.ts +382 -0
- package/lib/types/provision.js +656 -0
- package/lib/types/records.d.ts +41 -0
- package/lib/types/records.js +125 -0
- package/lib/types/session-log.d.ts +58 -0
- package/lib/types/session-log.js +83 -0
- package/lib/types/session-mirror.d.ts +155 -0
- package/lib/types/session-mirror.js +451 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +121 -0
|
@@ -0,0 +1,887 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dsh provider's live driver: one resident sub-dsh serve process per
|
|
3
|
+
* member (child session), driven over the family-internal wire
|
|
4
|
+
* (`@khorsheed/dsh-local-agent-dsh-headless/wire`). A delegation round is a
|
|
5
|
+
* `turn/start` request to the member's resident runtime instead of a fresh
|
|
6
|
+
* one-shot process, which buys what the process boundary could not:
|
|
7
|
+
* runtime-level graceful interrupt (`turn/interrupt` → in-process
|
|
8
|
+
* `Agent.cancel`; the process survives and the session stays continuable)
|
|
9
|
+
* and push-mode mirroring (session events arrive as `session/event`
|
|
10
|
+
* notifications and are mirrored event-by-event through the same fold the
|
|
11
|
+
* file mirror owns, with the file-based `mirrorDshSession` as the settle
|
|
12
|
+
* reconciliation pass).
|
|
13
|
+
*
|
|
14
|
+
* Lifecycle (the mode's main cost): the runtime spawns lazily on the member's
|
|
15
|
+
* first live round, is reclaimed after an idle timeout or on plugin unload,
|
|
16
|
+
* and is re-spawned — resuming the on-disk session — after a crash. Every
|
|
17
|
+
* process is registered in {@link DshLiveDriver.runtimes} until reclaimed, so
|
|
18
|
+
* no zombies survive a profile restart.
|
|
19
|
+
* @module @khorsheed/dsh-local-agent-dsh/live-driver
|
|
20
|
+
*/
|
|
21
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
22
|
+
import { BlockAssembler, createAssistantMessage } from '@deepseek-ai/dsh-llm';
|
|
23
|
+
import { settleRunResult, subprocessRunHandle, } from '@deepseek-ai/dsh-subagent';
|
|
24
|
+
import { delegationEnv, persistChildSession, LiveStreamPublisher, LiveFlush } from '@khorsheed/dsh-local-agent';
|
|
25
|
+
import { LIVE_SERVER_NAME, LIVE_WIRE_PROTOCOL_VERSION, } from '@khorsheed/dsh-local-agent-dsh-headless/wire';
|
|
26
|
+
import { DEFAULT_DISPOSE_GRACE_MS, dshLaunchArgv, dshTextTask, registerMemberRun, resolveApiKey, } from './dsh-cli-provider.js';
|
|
27
|
+
import { DEFAULT_SUB_PROFILE_NAME, provisionDshScope } from './provision.js';
|
|
28
|
+
import { mirrorDshLiveEvent, mirrorDshSession } from './session-mirror.js';
|
|
29
|
+
/** Default idle lifetime of an unused resident runtime before reclaim. */
|
|
30
|
+
export const DEFAULT_LIVE_IDLE_MS = 30 * 60_000;
|
|
31
|
+
/** Default timeout for a wire request acknowledgement. */
|
|
32
|
+
export const DEFAULT_LIVE_REQUEST_TIMEOUT_MS = 30_000;
|
|
33
|
+
/** Default timeout for the serve handshake (a cold sub-dsh boot can be slow). */
|
|
34
|
+
export const DEFAULT_LIVE_INITIALIZE_TIMEOUT_MS = 60_000;
|
|
35
|
+
/** Bounded wait for an interrupted turn to converge while a run disposes. */
|
|
36
|
+
export const DEFAULT_LIVE_DISPOSE_CONVERGE_MS = 5_000;
|
|
37
|
+
/** Bounded wait for a reclaimed runtime's own shutdown before SIGTERM. */
|
|
38
|
+
const RECLAIM_SHUTDOWN_GRACE_MS = 1_000;
|
|
39
|
+
/** How long the channel stays broken before a round retries live. */
|
|
40
|
+
export const DEFAULT_LIVE_CHANNEL_RETRY_MS = 5 * 60_000;
|
|
41
|
+
/**
|
|
42
|
+
* The serve channel could not come up (spawn failure or handshake
|
|
43
|
+
* timeout/mismatch). The provider catches exactly this and falls back to the
|
|
44
|
+
* exec one-shot; any other error is a real round failure.
|
|
45
|
+
*/
|
|
46
|
+
export class LiveChannelUnavailableError extends Error {
|
|
47
|
+
constructor(message) {
|
|
48
|
+
super(message);
|
|
49
|
+
this.name = 'LiveChannelUnavailableError';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const DEFAULT_TIMEOUTS = {
|
|
53
|
+
initializeMs: DEFAULT_LIVE_INITIALIZE_TIMEOUT_MS,
|
|
54
|
+
requestMs: DEFAULT_LIVE_REQUEST_TIMEOUT_MS,
|
|
55
|
+
convergeMs: DEFAULT_LIVE_DISPOSE_CONVERGE_MS,
|
|
56
|
+
channelRetryMs: DEFAULT_LIVE_CHANNEL_RETRY_MS,
|
|
57
|
+
};
|
|
58
|
+
function delay(ms) {
|
|
59
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
60
|
+
}
|
|
61
|
+
function thrown(value) {
|
|
62
|
+
return value instanceof Error ? value : new Error(String(value));
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* One resident serve process: the wire client, the process registry entry,
|
|
66
|
+
* and the reclaim ladder (wire shutdown → grace → SIGTERM → grace → SIGKILL
|
|
67
|
+
* via the subprocess handle's own terminate).
|
|
68
|
+
*/
|
|
69
|
+
class LiveRuntime {
|
|
70
|
+
child;
|
|
71
|
+
timeouts;
|
|
72
|
+
warn;
|
|
73
|
+
/** Set once the process exited or was reclaimed; a dead runtime never serves again. */
|
|
74
|
+
dead = false;
|
|
75
|
+
/**
|
|
76
|
+
* The model this process bound at spawn (`--model`), or undefined when it
|
|
77
|
+
* spawned with no model flag at all. A round whose start model differs
|
|
78
|
+
* retires the runtime instead of silently running the old one.
|
|
79
|
+
*/
|
|
80
|
+
configurationKey;
|
|
81
|
+
boundModel;
|
|
82
|
+
reclaimed = false;
|
|
83
|
+
pending = new Map();
|
|
84
|
+
nextId = 0;
|
|
85
|
+
buffer = '';
|
|
86
|
+
decoder = new StringDecoder('utf8');
|
|
87
|
+
stderrDecoder = new StringDecoder('utf8');
|
|
88
|
+
stderrTail = '';
|
|
89
|
+
/**
|
|
90
|
+
* Per-round handlers, installed by the active round (one at a time per
|
|
91
|
+
* member) and CLEARED when it settles — a notification with no active
|
|
92
|
+
* round is dropped, never delivered into a stale closure.
|
|
93
|
+
*/
|
|
94
|
+
onEvent;
|
|
95
|
+
onStream;
|
|
96
|
+
onIdle;
|
|
97
|
+
/** Fires once when the process dies or is reclaimed (driver bookkeeping). */
|
|
98
|
+
onDead;
|
|
99
|
+
constructor(child, timeouts, warn) {
|
|
100
|
+
this.child = child;
|
|
101
|
+
this.timeouts = timeouts;
|
|
102
|
+
this.warn = warn;
|
|
103
|
+
// StringDecoders hold a multi-byte UTF-8 tail split across chunks instead
|
|
104
|
+
// of corrupting it into U+FFFD (the wire carries arbitrary CJK text).
|
|
105
|
+
child.stdout?.on('data', (chunk) => { this.feed(this.decoder.write(chunk)); });
|
|
106
|
+
child.stderr?.on('data', (chunk) => {
|
|
107
|
+
this.stderrTail = (this.stderrTail + this.stderrDecoder.write(chunk)).slice(-4096);
|
|
108
|
+
});
|
|
109
|
+
void child.done.then(() => { this.markDead(); }, () => { this.markDead(); });
|
|
110
|
+
}
|
|
111
|
+
/** Diagnostic tail for round-failure logs. */
|
|
112
|
+
get diagnostics() {
|
|
113
|
+
return this.stderrTail.trim();
|
|
114
|
+
}
|
|
115
|
+
feed(text) {
|
|
116
|
+
this.buffer += text;
|
|
117
|
+
let index = this.buffer.indexOf('\n');
|
|
118
|
+
while (index >= 0) {
|
|
119
|
+
const line = this.buffer.slice(0, index);
|
|
120
|
+
this.buffer = this.buffer.slice(index + 1);
|
|
121
|
+
index = this.buffer.indexOf('\n');
|
|
122
|
+
if (line.trim() === '')
|
|
123
|
+
continue;
|
|
124
|
+
this.dispatchLine(line);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** Handle one wire line; a malformed or hostile line never escapes this frame. */
|
|
128
|
+
dispatchLine(line) {
|
|
129
|
+
let message;
|
|
130
|
+
try {
|
|
131
|
+
message = JSON.parse(line);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
this.warn('subagent-dsh live: ignored a malformed wire line');
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
if (typeof message.id === 'number' && ('result' in message || 'error' in message)) {
|
|
139
|
+
const request = this.pending.get(message.id);
|
|
140
|
+
if (request === undefined)
|
|
141
|
+
return;
|
|
142
|
+
this.pending.delete(message.id);
|
|
143
|
+
clearTimeout(request.timer);
|
|
144
|
+
const wireError = message.error;
|
|
145
|
+
if (wireError !== undefined && wireError !== null) {
|
|
146
|
+
request.reject(new Error(`subagent-dsh live: ${wireError.message ?? 'wire error'}`));
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
request.resolve(message.result);
|
|
150
|
+
}
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const params = (message.params ?? {});
|
|
154
|
+
if (typeof params.sessionId !== 'string')
|
|
155
|
+
return;
|
|
156
|
+
if (message.method === 'session/event') {
|
|
157
|
+
const event = params.event;
|
|
158
|
+
if (event === null || typeof event !== 'object' || typeof event.type !== 'string')
|
|
159
|
+
return;
|
|
160
|
+
this.onEvent?.(params.sessionId, typeof params.turn === 'number' ? params.turn : null, event);
|
|
161
|
+
}
|
|
162
|
+
else if (message.method === 'session/assistant-stream') {
|
|
163
|
+
if (typeof params.turn !== 'number' || params.frame === null || typeof params.frame !== 'object')
|
|
164
|
+
return;
|
|
165
|
+
this.onStream?.(params.sessionId, params.turn, params.frame);
|
|
166
|
+
}
|
|
167
|
+
else if (message.method === 'session/idle') {
|
|
168
|
+
if (typeof params.turn !== 'number')
|
|
169
|
+
return;
|
|
170
|
+
this.onIdle?.(params.sessionId, params.turn, (params.reason ?? null));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
// A handler fault (bad event shape, append failure) degrades to a warn;
|
|
175
|
+
// the wire pump and the process stay alive.
|
|
176
|
+
this.warn(`subagent-dsh live: notification handler failed: ${thrown(error).message}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/** Send one request and await its acknowledgement. */
|
|
180
|
+
request(method, params, timeoutMs) {
|
|
181
|
+
if (this.dead) {
|
|
182
|
+
return Promise.reject(new Error('subagent-dsh live: the runtime process is gone'));
|
|
183
|
+
}
|
|
184
|
+
this.nextId += 1;
|
|
185
|
+
const id = this.nextId;
|
|
186
|
+
return new Promise((resolve, reject) => {
|
|
187
|
+
const timer = setTimeout(() => {
|
|
188
|
+
this.pending.delete(id);
|
|
189
|
+
reject(new Error(`subagent-dsh live: ${method} timed out`));
|
|
190
|
+
timer.unref();
|
|
191
|
+
}, timeoutMs ?? this.timeouts.requestMs);
|
|
192
|
+
timer.unref();
|
|
193
|
+
this.pending.set(id, {
|
|
194
|
+
resolve: result => resolve(result),
|
|
195
|
+
reject,
|
|
196
|
+
timer,
|
|
197
|
+
});
|
|
198
|
+
this.child.stdin?.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
markDead() {
|
|
202
|
+
if (this.dead)
|
|
203
|
+
return;
|
|
204
|
+
this.dead = true;
|
|
205
|
+
for (const request of this.pending.values()) {
|
|
206
|
+
clearTimeout(request.timer);
|
|
207
|
+
request.reject(new Error('subagent-dsh live: the runtime process exited'));
|
|
208
|
+
}
|
|
209
|
+
this.pending.clear();
|
|
210
|
+
this.onDead?.();
|
|
211
|
+
}
|
|
212
|
+
/** Graceful teardown: wire shutdown, a beat to exit on its own, then the terminate ladder. */
|
|
213
|
+
async reclaim() {
|
|
214
|
+
if (this.reclaimed)
|
|
215
|
+
return;
|
|
216
|
+
this.reclaimed = true;
|
|
217
|
+
if (!this.dead) {
|
|
218
|
+
await this.request('shutdown', {}, RECLAIM_SHUTDOWN_GRACE_MS).catch(() => { });
|
|
219
|
+
await Promise.race([this.child.done.catch(() => { }), delay(RECLAIM_SHUTDOWN_GRACE_MS)]);
|
|
220
|
+
}
|
|
221
|
+
if (!this.dead) {
|
|
222
|
+
// Host 0.1.5 hides the child pid; terminate() is an idempotent no-op
|
|
223
|
+
// once the managed range is gone.
|
|
224
|
+
this.child.terminate();
|
|
225
|
+
await this.child.waitForExit();
|
|
226
|
+
}
|
|
227
|
+
await this.child.done.catch(() => { });
|
|
228
|
+
this.markDead();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* The live driver owns every resident runtime of this provider generation.
|
|
233
|
+
* The LiveDriverSwitch builds a generation when the settings resolve live on,
|
|
234
|
+
* retires one with {@link DshLiveDriver.drain} (never interrupts), and
|
|
235
|
+
* disposes with the enabled toggle (disposeAll).
|
|
236
|
+
*/
|
|
237
|
+
export class DshLiveDriver {
|
|
238
|
+
ctx;
|
|
239
|
+
config;
|
|
240
|
+
timeouts;
|
|
241
|
+
runtimes = new Map();
|
|
242
|
+
/** In-flight spawns by member: concurrent rounds share one, disposeAll waits them out. */
|
|
243
|
+
ensuring = new Map();
|
|
244
|
+
idleTimers = new Map();
|
|
245
|
+
/** Per-member round serialization (the resume lock covers resume-vs-resume only). */
|
|
246
|
+
roundChains = new Map();
|
|
247
|
+
/** When the channel last failed its spawn/handshake probe (breaker with cooldown). */
|
|
248
|
+
channelBrokenAt;
|
|
249
|
+
disposed = false;
|
|
250
|
+
/**
|
|
251
|
+
* Set by drain() (a settings-driven generation handoff): new rounds are
|
|
252
|
+
* refused so the provider falls back to exec, while in-flight rounds finish
|
|
253
|
+
* on their runtime undisturbed. Unlike `disposed`, the driver still serves
|
|
254
|
+
* what it already accepted.
|
|
255
|
+
*/
|
|
256
|
+
draining = false;
|
|
257
|
+
/** Aborts in-flight spawns when the driver is disposed mid-handshake. */
|
|
258
|
+
disposeController = new AbortController();
|
|
259
|
+
constructor(ctx, config, timeouts = DEFAULT_TIMEOUTS) {
|
|
260
|
+
this.ctx = ctx;
|
|
261
|
+
this.config = config;
|
|
262
|
+
this.timeouts = timeouts;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Whether the channel is in its post-failure cooldown. A spawn/handshake
|
|
266
|
+
* failure trips the breaker (rounds fall back to exec), but only for
|
|
267
|
+
* `channelRetryMs` — a transient boot fault must not disable live driving
|
|
268
|
+
* until the next plugin reload.
|
|
269
|
+
*/
|
|
270
|
+
get disabled() {
|
|
271
|
+
return this.channelBrokenAt !== undefined
|
|
272
|
+
&& Date.now() - this.channelBrokenAt < (this.timeouts.channelRetryMs ?? DEFAULT_LIVE_CHANNEL_RETRY_MS);
|
|
273
|
+
}
|
|
274
|
+
/** Count of resident runtimes currently registered (zombie accounting in tests). */
|
|
275
|
+
get liveCount() {
|
|
276
|
+
return this.runtimes.size;
|
|
277
|
+
}
|
|
278
|
+
clearIdleTimer(key) {
|
|
279
|
+
const timer = this.idleTimers.get(key);
|
|
280
|
+
if (timer !== undefined)
|
|
281
|
+
clearTimeout(timer);
|
|
282
|
+
this.idleTimers.delete(key);
|
|
283
|
+
}
|
|
284
|
+
/** Arm the idle reaper; every round settlement re-arms it. */
|
|
285
|
+
armIdleTimer(key) {
|
|
286
|
+
this.clearIdleTimer(key);
|
|
287
|
+
const idleMs = this.config.liveIdleMs ?? DEFAULT_LIVE_IDLE_MS;
|
|
288
|
+
const timer = setTimeout(() => { void this.reclaim(key); }, idleMs);
|
|
289
|
+
timer.unref();
|
|
290
|
+
this.idleTimers.set(key, timer);
|
|
291
|
+
}
|
|
292
|
+
/** Reclaim one runtime: deregister, wire shutdown, terminate ladder, member release. */
|
|
293
|
+
async reclaim(key) {
|
|
294
|
+
const runtime = this.runtimes.get(key);
|
|
295
|
+
this.clearIdleTimer(key);
|
|
296
|
+
if (runtime === undefined)
|
|
297
|
+
return;
|
|
298
|
+
this.runtimes.delete(key);
|
|
299
|
+
await runtime.reclaim();
|
|
300
|
+
}
|
|
301
|
+
/** Reclaim every runtime (plugin unload); no process survives the profile. */
|
|
302
|
+
async disposeAll() {
|
|
303
|
+
this.disposed = true;
|
|
304
|
+
// Abort in-flight spawns (their handshakes race this signal), then wait
|
|
305
|
+
// them out: each self-reclaims, so no process registers past the teardown.
|
|
306
|
+
this.disposeController.abort();
|
|
307
|
+
await Promise.all([...this.ensuring.values()].map(pending => pending.catch(() => undefined)));
|
|
308
|
+
for (const key of [...this.runtimes.keys()]) {
|
|
309
|
+
await this.reclaim(key);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* True while the member's runtime exists or is being spawned. The settings
|
|
314
|
+
* controller gates a new generation on this: a fresh driver must not serve
|
|
315
|
+
* a member whose retiring generation still hosts the (same) sub-dsh session.
|
|
316
|
+
*/
|
|
317
|
+
hasRuntime(key) {
|
|
318
|
+
return this.runtimes.has(key) || this.ensuring.has(key);
|
|
319
|
+
}
|
|
320
|
+
/** @deprecated Compatibility no-op: live output is always incremental. */
|
|
321
|
+
setLiveMirrorGranularity(_granularity) { }
|
|
322
|
+
/**
|
|
323
|
+
* Drain for a settings-driven generation handoff: refuse new rounds (the
|
|
324
|
+
* provider's catch falls back to exec), let every in-flight round finish on
|
|
325
|
+
* its runtime, then reclaim. Resolves when no runtime or spawn remains.
|
|
326
|
+
* Unlike disposeAll, in-flight work is never interrupted.
|
|
327
|
+
*/
|
|
328
|
+
async drain() {
|
|
329
|
+
this.draining = true;
|
|
330
|
+
// Snapshot: rounds chain synchronously at startRound, so every accepted
|
|
331
|
+
// round is already in roundChains; anything later is refused.
|
|
332
|
+
const keys = new Set([...this.runtimes.keys(), ...this.ensuring.keys(), ...this.roundChains.keys()]);
|
|
333
|
+
await Promise.all([...keys].map(async (key) => {
|
|
334
|
+
await (this.roundChains.get(key) ?? Promise.resolve()).catch(() => undefined);
|
|
335
|
+
await (this.ensuring.get(key) ?? Promise.resolve()).catch(() => undefined);
|
|
336
|
+
await this.reclaim(key);
|
|
337
|
+
}));
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* The model the member's live runtime bound at spawn: the identifier, or
|
|
341
|
+
* undefined when the runtime spawned with no `--model`. Null when the
|
|
342
|
+
* member has no live runtime in this generation — the broker's switch check
|
|
343
|
+
* distinguishes "no runtime to retire" from "runtime bound to no model".
|
|
344
|
+
*/
|
|
345
|
+
boundModelOf(key) {
|
|
346
|
+
const runtime = this.runtimes.get(key);
|
|
347
|
+
if (runtime === undefined || runtime.dead)
|
|
348
|
+
return null;
|
|
349
|
+
return runtime.boundModel;
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Retire the member's resident runtime so the NEXT round respawns (the
|
|
353
|
+
* sub-dsh session itself carries over via the on-disk resume). The
|
|
354
|
+
* composer's model switch calls this after storing the new override; the
|
|
355
|
+
* caller guarantees no round is in flight for the member.
|
|
356
|
+
*/
|
|
357
|
+
async retireRuntime(key) {
|
|
358
|
+
await this.reclaim(key);
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* The member's live runtime, spawning it (once per member at a time) when
|
|
362
|
+
* absent or dead. `signal` abandons the spawn on cancellation.
|
|
363
|
+
*/
|
|
364
|
+
async ensureRuntime(spec, apiKey, signal) {
|
|
365
|
+
const key = spec.sessionId;
|
|
366
|
+
const startModel = spec.startModel?.trim();
|
|
367
|
+
const existing = this.runtimes.get(key);
|
|
368
|
+
if (existing !== undefined && !existing.dead) {
|
|
369
|
+
// A round that names its own start model never runs on a runtime bound
|
|
370
|
+
// to a different one: retire so the respawn binds the asked-for model
|
|
371
|
+
// (the sub-dsh session resumes from disk — only the process is
|
|
372
|
+
// replaced).
|
|
373
|
+
if ((spec.configuration !== undefined && existing.configurationKey !== JSON.stringify(spec.configuration))
|
|
374
|
+
|| (spec.configuration === undefined && existing.configurationKey !== undefined)
|
|
375
|
+
|| (startModel !== undefined && startModel !== '' && existing.boundModel !== startModel)) {
|
|
376
|
+
await this.reclaim(key);
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
this.clearIdleTimer(key);
|
|
380
|
+
return existing;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
else if (existing !== undefined) {
|
|
384
|
+
this.runtimes.delete(key);
|
|
385
|
+
}
|
|
386
|
+
const pending = this.ensuring.get(key);
|
|
387
|
+
if (pending !== undefined)
|
|
388
|
+
return pending;
|
|
389
|
+
const spawn = this.spawnRuntime(spec, apiKey, signal)
|
|
390
|
+
.finally(() => { this.ensuring.delete(key); });
|
|
391
|
+
this.ensuring.set(key, spawn);
|
|
392
|
+
return spawn;
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Spawn the member's resident runtime and prove the wire with a handshake.
|
|
396
|
+
* Spawn/handshake failure trips the channel breaker (exec fallback) and
|
|
397
|
+
* throws {@link LiveChannelUnavailableError}; a later crash is NOT a broken
|
|
398
|
+
* channel — the next round re-spawns and resumes the on-disk session. A
|
|
399
|
+
* cancellation mid-spawn reclaims the half-started runtime and throws the
|
|
400
|
+
* abort instead, without touching the breaker.
|
|
401
|
+
*/
|
|
402
|
+
async spawnRuntime(spec, apiKey, signal) {
|
|
403
|
+
const key = spec.sessionId;
|
|
404
|
+
if (this.disposed) {
|
|
405
|
+
throw new LiveChannelUnavailableError('the live driver is disposed');
|
|
406
|
+
}
|
|
407
|
+
const profileName = this.config.profileName ?? DEFAULT_SUB_PROFILE_NAME;
|
|
408
|
+
// The member-channel coordinates ride the spawn env: the resident process
|
|
409
|
+
// carries them for its whole lifetime, so the token is registered with the
|
|
410
|
+
// process (released on reclaim/crash), not per round like the exec path.
|
|
411
|
+
const member = registerMemberRun(this.ctx, 'dsh-cli', spec.sessionId, spec.parentSessionId);
|
|
412
|
+
// The model binds at spawn through the headless launch's `--model`, which
|
|
413
|
+
// under `--serve` applies to every session the resident process hosts —
|
|
414
|
+
// one process carries one member, so this IS the member's model. The
|
|
415
|
+
// member's START model (a delegation-level request or the session-level
|
|
416
|
+
// override, resolved by the provider) wins; otherwise the spawn resolver
|
|
417
|
+
// reads the member's configured model (override → settings). A later
|
|
418
|
+
// switch reaches the runtime by retiring it first.
|
|
419
|
+
const startModel = spec.startModel?.trim();
|
|
420
|
+
const model = spec.configuration !== undefined ? spec.configuration.model
|
|
421
|
+
: startModel !== undefined && startModel !== '' ? startModel : this.config.modelFor?.(key)?.trim();
|
|
422
|
+
const spawnSpec = {
|
|
423
|
+
argv: [
|
|
424
|
+
...dshLaunchArgv(this.config),
|
|
425
|
+
'--profile', profileName,
|
|
426
|
+
'--serve',
|
|
427
|
+
...model === undefined || model === '' ? [] : ['--model', model],
|
|
428
|
+
...spec.configuration?.effort === undefined ? [] : ['--effort', spec.configuration.effort],
|
|
429
|
+
],
|
|
430
|
+
cwd: spec.cwd,
|
|
431
|
+
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
|
|
432
|
+
graceMs: DEFAULT_DISPOSE_GRACE_MS,
|
|
433
|
+
// Same explicit env layer as the exec path: the credential-shaped key
|
|
434
|
+
// and the DSH_* facts survive the shared scrub only here.
|
|
435
|
+
env: delegationEnv({
|
|
436
|
+
DSH_HOME: spec.homeDir,
|
|
437
|
+
DEEPSEEK_API_KEY: apiKey,
|
|
438
|
+
// Same tsx tsconfig carry-over as the exec path (see startDshCliRun).
|
|
439
|
+
...process.env.TSX_TSCONFIG_PATH === undefined
|
|
440
|
+
? {}
|
|
441
|
+
: { TSX_TSCONFIG_PATH: process.env.TSX_TSCONFIG_PATH },
|
|
442
|
+
...member === undefined ? {} : member.env,
|
|
443
|
+
}),
|
|
444
|
+
};
|
|
445
|
+
let child;
|
|
446
|
+
try {
|
|
447
|
+
child = this.ctx.subprocess.spawn(spawnSpec);
|
|
448
|
+
}
|
|
449
|
+
catch (error) {
|
|
450
|
+
member?.release();
|
|
451
|
+
this.markChannelBroken();
|
|
452
|
+
throw new LiveChannelUnavailableError(`the serve process failed to spawn: ${thrown(error).message}`);
|
|
453
|
+
}
|
|
454
|
+
const runtime = new LiveRuntime(child, this.timeouts, message => { this.ctx.logger.warn(message); });
|
|
455
|
+
// A blank resolution binds no model at all — record exactly what the argv
|
|
456
|
+
// carries so a later start-model comparison never retires needlessly.
|
|
457
|
+
runtime.boundModel = model === undefined || model === '' ? undefined : model;
|
|
458
|
+
runtime.configurationKey = spec.configuration === undefined ? undefined : JSON.stringify(spec.configuration);
|
|
459
|
+
runtime.onDead = () => {
|
|
460
|
+
// Delete only OUR registration: a crash-then-respawn can interleave so
|
|
461
|
+
// the dead runtime's late onDead would otherwise evict the NEW
|
|
462
|
+
// runtime's entry and leak it (the registry race the review caught).
|
|
463
|
+
if (this.runtimes.get(key) === runtime)
|
|
464
|
+
this.runtimes.delete(key);
|
|
465
|
+
this.clearIdleTimer(key);
|
|
466
|
+
member?.release();
|
|
467
|
+
};
|
|
468
|
+
const aborted = new Promise((_, reject) => {
|
|
469
|
+
const cancel = () => { reject(new Error('subagent-dsh: run cancelled locally')); };
|
|
470
|
+
const unload = () => { reject(new LiveChannelUnavailableError('the live driver was disposed during spawn')); };
|
|
471
|
+
if (signal.aborted) {
|
|
472
|
+
cancel();
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
if (this.disposeController.signal.aborted) {
|
|
476
|
+
unload();
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
signal.addEventListener('abort', cancel, { once: true });
|
|
480
|
+
this.disposeController.signal.addEventListener('abort', unload, { once: true });
|
|
481
|
+
});
|
|
482
|
+
aborted.catch(() => { });
|
|
483
|
+
try {
|
|
484
|
+
const hello = await Promise.race([
|
|
485
|
+
runtime.request('initialize', {}, this.timeouts.initializeMs),
|
|
486
|
+
aborted,
|
|
487
|
+
]);
|
|
488
|
+
if (hello.protocolVersion !== LIVE_WIRE_PROTOCOL_VERSION || hello.serverInfo.name !== LIVE_SERVER_NAME) {
|
|
489
|
+
throw new Error('the serve handshake reported an unexpected identity');
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
catch (error) {
|
|
493
|
+
await runtime.reclaim();
|
|
494
|
+
// A caller-cancelled handshake says nothing about the channel's health.
|
|
495
|
+
if (signal.aborted)
|
|
496
|
+
throw thrown(error);
|
|
497
|
+
this.markChannelBroken();
|
|
498
|
+
throw new LiveChannelUnavailableError(`the serve handshake failed: ${thrown(error).message}`);
|
|
499
|
+
}
|
|
500
|
+
if (this.disposed) {
|
|
501
|
+
// The plugin unloaded mid-spawn: reclaim instead of leaking the process.
|
|
502
|
+
await runtime.reclaim();
|
|
503
|
+
throw new LiveChannelUnavailableError('the live driver was disposed during spawn');
|
|
504
|
+
}
|
|
505
|
+
// A good handshake heals the breaker (the cooldown retry path).
|
|
506
|
+
this.channelBrokenAt = undefined;
|
|
507
|
+
this.runtimes.set(key, runtime);
|
|
508
|
+
return runtime;
|
|
509
|
+
}
|
|
510
|
+
markChannelBroken() {
|
|
511
|
+
this.channelBrokenAt = Date.now();
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Drive one delegation round on the member's resident runtime. Rounds of
|
|
515
|
+
* one member are strictly serialized: the facade's resume lock covers
|
|
516
|
+
* resume-vs-resume only, and a resume racing an in-flight fresh round would
|
|
517
|
+
* otherwise overwrite the runtime's single notification sink and strand
|
|
518
|
+
* the earlier round forever.
|
|
519
|
+
*/
|
|
520
|
+
/** Prepare the owned headless agent and validate its adapter without a model turn. */
|
|
521
|
+
async prepare(spec, signal) {
|
|
522
|
+
signal.throwIfAborted();
|
|
523
|
+
if (this.draining || this.disabled)
|
|
524
|
+
throw new LiveChannelUnavailableError('the live driver is unavailable for preparation');
|
|
525
|
+
try {
|
|
526
|
+
provisionDshScope(spec.homeDir, this.config);
|
|
527
|
+
const apiKey = await resolveApiKey(this.ctx, this.config);
|
|
528
|
+
const runtime = await this.ensureRuntime(spec, apiKey, signal);
|
|
529
|
+
await runtime.request('session/prepare', { sessionId: spec.sessionId, resume: spec.resume !== undefined });
|
|
530
|
+
signal.throwIfAborted();
|
|
531
|
+
this.armIdleTimer(spec.sessionId);
|
|
532
|
+
}
|
|
533
|
+
catch (error) {
|
|
534
|
+
await this.reclaim(spec.sessionId);
|
|
535
|
+
throw error;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
async startRound(request, spec) {
|
|
539
|
+
// A draining generation refuses new rounds BEFORE chaining so the
|
|
540
|
+
// provider's exec fallback does not queue behind an in-flight round.
|
|
541
|
+
if (this.draining) {
|
|
542
|
+
throw new LiveChannelUnavailableError('the live driver is draining (a settings change retired this generation)');
|
|
543
|
+
}
|
|
544
|
+
const key = String(spec.childSession.id);
|
|
545
|
+
const previous = this.roundChains.get(key) ?? Promise.resolve();
|
|
546
|
+
const round = previous.catch(() => { }).then(() => this.startRoundLocked(request, spec));
|
|
547
|
+
// The chain holds until the round's result SETTLES (never rejects by the
|
|
548
|
+
// seam contract), not merely until the handle publishes.
|
|
549
|
+
this.roundChains.set(key, round.then(handle => handle.result.catch(() => ({})), () => ({})));
|
|
550
|
+
return round;
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* The serialized round body. Mirrors the exec run's settlement contract
|
|
554
|
+
* exactly (settleRunResult + turn/end bookkeeping) so the facade, the
|
|
555
|
+
* tool, and the projections cannot tell the difference — except `cancel`
|
|
556
|
+
* is a runtime interrupt and the process survives. Cancellation is honored
|
|
557
|
+
* in EVERY window: a slow spawn/handshake or a slow accept races the abort
|
|
558
|
+
* signal and settles aborted instead of running the turn to completion
|
|
559
|
+
* unwatched.
|
|
560
|
+
*/
|
|
561
|
+
async startRoundLocked(request, spec) {
|
|
562
|
+
const task = dshTextTask(request.prompt);
|
|
563
|
+
if (request.signal.aborted) {
|
|
564
|
+
throw new Error('subagent-dsh: request was aborted before the run started');
|
|
565
|
+
}
|
|
566
|
+
// The drain race: the round chained before drain() but dequeued after it.
|
|
567
|
+
// Refuse so the provider falls back to exec instead of reusing a runtime
|
|
568
|
+
// the handoff is about to reclaim.
|
|
569
|
+
if (this.draining) {
|
|
570
|
+
throw new LiveChannelUnavailableError('the live driver is draining (a settings change retired this generation)');
|
|
571
|
+
}
|
|
572
|
+
if (this.disposed) {
|
|
573
|
+
throw new Error('subagent-dsh: the live driver is disposed');
|
|
574
|
+
}
|
|
575
|
+
const turn = spec.resume?.turn ?? 1;
|
|
576
|
+
const childSession = spec.childSession;
|
|
577
|
+
const localAgent = this.ctx.get('localAgent');
|
|
578
|
+
const runAbort = new AbortController();
|
|
579
|
+
let roundSettled = false;
|
|
580
|
+
let runtime;
|
|
581
|
+
/** True once turn/start may have reached the sub-dsh (interrupt becomes meaningful). */
|
|
582
|
+
let acceptSent = false;
|
|
583
|
+
/** True once the parent's turn/start boundary is in the child session. */
|
|
584
|
+
let turnOpened = false;
|
|
585
|
+
let lastText = '';
|
|
586
|
+
let mirroredMessages = 0;
|
|
587
|
+
/** Events of this round that arrived before the boundary opened (same-chunk batching). */
|
|
588
|
+
const bufferedEvents = [];
|
|
589
|
+
let persistQueue = Promise.resolve();
|
|
590
|
+
const persist = () => {
|
|
591
|
+
persistQueue = persistQueue.then(() => persistChildSession(this.ctx, childSession)).catch((error) => {
|
|
592
|
+
this.ctx.logger.warn(`subagent-dsh: live persistence failed: ${thrown(error).message}`);
|
|
593
|
+
});
|
|
594
|
+
};
|
|
595
|
+
const streamPublisher = localAgent?.liveStreams === undefined ? undefined
|
|
596
|
+
: new LiveStreamPublisher(localAgent.liveStreams, childSession, turn, persist, error => this.ctx.logger.warn(`live checkpoint failed: ${String(error)}`));
|
|
597
|
+
const liveFlush = new LiveFlush(error => this.ctx.logger.warn(`subagent-dsh: live flush failed: ${String(error)}`));
|
|
598
|
+
let streaming;
|
|
599
|
+
const publishStream = () => {
|
|
600
|
+
if (streaming === undefined || streamPublisher === undefined)
|
|
601
|
+
return;
|
|
602
|
+
const blocks = streaming.assembler.interruptedBlocks();
|
|
603
|
+
const receivedAt = streaming.receivedAt ?? Date.now();
|
|
604
|
+
for (const [index, block] of blocks.entries()) {
|
|
605
|
+
if ((block.type === 'reasoning' || block.type === 'text') && block.text !== '')
|
|
606
|
+
streamPublisher.update(streaming.step, block.type === 'reasoning' ? 'think' : 'text', block.text, { itemId: `${streaming.id}:${index}`, receivedAt });
|
|
607
|
+
}
|
|
608
|
+
delete streaming.receivedAt;
|
|
609
|
+
};
|
|
610
|
+
const acceptStream = (frame) => {
|
|
611
|
+
if (streamPublisher === undefined)
|
|
612
|
+
return;
|
|
613
|
+
if (frame.type === 'start') {
|
|
614
|
+
if (streaming !== undefined)
|
|
615
|
+
liveFlush.cancel(streaming.step);
|
|
616
|
+
streaming = { id: String(frame.attemptId), step: frame.step, assembler: new BlockAssembler(), next: 0 };
|
|
617
|
+
}
|
|
618
|
+
else if (frame.type === 'chunk' && streaming?.id === frame.attemptId) {
|
|
619
|
+
if (frame.index < streaming.next)
|
|
620
|
+
return;
|
|
621
|
+
if (frame.index !== streaming.next)
|
|
622
|
+
throw new Error('non-contiguous DSH assistant stream');
|
|
623
|
+
streaming.next++;
|
|
624
|
+
streaming.receivedAt ??= Date.now();
|
|
625
|
+
streaming.assembler.push(frame.chunk);
|
|
626
|
+
liveFlush.schedule(streaming.step, publishStream);
|
|
627
|
+
}
|
|
628
|
+
};
|
|
629
|
+
const requestCancel = () => {
|
|
630
|
+
if (roundSettled || runAbort.signal.aborted)
|
|
631
|
+
return;
|
|
632
|
+
runAbort.abort(new Error('subagent-dsh: run cancelled locally'));
|
|
633
|
+
// The live driver's core win: a graceful runtime interrupt instead of a
|
|
634
|
+
// process kill. Best-effort; local settlement does not wait for it.
|
|
635
|
+
if (acceptSent && runtime !== undefined && !runtime.dead) {
|
|
636
|
+
void runtime.request('turn/interrupt', { sessionId: spec.sessionId }).catch(() => { });
|
|
637
|
+
}
|
|
638
|
+
};
|
|
639
|
+
const onAbort = () => { requestCancel(); };
|
|
640
|
+
// Registered before any await: a cancel during the slow spawn/handshake/
|
|
641
|
+
// accept windows is exactly where the graceful interrupt matters most.
|
|
642
|
+
request.signal.addEventListener('abort', onAbort, { once: true });
|
|
643
|
+
const abortBranch = new Promise((_, reject) => {
|
|
644
|
+
runAbort.signal.addEventListener('abort', () => reject(new Error('subagent-dsh: run cancelled locally')), { once: true });
|
|
645
|
+
});
|
|
646
|
+
const mirrorOne = (event) => {
|
|
647
|
+
const text = mirrorDshLiveEvent(childSession, event);
|
|
648
|
+
if (event.type === 'assistant/message') {
|
|
649
|
+
liveFlush.cancel(event.data.step);
|
|
650
|
+
streamPublisher?.finish(event.data.step);
|
|
651
|
+
if (streaming?.step === event.data.step)
|
|
652
|
+
streaming = undefined;
|
|
653
|
+
}
|
|
654
|
+
if (event.type === 'user/message' || event.type === 'assistant/message') {
|
|
655
|
+
mirroredMessages += 1;
|
|
656
|
+
persist();
|
|
657
|
+
}
|
|
658
|
+
if (text !== undefined) {
|
|
659
|
+
localAgent?.reportRunProgress(childSession.id, { kind: 'delta', text });
|
|
660
|
+
}
|
|
661
|
+
if (event.type === 'assistant/message' && text !== undefined && text !== '')
|
|
662
|
+
lastText = text;
|
|
663
|
+
};
|
|
664
|
+
const openTurn = () => {
|
|
665
|
+
// The boundary opens after the accept ack (a definitive reject leaves no
|
|
666
|
+
// dangling turn/start); events that arrived in the ack's chunk were
|
|
667
|
+
// buffered and flush now, in wire order, inside the boundary.
|
|
668
|
+
childSession.append('turn/start', { turn });
|
|
669
|
+
turnOpened = true;
|
|
670
|
+
for (const entry of bufferedEvents.splice(0)) {
|
|
671
|
+
if (entry.kind === 'event')
|
|
672
|
+
mirrorOne(entry.event);
|
|
673
|
+
else
|
|
674
|
+
acceptStream(entry.frame);
|
|
675
|
+
}
|
|
676
|
+
};
|
|
677
|
+
let resolveIdle;
|
|
678
|
+
const idle = new Promise((resolve) => { resolveIdle = resolve; });
|
|
679
|
+
const installHandlers = (rt) => {
|
|
680
|
+
rt.onEvent = (sessionId, eventTurn, event) => {
|
|
681
|
+
// Only this round's events: a cancelled round's late unwind (tagged
|
|
682
|
+
// with ITS turn) and out-of-round events (null) never cross.
|
|
683
|
+
if (sessionId !== spec.sessionId || eventTurn !== turn)
|
|
684
|
+
return;
|
|
685
|
+
if (!turnOpened) {
|
|
686
|
+
bufferedEvents.push({ kind: 'event', event });
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
mirrorOne(event);
|
|
690
|
+
};
|
|
691
|
+
rt.onStream = (sessionId, eventTurn, frame) => {
|
|
692
|
+
if (sessionId !== spec.sessionId || eventTurn !== turn || roundSettled)
|
|
693
|
+
return;
|
|
694
|
+
if (!turnOpened)
|
|
695
|
+
bufferedEvents.push({ kind: 'stream', frame });
|
|
696
|
+
else
|
|
697
|
+
acceptStream(frame);
|
|
698
|
+
};
|
|
699
|
+
rt.onIdle = (sessionId, idleTurn, reason) => {
|
|
700
|
+
// Only this round's idle settles it — a cancelled round's unwind idle
|
|
701
|
+
// must not mis-settle the round that is actually running.
|
|
702
|
+
if (sessionId !== spec.sessionId || idleTurn !== turn)
|
|
703
|
+
return;
|
|
704
|
+
resolveIdle({ reason });
|
|
705
|
+
};
|
|
706
|
+
};
|
|
707
|
+
// Provisioning is idempotent; re-running heals a drifted sub-profile.
|
|
708
|
+
provisionDshScope(spec.homeDir, this.config);
|
|
709
|
+
const apiKey = await resolveApiKey(this.ctx, this.config);
|
|
710
|
+
const accepted = (async () => {
|
|
711
|
+
const rt = await this.ensureRuntime(spec, apiKey, request.signal);
|
|
712
|
+
runtime = rt;
|
|
713
|
+
if (runAbort.signal.aborted) {
|
|
714
|
+
// Cancelled while the runtime came up and no turn exists sub-side: the
|
|
715
|
+
// fresh runtime serves nothing — reclaim it, don't warm an abandoned
|
|
716
|
+
// member.
|
|
717
|
+
await this.reclaim(spec.sessionId);
|
|
718
|
+
throw new Error('subagent-dsh: run cancelled locally');
|
|
719
|
+
}
|
|
720
|
+
installHandlers(rt);
|
|
721
|
+
acceptSent = true;
|
|
722
|
+
try {
|
|
723
|
+
await rt.request('turn/start', {
|
|
724
|
+
sessionId: spec.sessionId,
|
|
725
|
+
text: task,
|
|
726
|
+
resume: spec.resume !== undefined,
|
|
727
|
+
turn,
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
catch (error) {
|
|
731
|
+
// The accept failed: the runtime's turn state is unknown, so do not
|
|
732
|
+
// reuse it — reclaim and fail the round loudly. The turn never opened
|
|
733
|
+
// parent-side, so no dangling turn/start (an explicit reject means
|
|
734
|
+
// the sub-dsh provably never started the turn; the narrow timeout
|
|
735
|
+
// race is the same class as exec's SIGKILL-mid-turn window).
|
|
736
|
+
if (!runAbort.signal.aborted)
|
|
737
|
+
await this.reclaim(spec.sessionId);
|
|
738
|
+
throw thrown(error);
|
|
739
|
+
}
|
|
740
|
+
openTurn();
|
|
741
|
+
})();
|
|
742
|
+
const attempt = accepted.then(async () => {
|
|
743
|
+
if (runAbort.signal.aborted)
|
|
744
|
+
throw new Error('subagent-dsh: run cancelled locally');
|
|
745
|
+
const rt = runtime;
|
|
746
|
+
const processFailure = rt.child.done.then(outcome => Promise.reject(new Error('subagent-dsh: the live runtime exited mid-round '
|
|
747
|
+
+ `(code ${String(outcome.exitCode)}, signal ${String(outcome.signal)})`)), (error) => Promise.reject(thrown(error)));
|
|
748
|
+
processFailure.catch(() => { });
|
|
749
|
+
const { reason } = await Promise.race([idle, processFailure]);
|
|
750
|
+
if (reason?.kind === 'aborted') {
|
|
751
|
+
return { output: collectOutput(), stopReason: 'aborted' };
|
|
752
|
+
}
|
|
753
|
+
if (reason?.kind === 'error') {
|
|
754
|
+
throw new Error(`subagent-dsh live: the turn failed: ${reason.error.message}`);
|
|
755
|
+
}
|
|
756
|
+
if (reason?.kind !== 'completed') {
|
|
757
|
+
throw new Error('subagent-dsh live: the turn produced no outcome');
|
|
758
|
+
}
|
|
759
|
+
const output = collectOutput();
|
|
760
|
+
if (output.length === 0) {
|
|
761
|
+
throw new Error('subagent-dsh live: the turn completed but produced no answer');
|
|
762
|
+
}
|
|
763
|
+
return { output, stopReason: 'completed' };
|
|
764
|
+
});
|
|
765
|
+
const collectOutput = () => lastText === '' ? [] : [{ type: 'text', text: lastText }];
|
|
766
|
+
const result = settleRunResult({
|
|
767
|
+
attempt: () => Promise.race([attempt, abortBranch]),
|
|
768
|
+
collectOutput,
|
|
769
|
+
cancelled: () => runAbort.signal.aborted,
|
|
770
|
+
onError: (error, stopReason) => {
|
|
771
|
+
const diagnostics = runtime?.diagnostics ?? '';
|
|
772
|
+
const suffix = diagnostics === '' ? '' : `; ${diagnostics}`;
|
|
773
|
+
this.ctx.logger.warn(`subagent-dsh: live round failed (${stopReason}): ${error.message}${suffix}`);
|
|
774
|
+
},
|
|
775
|
+
signal: request.signal,
|
|
776
|
+
onAbort,
|
|
777
|
+
}).then(async (settled) => {
|
|
778
|
+
roundSettled = true;
|
|
779
|
+
liveFlush.dispose();
|
|
780
|
+
if (turnOpened && streaming !== undefined && streamPublisher !== undefined) {
|
|
781
|
+
const blocks = streaming.assembler.interruptedBlocks();
|
|
782
|
+
if (blocks.length > 0) {
|
|
783
|
+
childSession.append('assistant/message', {
|
|
784
|
+
turn, step: streaming.step,
|
|
785
|
+
message: createAssistantMessage({ content: blocks, source: { provider: 'dsh-local', model: 'unobserved' } }), stream: [],
|
|
786
|
+
interrupted: true,
|
|
787
|
+
}, { surfaceOp: 'append' });
|
|
788
|
+
streamPublisher.finish(streaming.step);
|
|
789
|
+
persist();
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
streamPublisher?.dispose();
|
|
793
|
+
// Identical turn/end bookkeeping to the exec path — but only for a turn
|
|
794
|
+
// that actually opened (a pre-accept cancel records no turn at all).
|
|
795
|
+
if (turnOpened) {
|
|
796
|
+
if (settled.stopReason === 'completed') {
|
|
797
|
+
childSession.append('turn/end', { turn, reason: { kind: 'completed' } });
|
|
798
|
+
}
|
|
799
|
+
else if (settled.stopReason === 'aborted') {
|
|
800
|
+
childSession.append('turn/end', { turn, reason: { kind: 'aborted', reason: { kind: 'parent' } } });
|
|
801
|
+
}
|
|
802
|
+
else {
|
|
803
|
+
childSession.append('turn/end', {
|
|
804
|
+
turn,
|
|
805
|
+
reason: { kind: 'error', error: { message: 'the live round did not complete', code: 'UNKNOWN' } },
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
// Settlement clears the round's handlers: a late notification drops at
|
|
810
|
+
// the runtime frame instead of landing in a dead closure.
|
|
811
|
+
if (runtime !== undefined) {
|
|
812
|
+
runtime.onStream = undefined;
|
|
813
|
+
runtime.onEvent = undefined;
|
|
814
|
+
runtime.onIdle = undefined;
|
|
815
|
+
}
|
|
816
|
+
// A completed answer is not a durable turn until its final boundary
|
|
817
|
+
// and any closing stream checkpoints have reached the child store.
|
|
818
|
+
if (turnOpened) {
|
|
819
|
+
persist();
|
|
820
|
+
await persistQueue;
|
|
821
|
+
}
|
|
822
|
+
return settled;
|
|
823
|
+
});
|
|
824
|
+
// Settle reconciliation: the file-based mirror pass (the serve side flushed
|
|
825
|
+
// before its idle notification) dedupes against the live-mirrored prefix
|
|
826
|
+
// and catches anything the wire dropped; the final mirror progress report
|
|
827
|
+
// is authoritative, exactly as in the exec path. Then re-arm the reaper. A
|
|
828
|
+
// round that never opened owns no span — skip the pass entirely (it would
|
|
829
|
+
// re-scan the previous round or, under exec fallback, fight its polling).
|
|
830
|
+
void result.then(async () => {
|
|
831
|
+
try {
|
|
832
|
+
if (turnOpened) {
|
|
833
|
+
await persistQueue;
|
|
834
|
+
if (mirroredMessages > 0)
|
|
835
|
+
persist();
|
|
836
|
+
await persistQueue.catch(() => { });
|
|
837
|
+
const delta = await mirrorDshSession(this.ctx, childSession, spec.homeDir, spec.sessionId);
|
|
838
|
+
if (localAgent !== undefined) {
|
|
839
|
+
for (const text of delta.texts) {
|
|
840
|
+
localAgent.reportRunProgress(childSession.id, { kind: 'delta', text });
|
|
841
|
+
}
|
|
842
|
+
localAgent.reportRunProgress(childSession.id, { kind: 'mirror', mirroredLines: delta.total });
|
|
843
|
+
// Every settled round reports its observation through the
|
|
844
|
+
// registry's channel (record merge + `settled` event) — the live
|
|
845
|
+
// drive's half of the exec path's settle-mirror report: the
|
|
846
|
+
// sub-dsh session's own model attribution, the round's usage and
|
|
847
|
+
// tool-call accounting, each absent when the round's events name
|
|
848
|
+
// none. Degrades silently on a core predating recordRoundSettled.
|
|
849
|
+
const round = {
|
|
850
|
+
...delta.observedModel === undefined ? {} : { observedModel: delta.observedModel },
|
|
851
|
+
...delta.usage === undefined ? {} : { usage: delta.usage },
|
|
852
|
+
...delta.toolCalls === undefined ? {} : { toolCalls: delta.toolCalls },
|
|
853
|
+
};
|
|
854
|
+
const registry = localAgent;
|
|
855
|
+
registry?.recordRoundSettled?.(childSession.id, round);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
catch (error) {
|
|
860
|
+
this.ctx.logger.warn(`subagent-dsh: live settle mirror failed: ${thrown(error).message}`);
|
|
861
|
+
}
|
|
862
|
+
finally {
|
|
863
|
+
this.armIdleTimer(spec.sessionId);
|
|
864
|
+
}
|
|
865
|
+
});
|
|
866
|
+
// Publication gate: hold start() until the turn is accepted (channel
|
|
867
|
+
// errors still throw for the provider's exec fallback), but NEVER hold it
|
|
868
|
+
// hostage to the slow windows — a cancel returns the run immediately and
|
|
869
|
+
// the result settles aborted.
|
|
870
|
+
await Promise.race([accepted, abortBranch.catch(() => undefined)]);
|
|
871
|
+
return subprocessRunHandle({
|
|
872
|
+
id: childSession.id,
|
|
873
|
+
result,
|
|
874
|
+
signal: request.signal,
|
|
875
|
+
onAbort,
|
|
876
|
+
requestCancel,
|
|
877
|
+
// The live teardown contract: NEVER kill the runtime here. The graceful
|
|
878
|
+
// interrupt already went out via requestCancel; wait (bounded) for the
|
|
879
|
+
// turn to converge so the runtime is left consistent for the next round.
|
|
880
|
+
teardown: async () => {
|
|
881
|
+
if (roundSettled)
|
|
882
|
+
return;
|
|
883
|
+
await Promise.race([idle.catch(() => ({ reason: null })), delay(this.timeouts.convergeMs)]);
|
|
884
|
+
},
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
}
|