@rynx-ai/runtime 0.1.11-beta.1 → 0.1.11-beta.3
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/dist/claude/native-bridge.js +3 -8
- package/dist/claude/native-integration.d.ts +12 -1
- package/dist/claude/native-integration.js +16 -2
- package/dist/claude/transcript.d.ts +0 -7
- package/dist/claude/transcript.js +6 -20
- package/dist/codex-app-server/client.d.ts +2 -1
- package/dist/codex-app-server/forwarder.d.ts +4 -1
- package/dist/codex-app-server/forwarder.js +19 -1
- package/dist/codex-app-server/protocol.d.ts +45 -1
- package/dist/codex-home.d.ts +9 -26
- package/dist/codex-home.js +37 -65
- package/dist/codex-session-store.d.ts +22 -10
- package/dist/codex-session-store.js +277 -12
- package/dist/host.d.ts +47 -47
- package/dist/host.js +799 -351
- package/dist/index.d.ts +2 -3
- package/dist/index.js +1 -2
- package/dist/models-catalog.d.ts +1 -0
- package/dist/models-catalog.js +43 -1
- package/dist/provider-workspace.d.ts +56 -0
- package/dist/provider-workspace.js +83 -0
- package/dist/runner/child.d.ts +54 -6
- package/dist/runner/child.js +42 -17
- package/dist/runner/manager.d.ts +92 -19
- package/dist/runner/manager.js +838 -83
- package/dist/runner/protocol.d.ts +7 -18
- package/dist/runner-main.js +12 -4
- package/dist/runtime-state-paths.d.ts +10 -0
- package/dist/runtime-state-paths.js +53 -0
- package/dist/terminal/claude-tui.d.ts +8 -1
- package/dist/terminal/claude-tui.js +7 -1
- package/dist/terminal/codex-tui.d.ts +5 -1
- package/dist/terminal/codex-tui.js +12 -3
- package/dist/terminal/tmux.d.ts +8 -0
- package/dist/terminal/tmux.js +36 -3
- package/package.json +2 -2
- package/dist/codex/rollout-synth.d.ts +0 -42
- package/dist/codex/rollout-synth.js +0 -245
package/dist/runner/manager.js
CHANGED
|
@@ -22,6 +22,7 @@ import { fileURLToPath } from "node:url";
|
|
|
22
22
|
import { AgentRuntimeError, } from "@rynx-ai/core";
|
|
23
23
|
import { listRuntimeModels } from "../models-catalog.js";
|
|
24
24
|
import { probeRuntimeStatus } from "../runtime-status.js";
|
|
25
|
+
import { terminateTmuxServer } from "../terminal/tmux.js";
|
|
25
26
|
import { fromWireError, } from "./protocol.js";
|
|
26
27
|
import { StdioRunnerTransport } from "./transport.js";
|
|
27
28
|
/** Routing key for the shared capability runner (slash-command RPCs). */
|
|
@@ -36,6 +37,13 @@ const DEFAULT_SHUTDOWN_KILL_GRACE_MS = 5_000;
|
|
|
36
37
|
const DEFAULT_LIVE_START_TIMEOUT_MS = 10_000;
|
|
37
38
|
/** Thread readiness may legitimately wait through Provider startup. */
|
|
38
39
|
const DEFAULT_LIVE_READY_TIMEOUT_MS = 25_000;
|
|
40
|
+
/** A submitted TUI command should become a mirrored turn or native rotation
|
|
41
|
+
* quickly. If it does not, the runner is fenced by a verified process-tree
|
|
42
|
+
* shutdown before maintenance may treat the submission as settled. */
|
|
43
|
+
const DEFAULT_TERMINAL_INPUT_HANDOFF_TIMEOUT_MS = 30_000;
|
|
44
|
+
/** Retry transient terminal-server cleanup without spinning forever. */
|
|
45
|
+
const DEFAULT_TERMINAL_INPUT_CLEANUP_RETRY_MS = 1_000;
|
|
46
|
+
const MAX_TERMINAL_INPUT_CLEANUP_RETRY_MS = 30_000;
|
|
39
47
|
/** Keep per-Session spawn context small enough to remain an environment handoff,
|
|
40
48
|
* not an unbounded transport. */
|
|
41
49
|
const MAX_SESSION_CONTEXT_ENV_ENTRIES = 32;
|
|
@@ -139,6 +147,8 @@ export class RunnerManager {
|
|
|
139
147
|
spawn;
|
|
140
148
|
childEnv;
|
|
141
149
|
sessionContextProvider;
|
|
150
|
+
admissionOpen;
|
|
151
|
+
admissionReserve;
|
|
142
152
|
now;
|
|
143
153
|
defaultRuntime;
|
|
144
154
|
handles = new Map();
|
|
@@ -148,8 +158,13 @@ export class RunnerManager {
|
|
|
148
158
|
reapTimer;
|
|
149
159
|
shutdownGraceMs;
|
|
150
160
|
shutdownKillGraceMs;
|
|
161
|
+
signalChild;
|
|
162
|
+
terminateTerminalServer;
|
|
151
163
|
liveStartTimeoutMs;
|
|
152
164
|
liveReadyTimeoutMs;
|
|
165
|
+
liveInterruptTimeoutMs;
|
|
166
|
+
terminalInputHandoffTimeoutMs;
|
|
167
|
+
terminalInputCleanupRetryMs;
|
|
153
168
|
stopping = false;
|
|
154
169
|
stopPromise;
|
|
155
170
|
/** Sink for mirrored {@link SessionEvent}s from every session's forwarder. */
|
|
@@ -160,6 +175,24 @@ export class RunnerManager {
|
|
|
160
175
|
liveSessionKeys = new Set();
|
|
161
176
|
/** Last live-start error per local session, surfaced by the control API. */
|
|
162
177
|
liveErrors = new Map();
|
|
178
|
+
/** Last immutable launch snapshots seen for a Session. Used only to restore a
|
|
179
|
+
* target request that crossed a fork reservation boundary. */
|
|
180
|
+
liveOptions = new Map();
|
|
181
|
+
/** A fork reserves its target before the first asynchronous store read. This
|
|
182
|
+
* prevents another entry point from starting the target against an
|
|
183
|
+
* uncommitted Provider binding. */
|
|
184
|
+
forkReservations = new Map();
|
|
185
|
+
/** A native fork temporarily makes the source read-only so its canonical
|
|
186
|
+
* snapshot and Provider context are captured at the same boundary. */
|
|
187
|
+
sourceForkReservations = new Map();
|
|
188
|
+
/** Manager-wide fork de-duplication. LocalAgentHost only sees one source
|
|
189
|
+
* runner, so the fence must live here to cover concurrent source runners. */
|
|
190
|
+
forkOperations = new Map();
|
|
191
|
+
forkBufferedMessages = new Map();
|
|
192
|
+
forkBufferedTerminalInputs = new Map();
|
|
193
|
+
/** Owner TUI submissions accepted by the parent but not yet represented by a
|
|
194
|
+
* mirrored response or a durably published native rotation. */
|
|
195
|
+
terminalInputHandoffs = new Map();
|
|
163
196
|
constructor(opts) {
|
|
164
197
|
this.config = opts.config;
|
|
165
198
|
this.sessionStore = opts.sessionStore;
|
|
@@ -168,12 +201,19 @@ export class RunnerManager {
|
|
|
168
201
|
this.spawn = opts.spawn ?? nodeSpawn;
|
|
169
202
|
this.childEnv = opts.childEnv ?? {};
|
|
170
203
|
this.sessionContextProvider = opts.sessionContextProvider;
|
|
204
|
+
this.admissionOpen = opts.admissionOpen ?? (() => true);
|
|
205
|
+
this.admissionReserve = opts.admissionReserve;
|
|
171
206
|
this.now = opts.now ?? (() => Date.now());
|
|
172
|
-
this.defaultRuntime = opts.config.
|
|
207
|
+
this.defaultRuntime = opts.config.DEFAULT_RUNTIME ?? "codex";
|
|
173
208
|
this.shutdownGraceMs = Math.max(0, opts.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS);
|
|
174
209
|
this.shutdownKillGraceMs = Math.max(0, opts.shutdownKillGraceMs ?? DEFAULT_SHUTDOWN_KILL_GRACE_MS);
|
|
210
|
+
this.signalChild = opts.signalChild ?? signalRunnerChild;
|
|
211
|
+
this.terminateTerminalServer = opts.terminateTerminalServer ?? terminateTmuxServer;
|
|
175
212
|
this.liveStartTimeoutMs = Math.max(1, opts.liveStartTimeoutMs ?? DEFAULT_LIVE_START_TIMEOUT_MS);
|
|
176
213
|
this.liveReadyTimeoutMs = Math.max(1, opts.liveReadyTimeoutMs ?? DEFAULT_LIVE_READY_TIMEOUT_MS);
|
|
214
|
+
this.liveInterruptTimeoutMs = Math.max(1, opts.liveInterruptTimeoutMs ?? 10_000);
|
|
215
|
+
this.terminalInputHandoffTimeoutMs = Math.max(1, opts.terminalInputHandoffTimeoutMs ?? DEFAULT_TERMINAL_INPUT_HANDOFF_TIMEOUT_MS);
|
|
216
|
+
this.terminalInputCleanupRetryMs = Math.max(1, opts.terminalInputCleanupRetryMs ?? DEFAULT_TERMINAL_INPUT_CLEANUP_RETRY_MS);
|
|
177
217
|
const reapIntervalMs = opts.reapIntervalMs ?? 60_000;
|
|
178
218
|
if (reapIntervalMs > 0) {
|
|
179
219
|
this.reapTimer = setInterval(() => this.reapIdle(), reapIntervalMs);
|
|
@@ -201,6 +241,10 @@ export class RunnerManager {
|
|
|
201
241
|
* and attach.
|
|
202
242
|
*/
|
|
203
243
|
openLiveTerminal(localThreadId, opts) {
|
|
244
|
+
this.assertAdmissionOpen();
|
|
245
|
+
if (this.forkReservations.has(localThreadId)) {
|
|
246
|
+
throw new TerminalOpenError("Session fork is still being committed", "terminal_not_live");
|
|
247
|
+
}
|
|
204
248
|
const handle = this.handles.get(localThreadId);
|
|
205
249
|
if (!handle || handle.dead || !this.liveSessionKeys.has(localThreadId)) {
|
|
206
250
|
throw new TerminalOpenError("terminal not live", "terminal_not_live");
|
|
@@ -210,7 +254,50 @@ export class RunnerManager {
|
|
|
210
254
|
openTerminalOnHandle(handle, localThreadId, opts) {
|
|
211
255
|
handle.lastUsedAt = this.now();
|
|
212
256
|
const attachId = randomUUID();
|
|
213
|
-
const
|
|
257
|
+
const inputTracker = {
|
|
258
|
+
buffer: "",
|
|
259
|
+
previousWasCarriageReturn: false,
|
|
260
|
+
bracketedPaste: false,
|
|
261
|
+
pendingEscape: "",
|
|
262
|
+
};
|
|
263
|
+
const terminal = new ManagedTerminal(attachId, (msg) => {
|
|
264
|
+
const handoffs = msg.t === "term.input" && opts.role === "owner"
|
|
265
|
+
? this.beginTerminalInputHandoffs(handle, this.currentTerminalSessionId(handle, localThreadId), trackedTerminalSubmissions(inputTracker, Buffer.from(msg.dataB64, "base64").toString("utf8")))
|
|
266
|
+
: [];
|
|
267
|
+
const send = () => {
|
|
268
|
+
try {
|
|
269
|
+
handle.transport.send(msg);
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
for (const handoff of handoffs) {
|
|
273
|
+
this.finishTerminalInputHandoff(handle, handoff);
|
|
274
|
+
}
|
|
275
|
+
throw error;
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
const sourceReservation = this.sourceForkReservations.get(localThreadId);
|
|
279
|
+
if (msg.t === "term.input" && sourceReservation) {
|
|
280
|
+
void sourceReservation.then(() => {
|
|
281
|
+
if (!handle.dead)
|
|
282
|
+
send();
|
|
283
|
+
}).catch((error) => {
|
|
284
|
+
void this.terminateHandle(handle, `terminal input delivery failed: ${errorMessage(error)}`).catch((terminationError) => {
|
|
285
|
+
logTerminationFailure(handle, terminationError);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
// A terminal request may race a fork reservation. Never let later
|
|
291
|
+
// keystrokes reach an unpublished Provider target.
|
|
292
|
+
if (msg.t === "term.input") {
|
|
293
|
+
const target = this.reservedForkTarget(handle, localThreadId);
|
|
294
|
+
if (target) {
|
|
295
|
+
this.forkBufferedTerminalInputs.get(target)?.push({ handle, message: msg });
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
send();
|
|
300
|
+
}, () => handle.terminals.delete(attachId));
|
|
214
301
|
handle.terminals.set(attachId, terminal);
|
|
215
302
|
handle.transport.send({
|
|
216
303
|
t: "term.open",
|
|
@@ -229,6 +316,8 @@ export class RunnerManager {
|
|
|
229
316
|
}
|
|
230
317
|
/** Whether this process already owns a live runner for the Session. Read-only; never spawns. */
|
|
231
318
|
hasLiveSession(localThreadId) {
|
|
319
|
+
if (this.forkReservations.has(localThreadId))
|
|
320
|
+
return false;
|
|
232
321
|
const handle = this.handles.get(localThreadId);
|
|
233
322
|
return Boolean(handle && !handle.dead && this.liveSessionKeys.has(localThreadId));
|
|
234
323
|
}
|
|
@@ -245,6 +334,152 @@ export class RunnerManager {
|
|
|
245
334
|
onRotate(listener) {
|
|
246
335
|
this.rotateListener = listener;
|
|
247
336
|
}
|
|
337
|
+
/** Accepted TUI submissions that have not crossed into an observable runtime
|
|
338
|
+
* state. The daemon folds this into runningTurns after closing admission. */
|
|
339
|
+
pendingTerminalInputCount() {
|
|
340
|
+
let count = 0;
|
|
341
|
+
for (const handoffs of this.terminalInputHandoffs.values()) {
|
|
342
|
+
count += handoffs.length;
|
|
343
|
+
}
|
|
344
|
+
return count;
|
|
345
|
+
}
|
|
346
|
+
beginTerminalInputHandoffs(handle, sessionId, kinds) {
|
|
347
|
+
if (kinds.length === 0)
|
|
348
|
+
return [];
|
|
349
|
+
const created = [];
|
|
350
|
+
try {
|
|
351
|
+
for (const kind of kinds) {
|
|
352
|
+
const handoff = {
|
|
353
|
+
sessionId,
|
|
354
|
+
kind,
|
|
355
|
+
reservation: this.reserveAdmission(),
|
|
356
|
+
timer: undefined,
|
|
357
|
+
};
|
|
358
|
+
handoff.timer = setTimeout(() => {
|
|
359
|
+
this.expireTerminalInputHandoffs(handle);
|
|
360
|
+
}, this.terminalInputHandoffTimeoutMs);
|
|
361
|
+
handoff.timer.unref?.();
|
|
362
|
+
const handoffs = this.terminalInputHandoffs.get(handle);
|
|
363
|
+
if (handoffs)
|
|
364
|
+
handoffs.push(handoff);
|
|
365
|
+
else
|
|
366
|
+
this.terminalInputHandoffs.set(handle, [handoff]);
|
|
367
|
+
created.push(handoff);
|
|
368
|
+
}
|
|
369
|
+
return created;
|
|
370
|
+
}
|
|
371
|
+
catch (error) {
|
|
372
|
+
for (const handoff of created) {
|
|
373
|
+
this.finishTerminalInputHandoff(handle, handoff);
|
|
374
|
+
}
|
|
375
|
+
throw error;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
settleTerminalInputHandoff(handle, sessionId, kind) {
|
|
379
|
+
const handoff = this.terminalInputHandoffs
|
|
380
|
+
.get(handle)
|
|
381
|
+
?.find((candidate) => candidate.sessionId === sessionId);
|
|
382
|
+
// Runtime observations settle accepted input in submission order. In
|
|
383
|
+
// particular, a response from the source Session while /clear or /fork is
|
|
384
|
+
// still publishing must not skip that rotation and release a later turn
|
|
385
|
+
// which has not yet been rebound or delivered.
|
|
386
|
+
if (handoff?.kind === kind)
|
|
387
|
+
this.finishTerminalInputHandoff(handle, handoff);
|
|
388
|
+
}
|
|
389
|
+
settleTerminalRotationHandoff(handle, sourceSessionId, targetSessionId) {
|
|
390
|
+
const handoffs = this.terminalInputHandoffs.get(handle);
|
|
391
|
+
const rotationIndex = handoffs?.findIndex((handoff) => handoff.sessionId === sourceSessionId && handoff.kind === "rotation") ?? -1;
|
|
392
|
+
if (!handoffs || rotationIndex < 0)
|
|
393
|
+
return;
|
|
394
|
+
// Turns submitted before /clear or /fork are superseded once the rotation
|
|
395
|
+
// is published. Inputs accepted afterwards belong to the transferred pane
|
|
396
|
+
// and must follow it to the target Session.
|
|
397
|
+
const rotation = handoffs[rotationIndex];
|
|
398
|
+
for (const handoff of [...handoffs.slice(0, rotationIndex)]) {
|
|
399
|
+
if (handoff.sessionId === sourceSessionId && handoff.kind === "turn") {
|
|
400
|
+
this.finishTerminalInputHandoff(handle, handoff);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
if (rotation)
|
|
404
|
+
this.finishTerminalInputHandoff(handle, rotation);
|
|
405
|
+
for (const handoff of this.terminalInputHandoffs.get(handle) ?? []) {
|
|
406
|
+
if (handoff.sessionId === sourceSessionId)
|
|
407
|
+
handoff.sessionId = targetSessionId;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
currentTerminalSessionId(handle, fallback) {
|
|
411
|
+
for (const [sessionId, candidate] of this.handles) {
|
|
412
|
+
if (candidate === handle && this.liveSessionKeys.has(sessionId))
|
|
413
|
+
return sessionId;
|
|
414
|
+
}
|
|
415
|
+
return fallback;
|
|
416
|
+
}
|
|
417
|
+
finishTerminalInputHandoff(handle, handoff) {
|
|
418
|
+
if (handoff.timer) {
|
|
419
|
+
clearTimeout(handoff.timer);
|
|
420
|
+
handoff.timer = undefined;
|
|
421
|
+
}
|
|
422
|
+
handoff.reservation?.release();
|
|
423
|
+
handoff.reservation = undefined;
|
|
424
|
+
const handoffs = this.terminalInputHandoffs.get(handle);
|
|
425
|
+
if (!handoffs)
|
|
426
|
+
return;
|
|
427
|
+
const index = handoffs.indexOf(handoff);
|
|
428
|
+
if (index >= 0)
|
|
429
|
+
handoffs.splice(index, 1);
|
|
430
|
+
if (handoffs.length === 0)
|
|
431
|
+
this.terminalInputHandoffs.delete(handle);
|
|
432
|
+
}
|
|
433
|
+
finishAllTerminalInputHandoffs(handle) {
|
|
434
|
+
if (handle.terminalCleanupRetryTimer) {
|
|
435
|
+
clearTimeout(handle.terminalCleanupRetryTimer);
|
|
436
|
+
delete handle.terminalCleanupRetryTimer;
|
|
437
|
+
}
|
|
438
|
+
handle.terminalCleanupRetryFailures = 0;
|
|
439
|
+
for (const handoff of [...(this.terminalInputHandoffs.get(handle) ?? [])]) {
|
|
440
|
+
this.finishTerminalInputHandoff(handle, handoff);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
preserveTerminalInputHandoffsAsActivity(handle) {
|
|
444
|
+
for (const handoff of this.terminalInputHandoffs.get(handle) ?? []) {
|
|
445
|
+
if (handoff.timer) {
|
|
446
|
+
clearTimeout(handoff.timer);
|
|
447
|
+
handoff.timer = undefined;
|
|
448
|
+
}
|
|
449
|
+
handoff.reservation?.release();
|
|
450
|
+
handoff.reservation = undefined;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
scheduleTerminalInputCleanupRetry(handle) {
|
|
454
|
+
if (handle.terminalCleanupRetryTimer ||
|
|
455
|
+
!this.terminalInputHandoffs.has(handle))
|
|
456
|
+
return;
|
|
457
|
+
const exponent = Math.min(handle.terminalCleanupRetryFailures - 1, 10);
|
|
458
|
+
const delay = Math.min(this.terminalInputCleanupRetryMs * (2 ** Math.max(0, exponent)), MAX_TERMINAL_INPUT_CLEANUP_RETRY_MS);
|
|
459
|
+
handle.terminalCleanupRetryTimer = setTimeout(() => {
|
|
460
|
+
delete handle.terminalCleanupRetryTimer;
|
|
461
|
+
if (!this.terminalInputHandoffs.has(handle))
|
|
462
|
+
return;
|
|
463
|
+
void this.terminateHandle(handle, "retrying unproven terminal input cleanup").catch((error) => {
|
|
464
|
+
logTerminationFailure(handle, error);
|
|
465
|
+
});
|
|
466
|
+
}, delay);
|
|
467
|
+
handle.terminalCleanupRetryTimer.unref?.();
|
|
468
|
+
}
|
|
469
|
+
expireTerminalInputHandoffs(handle) {
|
|
470
|
+
const handoffs = this.terminalInputHandoffs.get(handle);
|
|
471
|
+
if (!handoffs?.length)
|
|
472
|
+
return;
|
|
473
|
+
for (const handoff of handoffs) {
|
|
474
|
+
if (handoff.timer) {
|
|
475
|
+
clearTimeout(handoff.timer);
|
|
476
|
+
handoff.timer = undefined;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
void this.terminateHandle(handle, "accepted terminal input did not become observable before maintenance timeout").catch((error) => {
|
|
480
|
+
logTerminationFailure(handle, error);
|
|
481
|
+
});
|
|
482
|
+
}
|
|
248
483
|
/**
|
|
249
484
|
* Eagerly bring up a session's codex-native live view (persistent forwarder +
|
|
250
485
|
* detached `codex --remote` TUI) in its runner child, spawning the runner if
|
|
@@ -252,16 +487,38 @@ export class RunnerManager {
|
|
|
252
487
|
* non-codex / non-live session (the caller then uses the normal run path).
|
|
253
488
|
*/
|
|
254
489
|
ensureLiveSession(localThreadId, opts) {
|
|
255
|
-
|
|
490
|
+
const admission = this.reserveAdmission();
|
|
491
|
+
return this.requestLiveSession(localThreadId, opts, true)
|
|
492
|
+
.finally(() => admission.release());
|
|
256
493
|
}
|
|
257
494
|
/** Start the Provider pane without waiting for login/onboarding to create a
|
|
258
495
|
* native thread. This is the setup-terminal gate; callers may attach as soon
|
|
259
496
|
* as it resolves, while normal message delivery still uses ensureLiveSession. */
|
|
260
497
|
startLiveSession(localThreadId, opts) {
|
|
261
|
-
|
|
498
|
+
const admission = this.reserveAdmission();
|
|
499
|
+
return this.requestLiveSession(localThreadId, opts, false)
|
|
500
|
+
.finally(() => admission.release());
|
|
262
501
|
}
|
|
263
|
-
requestLiveSession(localThreadId, opts, waitForReady) {
|
|
264
|
-
const
|
|
502
|
+
requestLiveSession(localThreadId, opts, waitForReady, allowReservedForkTarget = false) {
|
|
503
|
+
const sourceReservation = allowReservedForkTarget
|
|
504
|
+
? undefined
|
|
505
|
+
: this.sourceForkReservations.get(localThreadId);
|
|
506
|
+
if (sourceReservation) {
|
|
507
|
+
return sourceReservation.then(() => this.requestLiveSession(localThreadId, opts, waitForReady));
|
|
508
|
+
}
|
|
509
|
+
this.liveOptions.set(localThreadId, {
|
|
510
|
+
workspace: structuredClone(opts.workspace),
|
|
511
|
+
execution: structuredClone(opts.execution),
|
|
512
|
+
...(opts.cols ? { cols: opts.cols } : {}),
|
|
513
|
+
...(opts.rows ? { rows: opts.rows } : {}),
|
|
514
|
+
});
|
|
515
|
+
const reservation = allowReservedForkTarget
|
|
516
|
+
? undefined
|
|
517
|
+
: this.forkReservations.get(localThreadId);
|
|
518
|
+
if (reservation) {
|
|
519
|
+
return reservation.then(() => this.requestLiveSession(localThreadId, opts, waitForReady));
|
|
520
|
+
}
|
|
521
|
+
const handle = this.getOrSpawn(localThreadId, allowReservedForkTarget);
|
|
265
522
|
handle.lastUsedAt = this.now();
|
|
266
523
|
this.liveSessionKeys.add(localThreadId);
|
|
267
524
|
const reqId = randomUUID();
|
|
@@ -270,39 +527,60 @@ export class RunnerManager {
|
|
|
270
527
|
const timeout = setTimeout(() => {
|
|
271
528
|
if (!handle.live.delete(reqId))
|
|
272
529
|
return;
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
530
|
+
const finishTimeout = () => {
|
|
531
|
+
const reason = `runner did not acknowledge ${waitForReady ? "live readiness" : "terminal start"} within ${timeoutMs}ms`;
|
|
532
|
+
this.liveErrors.set(localThreadId, reason);
|
|
533
|
+
this.liveSessionKeys.delete(localThreadId);
|
|
534
|
+
// A child that cannot answer a bounded control round-trip is unsafe
|
|
535
|
+
// to reuse. Reap it so the next click gets a fresh runner.
|
|
536
|
+
void this.terminateHandle(handle, reason).catch((error) => {
|
|
537
|
+
logTerminationFailure(handle, error);
|
|
538
|
+
});
|
|
539
|
+
return false;
|
|
540
|
+
};
|
|
541
|
+
const reservation = allowReservedForkTarget
|
|
542
|
+
? undefined
|
|
543
|
+
: this.forkReservations.get(localThreadId);
|
|
544
|
+
if (reservation) {
|
|
545
|
+
void reservation
|
|
546
|
+
.then(() => finishTimeout())
|
|
547
|
+
.then(resolve, () => resolve(false));
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
resolve(finishTimeout());
|
|
282
551
|
}, timeoutMs);
|
|
283
552
|
timeout.unref?.();
|
|
284
553
|
handle.live.set(reqId, (res) => {
|
|
285
554
|
clearTimeout(timeout);
|
|
286
555
|
const ok = res.ok ?? false;
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
556
|
+
const finish = () => {
|
|
557
|
+
if (ok) {
|
|
558
|
+
this.liveErrors.delete(localThreadId);
|
|
559
|
+
}
|
|
560
|
+
else {
|
|
561
|
+
this.liveErrors.set(localThreadId, res.error ?? "live session did not become ready");
|
|
562
|
+
}
|
|
563
|
+
return ok;
|
|
564
|
+
};
|
|
565
|
+
const reservation = allowReservedForkTarget
|
|
566
|
+
? undefined
|
|
567
|
+
: this.forkReservations.get(localThreadId);
|
|
568
|
+
if (reservation) {
|
|
569
|
+
void reservation
|
|
570
|
+
.then(() => finish())
|
|
571
|
+
.then(resolve, () => resolve(false));
|
|
572
|
+
return;
|
|
292
573
|
}
|
|
293
|
-
resolve(
|
|
574
|
+
resolve(finish());
|
|
294
575
|
});
|
|
295
576
|
handle.transport.send({
|
|
296
577
|
t: "live.ensure",
|
|
297
578
|
reqId,
|
|
298
579
|
localThreadId,
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
...(opts
|
|
302
|
-
...(opts
|
|
303
|
-
...(opts?.reasoningEffort ? { reasoningEffort: opts.reasoningEffort } : {}),
|
|
304
|
-
...(opts?.agentName ? { agentName: opts.agentName } : {}),
|
|
305
|
-
...(opts?.agentSpec ? { agentSpec: opts.agentSpec } : {}),
|
|
580
|
+
workspace: opts.workspace,
|
|
581
|
+
execution: opts.execution,
|
|
582
|
+
...(opts.cols ? { cols: opts.cols } : {}),
|
|
583
|
+
...(opts.rows ? { rows: opts.rows } : {}),
|
|
306
584
|
waitForReady,
|
|
307
585
|
});
|
|
308
586
|
});
|
|
@@ -317,11 +595,43 @@ export class RunnerManager {
|
|
|
317
595
|
* the session has no live forwarder (caller falls back to the run path).
|
|
318
596
|
*/
|
|
319
597
|
injectMessage(localThreadId, input) {
|
|
598
|
+
const admission = this.reserveAdmission();
|
|
599
|
+
return this.injectMessageAdmitted(localThreadId, input)
|
|
600
|
+
.finally(() => admission.release());
|
|
601
|
+
}
|
|
602
|
+
injectMessageAdmitted(localThreadId, input) {
|
|
603
|
+
const sourceReservation = this.sourceForkReservations.get(localThreadId);
|
|
604
|
+
if (sourceReservation) {
|
|
605
|
+
return sourceReservation.then(() => this.injectMessageAdmitted(localThreadId, input));
|
|
606
|
+
}
|
|
607
|
+
const reservation = this.forkReservations.get(localThreadId);
|
|
608
|
+
if (reservation) {
|
|
609
|
+
return reservation.then(() => this.injectMessageAdmitted(localThreadId, input));
|
|
610
|
+
}
|
|
320
611
|
const handle = this.getOrSpawn(localThreadId);
|
|
321
612
|
handle.lastUsedAt = this.now();
|
|
322
613
|
const reqId = randomUUID();
|
|
323
614
|
return new Promise((resolve) => {
|
|
324
|
-
handle.live.set(reqId, (res) =>
|
|
615
|
+
handle.live.set(reqId, (res) => {
|
|
616
|
+
const outcome = res.outcome ?? "failed";
|
|
617
|
+
const finish = () => {
|
|
618
|
+
if (outcome === "injected") {
|
|
619
|
+
this.liveErrors.delete(localThreadId);
|
|
620
|
+
}
|
|
621
|
+
else {
|
|
622
|
+
this.liveErrors.set(localThreadId, res.error ?? `live injection ${outcome}`);
|
|
623
|
+
}
|
|
624
|
+
return outcome;
|
|
625
|
+
};
|
|
626
|
+
const reservation = this.forkReservations.get(localThreadId);
|
|
627
|
+
if (reservation) {
|
|
628
|
+
void reservation
|
|
629
|
+
.then(() => finish())
|
|
630
|
+
.then(resolve, () => resolve("failed"));
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
resolve(finish());
|
|
634
|
+
});
|
|
325
635
|
handle.transport.send(typeof input === "string"
|
|
326
636
|
? { t: "inject", reqId, localThreadId, text: input }
|
|
327
637
|
: { t: "inject", reqId, localThreadId, input });
|
|
@@ -339,7 +649,16 @@ export class RunnerManager {
|
|
|
339
649
|
handle.lastUsedAt = this.now();
|
|
340
650
|
const reqId = randomUUID();
|
|
341
651
|
return new Promise((resolve) => {
|
|
342
|
-
|
|
652
|
+
const timeout = setTimeout(() => {
|
|
653
|
+
if (!handle.live.delete(reqId))
|
|
654
|
+
return;
|
|
655
|
+
resolve(false);
|
|
656
|
+
}, this.liveInterruptTimeoutMs);
|
|
657
|
+
timeout.unref?.();
|
|
658
|
+
handle.live.set(reqId, (res) => {
|
|
659
|
+
clearTimeout(timeout);
|
|
660
|
+
resolve(res.ok ?? false);
|
|
661
|
+
});
|
|
343
662
|
handle.transport.send({ t: "live.interrupt", reqId, localThreadId });
|
|
344
663
|
});
|
|
345
664
|
}
|
|
@@ -382,9 +701,83 @@ export class RunnerManager {
|
|
|
382
701
|
clearGoal(localThreadId) {
|
|
383
702
|
return this.forwardCap("clearGoal", [localThreadId], localThreadId);
|
|
384
703
|
}
|
|
385
|
-
forkSession(currentLocalThreadId, newLocalThreadId) {
|
|
386
|
-
|
|
387
|
-
|
|
704
|
+
forkSession(currentLocalThreadId, newLocalThreadId, options) {
|
|
705
|
+
this.assertAdmissionOpen();
|
|
706
|
+
if (currentLocalThreadId === newLocalThreadId) {
|
|
707
|
+
return Promise.resolve({
|
|
708
|
+
ok: false,
|
|
709
|
+
message: "source and target Session must differ",
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
const inflight = this.forkOperations.get(newLocalThreadId);
|
|
713
|
+
if (inflight) {
|
|
714
|
+
if (inflight.sourceSessionId === currentLocalThreadId) {
|
|
715
|
+
return inflight.operation.then(capabilityForkResult);
|
|
716
|
+
}
|
|
717
|
+
return Promise.resolve({
|
|
718
|
+
ok: false,
|
|
719
|
+
message: "target Session already has a fork in progress",
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
if (this.sourceForkReservations.has(currentLocalThreadId)) {
|
|
723
|
+
return Promise.resolve({
|
|
724
|
+
ok: false,
|
|
725
|
+
message: "source Session already has a fork in progress",
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
let releaseReservation;
|
|
729
|
+
const reservation = new Promise((resolve) => {
|
|
730
|
+
releaseReservation = resolve;
|
|
731
|
+
});
|
|
732
|
+
let releaseSource;
|
|
733
|
+
const sourceReservation = new Promise((resolve) => {
|
|
734
|
+
releaseSource = resolve;
|
|
735
|
+
});
|
|
736
|
+
this.forkReservations.set(newLocalThreadId, reservation);
|
|
737
|
+
this.sourceForkReservations.set(currentLocalThreadId, sourceReservation);
|
|
738
|
+
this.forkBufferedMessages.set(newLocalThreadId, []);
|
|
739
|
+
this.forkBufferedTerminalInputs.set(newLocalThreadId, []);
|
|
740
|
+
const retainedLiveOptions = this.liveOptions.get(newLocalThreadId);
|
|
741
|
+
const operation = (async () => {
|
|
742
|
+
await options.beforeProviderFork();
|
|
743
|
+
return this.performManagedFork(currentLocalThreadId, newLocalThreadId, options);
|
|
744
|
+
})()
|
|
745
|
+
.finally(() => {
|
|
746
|
+
const current = this.forkOperations.get(newLocalThreadId);
|
|
747
|
+
if (current?.operation === operation) {
|
|
748
|
+
this.forkOperations.delete(newLocalThreadId);
|
|
749
|
+
}
|
|
750
|
+
if (this.forkReservations.get(newLocalThreadId) === reservation) {
|
|
751
|
+
this.forkReservations.delete(newLocalThreadId);
|
|
752
|
+
}
|
|
753
|
+
if (this.sourceForkReservations.get(currentLocalThreadId) === sourceReservation) {
|
|
754
|
+
this.sourceForkReservations.delete(currentLocalThreadId);
|
|
755
|
+
}
|
|
756
|
+
const buffered = this.forkBufferedMessages.get(newLocalThreadId) ?? [];
|
|
757
|
+
this.forkBufferedMessages.delete(newLocalThreadId);
|
|
758
|
+
const bufferedTerminalInputs = this.forkBufferedTerminalInputs.get(newLocalThreadId) ?? [];
|
|
759
|
+
this.forkBufferedTerminalInputs.delete(newLocalThreadId);
|
|
760
|
+
for (const entry of buffered) {
|
|
761
|
+
this.deliverForkBufferedMessage(entry.handle, entry.message);
|
|
762
|
+
}
|
|
763
|
+
for (const entry of bufferedTerminalInputs) {
|
|
764
|
+
if (!entry.handle.dead)
|
|
765
|
+
entry.handle.transport.send(entry.message);
|
|
766
|
+
}
|
|
767
|
+
releaseReservation();
|
|
768
|
+
releaseSource();
|
|
769
|
+
queueMicrotask(() => {
|
|
770
|
+
if (this.liveOptions.get(newLocalThreadId) === retainedLiveOptions
|
|
771
|
+
&& !this.handles.has(newLocalThreadId)) {
|
|
772
|
+
this.liveOptions.delete(newLocalThreadId);
|
|
773
|
+
}
|
|
774
|
+
});
|
|
775
|
+
});
|
|
776
|
+
this.forkOperations.set(newLocalThreadId, {
|
|
777
|
+
sourceSessionId: currentLocalThreadId,
|
|
778
|
+
operation,
|
|
779
|
+
});
|
|
780
|
+
return operation.then(capabilityForkResult);
|
|
388
781
|
}
|
|
389
782
|
/**
|
|
390
783
|
* Backend-free runtime readiness (not part of `AgentCapabilities`; surfaced for
|
|
@@ -395,7 +788,7 @@ export class RunnerManager {
|
|
|
395
788
|
}
|
|
396
789
|
/** Stop the runner bound to one session (if any), rejecting its in-flight work. */
|
|
397
790
|
stopRunner(localThreadId) {
|
|
398
|
-
const handle = this.
|
|
791
|
+
const handle = this.handleForCleanup(localThreadId);
|
|
399
792
|
if (!handle) {
|
|
400
793
|
return;
|
|
401
794
|
}
|
|
@@ -403,6 +796,15 @@ export class RunnerManager {
|
|
|
403
796
|
logTerminationFailure(handle, error);
|
|
404
797
|
});
|
|
405
798
|
}
|
|
799
|
+
/** Force-stop and join the runner bound to one Session. Unlike stopRunner,
|
|
800
|
+
* completion proves that the child process has exited. */
|
|
801
|
+
async terminateLiveSession(localThreadId) {
|
|
802
|
+
const handle = this.handleForCleanup(localThreadId);
|
|
803
|
+
if (!handle)
|
|
804
|
+
return false;
|
|
805
|
+
await this.terminateHandle(handle, "runner force-stopped");
|
|
806
|
+
return true;
|
|
807
|
+
}
|
|
406
808
|
/** Stop every runner and join all child exits. Idempotent across concurrent calls. */
|
|
407
809
|
stop() {
|
|
408
810
|
if (this.stopPromise)
|
|
@@ -411,12 +813,16 @@ export class RunnerManager {
|
|
|
411
813
|
if (this.reapTimer) {
|
|
412
814
|
clearInterval(this.reapTimer);
|
|
413
815
|
}
|
|
414
|
-
const children = [
|
|
415
|
-
|
|
416
|
-
|
|
816
|
+
const children = new Set([
|
|
817
|
+
...this.childHandles,
|
|
818
|
+
...this.terminalInputHandoffs.keys(),
|
|
819
|
+
]);
|
|
820
|
+
const attempt = (async () => {
|
|
821
|
+
const results = await Promise.allSettled([...children].map((handle) => this.terminateHandle(handle, "runner manager stopped")));
|
|
417
822
|
this.handles.clear();
|
|
418
823
|
this.liveSessionKeys.clear();
|
|
419
824
|
this.liveErrors.clear();
|
|
825
|
+
this.liveOptions.clear();
|
|
420
826
|
this.mirrorListener = null;
|
|
421
827
|
this.rotateListener = null;
|
|
422
828
|
const errors = results
|
|
@@ -427,9 +833,19 @@ export class RunnerManager {
|
|
|
427
833
|
}
|
|
428
834
|
this.childHandles.clear();
|
|
429
835
|
})();
|
|
430
|
-
|
|
836
|
+
this.stopPromise = attempt;
|
|
837
|
+
void attempt.catch(() => {
|
|
838
|
+
if (this.stopPromise === attempt)
|
|
839
|
+
this.stopPromise = undefined;
|
|
840
|
+
});
|
|
841
|
+
return attempt;
|
|
431
842
|
}
|
|
432
843
|
// ── internals ──────────────────────────────────────────────────────────────
|
|
844
|
+
handleForCleanup(localThreadId) {
|
|
845
|
+
return this.handles.get(localThreadId) ??
|
|
846
|
+
[...this.terminalInputHandoffs].find(([handle, handoffs]) => handle.key === localThreadId ||
|
|
847
|
+
handoffs.some((handoff) => handoff.sessionId === localThreadId))?.[0];
|
|
848
|
+
}
|
|
433
849
|
/** Forward a capability to a runner child. Session-less caps (listModels/status)
|
|
434
850
|
* use the shared `CAP_KEY` child; per-thread caps pass the session's key so they
|
|
435
851
|
* run on that session's child (which owns its private CODEX_HOME). */
|
|
@@ -442,19 +858,255 @@ export class RunnerManager {
|
|
|
442
858
|
handle.transport.send({ t: "cap", capId, name, args });
|
|
443
859
|
});
|
|
444
860
|
}
|
|
445
|
-
getOrSpawn(key) {
|
|
446
|
-
|
|
447
|
-
|
|
861
|
+
getOrSpawn(key, allowReservedForkTarget = false) {
|
|
862
|
+
const admission = this.reserveAdmission();
|
|
863
|
+
try {
|
|
864
|
+
if (this.stopping) {
|
|
865
|
+
throw new AgentRuntimeError("runner manager is stopped", 503, "runner_stopped");
|
|
866
|
+
}
|
|
867
|
+
if (key !== CAP_KEY &&
|
|
868
|
+
this.forkReservations.has(key) &&
|
|
869
|
+
!allowReservedForkTarget) {
|
|
870
|
+
throw new AgentRuntimeError("Session fork is still being committed", 409, "session_fork_pending");
|
|
871
|
+
}
|
|
872
|
+
this.reapIdle();
|
|
873
|
+
const existing = this.handles.get(key);
|
|
874
|
+
if (existing && !existing.dead) {
|
|
875
|
+
existing.lastUsedAt = this.now();
|
|
876
|
+
return existing;
|
|
877
|
+
}
|
|
878
|
+
const handle = this.spawnHandle(key);
|
|
879
|
+
this.handles.set(key, handle);
|
|
880
|
+
return handle;
|
|
448
881
|
}
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
if (existing && !existing.dead) {
|
|
452
|
-
existing.lastUsedAt = this.now();
|
|
453
|
-
return existing;
|
|
882
|
+
finally {
|
|
883
|
+
admission.release();
|
|
454
884
|
}
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
885
|
+
}
|
|
886
|
+
assertAdmissionOpen() {
|
|
887
|
+
const reservation = this.admissionReserve?.();
|
|
888
|
+
if (reservation) {
|
|
889
|
+
reservation.release();
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
if (!this.admissionReserve && this.admissionOpen())
|
|
893
|
+
return;
|
|
894
|
+
throw new AgentRuntimeError("daemon maintenance is in progress", 503, "daemon_maintenance");
|
|
895
|
+
}
|
|
896
|
+
reserveAdmission() {
|
|
897
|
+
const reservation = this.admissionReserve?.();
|
|
898
|
+
if (reservation)
|
|
899
|
+
return reservation;
|
|
900
|
+
if (!this.admissionReserve && this.admissionOpen()) {
|
|
901
|
+
return { release() { } };
|
|
902
|
+
}
|
|
903
|
+
throw new AgentRuntimeError("daemon maintenance is in progress", 503, "daemon_maintenance");
|
|
904
|
+
}
|
|
905
|
+
async performManagedFork(currentLocalThreadId, newLocalThreadId, options) {
|
|
906
|
+
const existingTarget = await this.sessionStore.get(newLocalThreadId);
|
|
907
|
+
if (existingTarget?.parentSessionId === currentLocalThreadId) {
|
|
908
|
+
return { ok: true, data: undefined };
|
|
909
|
+
}
|
|
910
|
+
if (existingTarget?.parentSessionId) {
|
|
911
|
+
return {
|
|
912
|
+
ok: false,
|
|
913
|
+
reason: "error",
|
|
914
|
+
message: "target Session already belongs to a different fork",
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
const targetHandle = this.handles.get(newLocalThreadId);
|
|
918
|
+
if (existingTarget) {
|
|
919
|
+
return {
|
|
920
|
+
ok: false,
|
|
921
|
+
reason: "error",
|
|
922
|
+
message: "target Session already has a Provider binding",
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
if (targetHandle && !targetHandle.dead) {
|
|
926
|
+
return {
|
|
927
|
+
ok: false,
|
|
928
|
+
reason: "error",
|
|
929
|
+
message: "target Session already has a runner",
|
|
930
|
+
};
|
|
931
|
+
}
|
|
932
|
+
if (options.execution.provider === "claude") {
|
|
933
|
+
return this.performClaudeManagedFork(currentLocalThreadId, newLocalThreadId, options);
|
|
934
|
+
}
|
|
935
|
+
// Runs on the SOURCE session's child (its home has the source rollout to
|
|
936
|
+
// fork). The Provider persists the native fork and target binding before
|
|
937
|
+
// returning. Only then is the target reservation released.
|
|
938
|
+
return this.forwardCap("forkSession", [
|
|
939
|
+
currentLocalThreadId,
|
|
940
|
+
newLocalThreadId,
|
|
941
|
+
{
|
|
942
|
+
workspace: structuredClone(options.workspace),
|
|
943
|
+
execution: structuredClone(options.execution),
|
|
944
|
+
},
|
|
945
|
+
], currentLocalThreadId);
|
|
946
|
+
}
|
|
947
|
+
async performClaudeManagedFork(currentLocalThreadId, newLocalThreadId, options) {
|
|
948
|
+
const source = await this.sessionStore.get(currentLocalThreadId);
|
|
949
|
+
const setIntent = this.sessionStore.setClaudeForkIntent?.bind(this.sessionStore);
|
|
950
|
+
const deleteIntent = this.sessionStore.deleteClaudeForkIntent?.bind(this.sessionStore);
|
|
951
|
+
if (!source?.codexSessionId || !setIntent || !deleteIntent) {
|
|
952
|
+
return {
|
|
953
|
+
ok: false,
|
|
954
|
+
reason: "unsupported",
|
|
955
|
+
message: "Claude fork persistence is unavailable",
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
const targetClaudeSessionId = randomUUID();
|
|
959
|
+
try {
|
|
960
|
+
await setIntent({
|
|
961
|
+
targetSessionId: newLocalThreadId,
|
|
962
|
+
sourceSessionId: currentLocalThreadId,
|
|
963
|
+
sourceClaudeSessionId: source.codexSessionId,
|
|
964
|
+
targetClaudeSessionId,
|
|
965
|
+
updatedAt: new Date().toISOString(),
|
|
966
|
+
});
|
|
967
|
+
const ready = await this.requestLiveSession(newLocalThreadId, {
|
|
968
|
+
workspace: structuredClone(options.workspace),
|
|
969
|
+
execution: structuredClone(options.execution),
|
|
970
|
+
}, true, true);
|
|
971
|
+
const target = ready ? await this.sessionStore.get(newLocalThreadId) : null;
|
|
972
|
+
if (!target ||
|
|
973
|
+
target.parentSessionId !== currentLocalThreadId ||
|
|
974
|
+
target.codexSessionId !== targetClaudeSessionId) {
|
|
975
|
+
throw new Error(this.lastLiveSessionError(newLocalThreadId) ??
|
|
976
|
+
"Claude did not materialize the requested fork");
|
|
977
|
+
}
|
|
978
|
+
return { ok: true, data: undefined };
|
|
979
|
+
}
|
|
980
|
+
catch (error) {
|
|
981
|
+
await deleteIntent(newLocalThreadId).catch(() => undefined);
|
|
982
|
+
const target = await this.sessionStore.get(newLocalThreadId).catch(() => null);
|
|
983
|
+
if (target?.parentSessionId === currentLocalThreadId) {
|
|
984
|
+
await this.sessionStore.delete(newLocalThreadId).catch(() => undefined);
|
|
985
|
+
}
|
|
986
|
+
const handle = this.handles.get(newLocalThreadId);
|
|
987
|
+
if (handle && !handle.dead) {
|
|
988
|
+
await this.terminateHandle(handle, "Claude fork materialization failed").catch(() => undefined);
|
|
989
|
+
}
|
|
990
|
+
return {
|
|
991
|
+
ok: false,
|
|
992
|
+
reason: "error",
|
|
993
|
+
message: error instanceof Error ? error.message : String(error),
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
bufferForkTargetMessage(handle, message) {
|
|
998
|
+
const directTarget = message.t === "mirror" ? message.sessionId : message.from;
|
|
999
|
+
const target = this.reservedForkTarget(handle, directTarget);
|
|
1000
|
+
if (!target)
|
|
1001
|
+
return false;
|
|
1002
|
+
this.forkBufferedMessages.get(target)?.push({ handle, message });
|
|
1003
|
+
return true;
|
|
1004
|
+
}
|
|
1005
|
+
reservedForkTarget(handle, directTarget) {
|
|
1006
|
+
return this.forkReservations.has(directTarget)
|
|
1007
|
+
? directTarget
|
|
1008
|
+
: [...this.forkReservations.keys()].find((candidate) => this.handles.get(candidate) === handle);
|
|
1009
|
+
}
|
|
1010
|
+
deliverForkBufferedMessage(handle, message) {
|
|
1011
|
+
if (handle.dead)
|
|
1012
|
+
return;
|
|
1013
|
+
if (message.t === "mirror") {
|
|
1014
|
+
this.mirrorListener?.(message.sessionId, message.event);
|
|
1015
|
+
if (message.event.type === "response.created") {
|
|
1016
|
+
this.settleTerminalInputHandoff(handle, message.sessionId, "turn");
|
|
1017
|
+
}
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
this.deliverRotateMessage(handle, message);
|
|
1021
|
+
}
|
|
1022
|
+
deliverRotateMessage(handle, message) {
|
|
1023
|
+
if (this.forkReservations.has(message.to) ||
|
|
1024
|
+
this.sourceForkReservations.has(message.from)) {
|
|
1025
|
+
void this.terminateHandle(handle, "conflicting Session rotation").catch((error) => logTerminationFailure(handle, error));
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
try {
|
|
1029
|
+
// Rotate the runtime-local context before publishing the new Session
|
|
1030
|
+
// alias to routing or observers. This prevents the new identity from
|
|
1031
|
+
// briefly inheriting stale context after `/clear` or `/fork`.
|
|
1032
|
+
handle.sessionContext?.rotate(message.to);
|
|
1033
|
+
}
|
|
1034
|
+
catch (error) {
|
|
1035
|
+
const reason = `runner Session context rotation failed: ${errorMessage(error)}`;
|
|
1036
|
+
void this.terminateHandle(handle, reason).catch((terminationError) => {
|
|
1037
|
+
logTerminationFailure(handle, terminationError);
|
|
1038
|
+
});
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
let releaseTarget;
|
|
1042
|
+
const targetReservation = new Promise((resolve) => {
|
|
1043
|
+
releaseTarget = resolve;
|
|
1044
|
+
});
|
|
1045
|
+
let releaseSource;
|
|
1046
|
+
const sourceReservation = new Promise((resolve) => {
|
|
1047
|
+
releaseSource = resolve;
|
|
1048
|
+
});
|
|
1049
|
+
this.forkReservations.set(message.to, targetReservation);
|
|
1050
|
+
this.sourceForkReservations.set(message.from, sourceReservation);
|
|
1051
|
+
this.forkBufferedMessages.set(message.to, []);
|
|
1052
|
+
this.forkBufferedTerminalInputs.set(message.to, []);
|
|
1053
|
+
// The physical pane has already rotated. Remove every old routing alias now:
|
|
1054
|
+
// a later request for the source must spawn a fresh runner that resumes the
|
|
1055
|
+
// source Provider binding, while existing terminal attachments continue to
|
|
1056
|
+
// follow the transferred pane.
|
|
1057
|
+
for (const [key, candidate] of this.handles) {
|
|
1058
|
+
if (candidate !== handle)
|
|
1059
|
+
continue;
|
|
1060
|
+
this.handles.delete(key);
|
|
1061
|
+
this.liveSessionKeys.delete(key);
|
|
1062
|
+
this.liveOptions.delete(key);
|
|
1063
|
+
}
|
|
1064
|
+
const rotation = {
|
|
1065
|
+
from: message.from,
|
|
1066
|
+
to: message.to,
|
|
1067
|
+
kind: message.kind,
|
|
1068
|
+
workspace: message.workspace,
|
|
1069
|
+
execution: message.execution,
|
|
1070
|
+
...(message.parentSessionId ? { parentSessionId: message.parentSessionId } : {}),
|
|
1071
|
+
};
|
|
1072
|
+
void Promise.resolve(this.rotateListener?.(rotation))
|
|
1073
|
+
.then(() => {
|
|
1074
|
+
// The server's rotation listener resolves only after the target Session
|
|
1075
|
+
// is published. Release the terminal admission handoff afterwards so a
|
|
1076
|
+
// maintenance snapshot cannot observe a gap with neither source work
|
|
1077
|
+
// nor the target Session.
|
|
1078
|
+
this.settleTerminalRotationHandoff(handle, message.from, message.to);
|
|
1079
|
+
if (handle.dead)
|
|
1080
|
+
return;
|
|
1081
|
+
this.handles.set(message.to, handle);
|
|
1082
|
+
this.liveSessionKeys.add(message.to);
|
|
1083
|
+
this.liveOptions.set(message.to, {
|
|
1084
|
+
workspace: structuredClone(message.workspace),
|
|
1085
|
+
execution: structuredClone(message.execution),
|
|
1086
|
+
});
|
|
1087
|
+
for (const entry of this.forkBufferedMessages.get(message.to) ?? []) {
|
|
1088
|
+
this.deliverForkBufferedMessage(entry.handle, entry.message);
|
|
1089
|
+
}
|
|
1090
|
+
for (const entry of this.forkBufferedTerminalInputs.get(message.to) ?? []) {
|
|
1091
|
+
if (!entry.handle.dead)
|
|
1092
|
+
entry.handle.transport.send(entry.message);
|
|
1093
|
+
}
|
|
1094
|
+
})
|
|
1095
|
+
.catch((error) => {
|
|
1096
|
+
void this.terminateHandle(handle, `Session rotation publication failed: ${errorMessage(error)}`).catch((terminationError) => logTerminationFailure(handle, terminationError));
|
|
1097
|
+
})
|
|
1098
|
+
.finally(() => {
|
|
1099
|
+
if (this.forkReservations.get(message.to) === targetReservation) {
|
|
1100
|
+
this.forkReservations.delete(message.to);
|
|
1101
|
+
}
|
|
1102
|
+
if (this.sourceForkReservations.get(message.from) === sourceReservation) {
|
|
1103
|
+
this.sourceForkReservations.delete(message.from);
|
|
1104
|
+
}
|
|
1105
|
+
this.forkBufferedMessages.delete(message.to);
|
|
1106
|
+
this.forkBufferedTerminalInputs.delete(message.to);
|
|
1107
|
+
releaseTarget();
|
|
1108
|
+
releaseSource();
|
|
1109
|
+
});
|
|
458
1110
|
}
|
|
459
1111
|
spawnHandle(key) {
|
|
460
1112
|
const args = this.runnerEntry.endsWith(".ts")
|
|
@@ -463,10 +1115,12 @@ export class RunnerManager {
|
|
|
463
1115
|
const sessionContext = key === CAP_KEY
|
|
464
1116
|
? undefined
|
|
465
1117
|
: this.openSessionContext(key);
|
|
1118
|
+
const processGroup = process.platform !== "win32";
|
|
466
1119
|
let child;
|
|
467
1120
|
try {
|
|
468
1121
|
child = this.spawn(process.execPath, args, {
|
|
469
1122
|
stdio: ["pipe", "pipe", "pipe"],
|
|
1123
|
+
detached: processGroup,
|
|
470
1124
|
// `RYNX_RUNNER_SESSION` = the child's session key (localThreadId, or `__cap__`
|
|
471
1125
|
// for the shared capability child) so its LocalAgentHost scopes the private
|
|
472
1126
|
// CODEX_HOME to this session. It is deliberately applied last.
|
|
@@ -492,6 +1146,8 @@ export class RunnerManager {
|
|
|
492
1146
|
lastUsedAt: this.now(),
|
|
493
1147
|
dead: false,
|
|
494
1148
|
completion,
|
|
1149
|
+
terminalCleanupRetryFailures: 0,
|
|
1150
|
+
processGroup,
|
|
495
1151
|
...(sessionContext ? { sessionContext } : {}),
|
|
496
1152
|
caps: new Map(),
|
|
497
1153
|
terminals: new Map(),
|
|
@@ -550,37 +1206,24 @@ export class RunnerManager {
|
|
|
550
1206
|
return;
|
|
551
1207
|
}
|
|
552
1208
|
case "mirror":
|
|
1209
|
+
if (handle.dead)
|
|
1210
|
+
return;
|
|
1211
|
+
if (this.bufferForkTargetMessage(handle, msg))
|
|
1212
|
+
return;
|
|
553
1213
|
this.mirrorListener?.(msg.sessionId, msg.event);
|
|
1214
|
+
if (msg.event.type === "response.created") {
|
|
1215
|
+
// The listener projects response.created into SessionRuntimeIndex
|
|
1216
|
+
// synchronously. Only then may the accepted terminal reservation
|
|
1217
|
+
// drain into a maintenance activity snapshot.
|
|
1218
|
+
this.settleTerminalInputHandoff(handle, msg.sessionId, "turn");
|
|
1219
|
+
}
|
|
554
1220
|
return;
|
|
555
1221
|
case "rotate": {
|
|
556
1222
|
if (handle.dead)
|
|
557
1223
|
return;
|
|
558
|
-
|
|
559
|
-
// Rotate the runtime-local context before publishing the new Session
|
|
560
|
-
// alias to routing or observers. This prevents the new identity from
|
|
561
|
-
// briefly inheriting stale context after `/clear` or `/fork`.
|
|
562
|
-
handle.sessionContext?.rotate(msg.to);
|
|
563
|
-
}
|
|
564
|
-
catch (error) {
|
|
565
|
-
const reason = `runner Session context rotation failed: ${errorMessage(error)}`;
|
|
566
|
-
void this.terminateHandle(handle, reason).catch((terminationError) => {
|
|
567
|
-
logTerminationFailure(handle, terminationError);
|
|
568
|
-
});
|
|
1224
|
+
if (this.bufferForkTargetMessage(handle, msg))
|
|
569
1225
|
return;
|
|
570
|
-
|
|
571
|
-
// Terminal transfer: alias the new session to THIS runner so its injection
|
|
572
|
-
// (and live/approval) route to the same child that still owns the pane.
|
|
573
|
-
this.handles.set(msg.to, handle);
|
|
574
|
-
this.liveSessionKeys.add(msg.to);
|
|
575
|
-
this.rotateListener?.({
|
|
576
|
-
from: msg.from,
|
|
577
|
-
to: msg.to,
|
|
578
|
-
kind: msg.kind,
|
|
579
|
-
...(msg.agent ? { agent: msg.agent } : {}),
|
|
580
|
-
...(msg.model ? { model: msg.model } : {}),
|
|
581
|
-
...(msg.cwd ? { cwd: msg.cwd } : {}),
|
|
582
|
-
...(msg.parentSessionId ? { parentSessionId: msg.parentSessionId } : {}),
|
|
583
|
-
});
|
|
1226
|
+
this.deliverRotateMessage(handle, msg);
|
|
584
1227
|
return;
|
|
585
1228
|
}
|
|
586
1229
|
case "live.ready":
|
|
@@ -614,6 +1257,9 @@ export class RunnerManager {
|
|
|
614
1257
|
if (h === handle) {
|
|
615
1258
|
this.handles.delete(key);
|
|
616
1259
|
this.liveSessionKeys.delete(key);
|
|
1260
|
+
if (!this.forkReservations.has(key)) {
|
|
1261
|
+
this.liveOptions.delete(key);
|
|
1262
|
+
}
|
|
617
1263
|
}
|
|
618
1264
|
}
|
|
619
1265
|
const tail = handle.stderr.join("\n");
|
|
@@ -636,7 +1282,11 @@ export class RunnerManager {
|
|
|
636
1282
|
terminateHandle(handle, reason) {
|
|
637
1283
|
if (handle.termination)
|
|
638
1284
|
return handle.termination;
|
|
639
|
-
handle.
|
|
1285
|
+
if (handle.terminalCleanupRetryTimer) {
|
|
1286
|
+
clearTimeout(handle.terminalCleanupRetryTimer);
|
|
1287
|
+
delete handle.terminalCleanupRetryTimer;
|
|
1288
|
+
}
|
|
1289
|
+
const attempt = (async () => {
|
|
640
1290
|
if (!handle.dead) {
|
|
641
1291
|
try {
|
|
642
1292
|
handle.transport.send({ t: "shutdown" });
|
|
@@ -646,16 +1296,37 @@ export class RunnerManager {
|
|
|
646
1296
|
}
|
|
647
1297
|
this.failHandle(handle, reason);
|
|
648
1298
|
}
|
|
649
|
-
signalChild(handle.child, "SIGTERM");
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
1299
|
+
this.signalChild(handle.child, "SIGTERM", handle.processGroup);
|
|
1300
|
+
let childExited = await waitForChildExit(handle.completion, this.shutdownGraceMs);
|
|
1301
|
+
if (!childExited) {
|
|
1302
|
+
this.signalChild(handle.child, "SIGKILL", handle.processGroup);
|
|
1303
|
+
childExited = await waitForChildExit(handle.completion, this.shutdownKillGraceMs);
|
|
1304
|
+
}
|
|
1305
|
+
const terminalStopped = handle.key === CAP_KEY
|
|
1306
|
+
? true
|
|
1307
|
+
: await Promise.resolve(this.terminateTerminalServer(`${handle.key}-main`)).catch(() => false);
|
|
1308
|
+
if (!childExited) {
|
|
1309
|
+
throw new Error(`runner child ${handle.child.pid ?? handle.key} did not exit after SIGKILL`);
|
|
1310
|
+
}
|
|
1311
|
+
if (!terminalStopped) {
|
|
1312
|
+
throw new Error(`runner terminal ${handle.key}-main remained live after child exit`);
|
|
655
1313
|
}
|
|
656
1314
|
this.childHandles.delete(handle);
|
|
657
1315
|
})();
|
|
658
|
-
|
|
1316
|
+
handle.termination = attempt;
|
|
1317
|
+
void attempt.then(() => {
|
|
1318
|
+
this.finishAllTerminalInputHandoffs(handle);
|
|
1319
|
+
}, () => {
|
|
1320
|
+
if (handle.termination === attempt)
|
|
1321
|
+
delete handle.termination;
|
|
1322
|
+
handle.terminalCleanupRetryFailures += 1;
|
|
1323
|
+
// A failed cleanup must not pin closeAndDrain forever. Keep the
|
|
1324
|
+
// submission in the daemon activity snapshot, but release its gate
|
|
1325
|
+
// reservation so maintenance returns a structured busy result.
|
|
1326
|
+
this.preserveTerminalInputHandoffsAsActivity(handle);
|
|
1327
|
+
this.scheduleTerminalInputCleanupRetry(handle);
|
|
1328
|
+
});
|
|
1329
|
+
return attempt;
|
|
659
1330
|
}
|
|
660
1331
|
reapIdle() {
|
|
661
1332
|
const now = this.now();
|
|
@@ -694,6 +1365,70 @@ export class RunnerManager {
|
|
|
694
1365
|
}
|
|
695
1366
|
}
|
|
696
1367
|
}
|
|
1368
|
+
function trackedTerminalSubmissions(tracker, data) {
|
|
1369
|
+
const pasteStart = "\u001b[200~";
|
|
1370
|
+
const pasteEnd = "\u001b[201~";
|
|
1371
|
+
const input = tracker.pendingEscape + data;
|
|
1372
|
+
tracker.pendingEscape = "";
|
|
1373
|
+
const submissions = [];
|
|
1374
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
1375
|
+
const character = input[index] ?? "";
|
|
1376
|
+
if (character === "\u001b") {
|
|
1377
|
+
const remaining = input.slice(index);
|
|
1378
|
+
if (remaining.startsWith(pasteStart)) {
|
|
1379
|
+
tracker.bracketedPaste = true;
|
|
1380
|
+
tracker.previousWasCarriageReturn = false;
|
|
1381
|
+
index += pasteStart.length - 1;
|
|
1382
|
+
continue;
|
|
1383
|
+
}
|
|
1384
|
+
if (remaining.startsWith(pasteEnd)) {
|
|
1385
|
+
tracker.bracketedPaste = false;
|
|
1386
|
+
tracker.previousWasCarriageReturn = false;
|
|
1387
|
+
index += pasteEnd.length - 1;
|
|
1388
|
+
continue;
|
|
1389
|
+
}
|
|
1390
|
+
if (pasteStart.startsWith(remaining) || pasteEnd.startsWith(remaining)) {
|
|
1391
|
+
tracker.pendingEscape = remaining;
|
|
1392
|
+
break;
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
if (tracker.bracketedPaste) {
|
|
1396
|
+
tracker.buffer += character;
|
|
1397
|
+
tracker.previousWasCarriageReturn = false;
|
|
1398
|
+
continue;
|
|
1399
|
+
}
|
|
1400
|
+
if (character === "\n" && tracker.previousWasCarriageReturn) {
|
|
1401
|
+
tracker.previousWasCarriageReturn = false;
|
|
1402
|
+
continue;
|
|
1403
|
+
}
|
|
1404
|
+
tracker.previousWasCarriageReturn = character === "\r";
|
|
1405
|
+
if (character === "\r" || character === "\n") {
|
|
1406
|
+
const command = tracker.buffer.trim();
|
|
1407
|
+
tracker.buffer = "";
|
|
1408
|
+
if (command.length > 0 &&
|
|
1409
|
+
(command.includes("\u001b") ||
|
|
1410
|
+
!command.startsWith("/") ||
|
|
1411
|
+
/^\/(?:clear|fork)(?:\s|$)/u.test(command))) {
|
|
1412
|
+
submissions.push(/^\/(?:clear|fork)(?:\s|$)/u.test(command) ? "rotation" : "turn");
|
|
1413
|
+
}
|
|
1414
|
+
continue;
|
|
1415
|
+
}
|
|
1416
|
+
if (character === "\b" || character === "\u007f") {
|
|
1417
|
+
tracker.buffer = tracker.buffer.slice(0, -1);
|
|
1418
|
+
continue;
|
|
1419
|
+
}
|
|
1420
|
+
if (character === "\u0015" || character === "\u0003") {
|
|
1421
|
+
tracker.buffer = "";
|
|
1422
|
+
continue;
|
|
1423
|
+
}
|
|
1424
|
+
// Printable text and tabs are enough to distinguish work-producing prompts
|
|
1425
|
+
// from local TUI slash commands. Other control sequences are ignored.
|
|
1426
|
+
if (character === "\u001b" || character === "\t" || character >= " ") {
|
|
1427
|
+
tracker.buffer += character;
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
return submissions;
|
|
1431
|
+
}
|
|
697
1432
|
function validateSessionContext(context) {
|
|
698
1433
|
if (!context || typeof context !== "object") {
|
|
699
1434
|
throw new Error("Session context environment provider must return an object");
|
|
@@ -798,9 +1533,18 @@ function observeChildCompletion(child) {
|
|
|
798
1533
|
child.once("exit", onExit);
|
|
799
1534
|
});
|
|
800
1535
|
}
|
|
801
|
-
function
|
|
1536
|
+
function signalRunnerChild(child, signal, processGroup) {
|
|
802
1537
|
if (childHasExited(child))
|
|
803
1538
|
return;
|
|
1539
|
+
if (processGroup && child.pid) {
|
|
1540
|
+
try {
|
|
1541
|
+
process.kill(-child.pid, signal);
|
|
1542
|
+
return;
|
|
1543
|
+
}
|
|
1544
|
+
catch {
|
|
1545
|
+
// The group may have exited between the completion check and the signal.
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
804
1548
|
try {
|
|
805
1549
|
child.kill(signal);
|
|
806
1550
|
}
|
|
@@ -836,3 +1580,14 @@ function logTerminationFailure(handle, error) {
|
|
|
836
1580
|
error: error instanceof Error ? error.message : String(error),
|
|
837
1581
|
}));
|
|
838
1582
|
}
|
|
1583
|
+
function capabilityForkResult(result) {
|
|
1584
|
+
return result.ok
|
|
1585
|
+
? { ok: true }
|
|
1586
|
+
: {
|
|
1587
|
+
ok: false,
|
|
1588
|
+
message: result.message ??
|
|
1589
|
+
(result.reason === "unsupported"
|
|
1590
|
+
? "Provider does not support Session fork"
|
|
1591
|
+
: "Provider Session is unavailable"),
|
|
1592
|
+
};
|
|
1593
|
+
}
|