@rynx-ai/runtime 0.1.10 → 0.1.11-beta.2
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 +790 -350
- package/dist/index.d.ts +1 -2
- package/dist/index.js +0 -1
- 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 +41 -18
- package/dist/runner/manager.js +432 -55
- 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/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
|
@@ -160,6 +160,21 @@ export class RunnerManager {
|
|
|
160
160
|
liveSessionKeys = new Set();
|
|
161
161
|
/** Last live-start error per local session, surfaced by the control API. */
|
|
162
162
|
liveErrors = new Map();
|
|
163
|
+
/** Last immutable launch snapshots seen for a Session. Used only to restore a
|
|
164
|
+
* target request that crossed a fork reservation boundary. */
|
|
165
|
+
liveOptions = new Map();
|
|
166
|
+
/** A fork reserves its target before the first asynchronous store read. This
|
|
167
|
+
* prevents another entry point from starting the target against an
|
|
168
|
+
* uncommitted Provider binding. */
|
|
169
|
+
forkReservations = new Map();
|
|
170
|
+
/** A native fork temporarily makes the source read-only so its canonical
|
|
171
|
+
* snapshot and Provider context are captured at the same boundary. */
|
|
172
|
+
sourceForkReservations = new Map();
|
|
173
|
+
/** Manager-wide fork de-duplication. LocalAgentHost only sees one source
|
|
174
|
+
* runner, so the fence must live here to cover concurrent source runners. */
|
|
175
|
+
forkOperations = new Map();
|
|
176
|
+
forkBufferedMessages = new Map();
|
|
177
|
+
forkBufferedTerminalInputs = new Map();
|
|
163
178
|
constructor(opts) {
|
|
164
179
|
this.config = opts.config;
|
|
165
180
|
this.sessionStore = opts.sessionStore;
|
|
@@ -201,6 +216,9 @@ export class RunnerManager {
|
|
|
201
216
|
* and attach.
|
|
202
217
|
*/
|
|
203
218
|
openLiveTerminal(localThreadId, opts) {
|
|
219
|
+
if (this.forkReservations.has(localThreadId)) {
|
|
220
|
+
throw new TerminalOpenError("Session fork is still being committed", "terminal_not_live");
|
|
221
|
+
}
|
|
204
222
|
const handle = this.handles.get(localThreadId);
|
|
205
223
|
if (!handle || handle.dead || !this.liveSessionKeys.has(localThreadId)) {
|
|
206
224
|
throw new TerminalOpenError("terminal not live", "terminal_not_live");
|
|
@@ -210,7 +228,26 @@ export class RunnerManager {
|
|
|
210
228
|
openTerminalOnHandle(handle, localThreadId, opts) {
|
|
211
229
|
handle.lastUsedAt = this.now();
|
|
212
230
|
const attachId = randomUUID();
|
|
213
|
-
const terminal = new ManagedTerminal(attachId, (msg) =>
|
|
231
|
+
const terminal = new ManagedTerminal(attachId, (msg) => {
|
|
232
|
+
const sourceReservation = this.sourceForkReservations.get(localThreadId);
|
|
233
|
+
if (msg.t === "term.input" && sourceReservation) {
|
|
234
|
+
void sourceReservation.then(() => {
|
|
235
|
+
if (!handle.dead)
|
|
236
|
+
handle.transport.send(msg);
|
|
237
|
+
});
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
// A terminal request may race a fork reservation. Never let later
|
|
241
|
+
// keystrokes reach an unpublished Provider target.
|
|
242
|
+
if (msg.t === "term.input") {
|
|
243
|
+
const target = this.reservedForkTarget(handle, localThreadId);
|
|
244
|
+
if (target) {
|
|
245
|
+
this.forkBufferedTerminalInputs.get(target)?.push({ handle, message: msg });
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
handle.transport.send(msg);
|
|
250
|
+
}, () => handle.terminals.delete(attachId));
|
|
214
251
|
handle.terminals.set(attachId, terminal);
|
|
215
252
|
handle.transport.send({
|
|
216
253
|
t: "term.open",
|
|
@@ -229,6 +266,8 @@ export class RunnerManager {
|
|
|
229
266
|
}
|
|
230
267
|
/** Whether this process already owns a live runner for the Session. Read-only; never spawns. */
|
|
231
268
|
hasLiveSession(localThreadId) {
|
|
269
|
+
if (this.forkReservations.has(localThreadId))
|
|
270
|
+
return false;
|
|
232
271
|
const handle = this.handles.get(localThreadId);
|
|
233
272
|
return Boolean(handle && !handle.dead && this.liveSessionKeys.has(localThreadId));
|
|
234
273
|
}
|
|
@@ -260,8 +299,26 @@ export class RunnerManager {
|
|
|
260
299
|
startLiveSession(localThreadId, opts) {
|
|
261
300
|
return this.requestLiveSession(localThreadId, opts, false);
|
|
262
301
|
}
|
|
263
|
-
requestLiveSession(localThreadId, opts, waitForReady) {
|
|
264
|
-
const
|
|
302
|
+
requestLiveSession(localThreadId, opts, waitForReady, allowReservedForkTarget = false) {
|
|
303
|
+
const sourceReservation = allowReservedForkTarget
|
|
304
|
+
? undefined
|
|
305
|
+
: this.sourceForkReservations.get(localThreadId);
|
|
306
|
+
if (sourceReservation) {
|
|
307
|
+
return sourceReservation.then(() => this.requestLiveSession(localThreadId, opts, waitForReady));
|
|
308
|
+
}
|
|
309
|
+
this.liveOptions.set(localThreadId, {
|
|
310
|
+
workspace: structuredClone(opts.workspace),
|
|
311
|
+
execution: structuredClone(opts.execution),
|
|
312
|
+
...(opts.cols ? { cols: opts.cols } : {}),
|
|
313
|
+
...(opts.rows ? { rows: opts.rows } : {}),
|
|
314
|
+
});
|
|
315
|
+
const reservation = allowReservedForkTarget
|
|
316
|
+
? undefined
|
|
317
|
+
: this.forkReservations.get(localThreadId);
|
|
318
|
+
if (reservation) {
|
|
319
|
+
return reservation.then(() => this.requestLiveSession(localThreadId, opts, waitForReady));
|
|
320
|
+
}
|
|
321
|
+
const handle = this.getOrSpawn(localThreadId, allowReservedForkTarget);
|
|
265
322
|
handle.lastUsedAt = this.now();
|
|
266
323
|
this.liveSessionKeys.add(localThreadId);
|
|
267
324
|
const reqId = randomUUID();
|
|
@@ -270,39 +327,60 @@ export class RunnerManager {
|
|
|
270
327
|
const timeout = setTimeout(() => {
|
|
271
328
|
if (!handle.live.delete(reqId))
|
|
272
329
|
return;
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
330
|
+
const finishTimeout = () => {
|
|
331
|
+
const reason = `runner did not acknowledge ${waitForReady ? "live readiness" : "terminal start"} within ${timeoutMs}ms`;
|
|
332
|
+
this.liveErrors.set(localThreadId, reason);
|
|
333
|
+
this.liveSessionKeys.delete(localThreadId);
|
|
334
|
+
// A child that cannot answer a bounded control round-trip is unsafe
|
|
335
|
+
// to reuse. Reap it so the next click gets a fresh runner.
|
|
336
|
+
void this.terminateHandle(handle, reason).catch((error) => {
|
|
337
|
+
logTerminationFailure(handle, error);
|
|
338
|
+
});
|
|
339
|
+
return false;
|
|
340
|
+
};
|
|
341
|
+
const reservation = allowReservedForkTarget
|
|
342
|
+
? undefined
|
|
343
|
+
: this.forkReservations.get(localThreadId);
|
|
344
|
+
if (reservation) {
|
|
345
|
+
void reservation
|
|
346
|
+
.then(() => finishTimeout())
|
|
347
|
+
.then(resolve, () => resolve(false));
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
resolve(finishTimeout());
|
|
282
351
|
}, timeoutMs);
|
|
283
352
|
timeout.unref?.();
|
|
284
353
|
handle.live.set(reqId, (res) => {
|
|
285
354
|
clearTimeout(timeout);
|
|
286
355
|
const ok = res.ok ?? false;
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
356
|
+
const finish = () => {
|
|
357
|
+
if (ok) {
|
|
358
|
+
this.liveErrors.delete(localThreadId);
|
|
359
|
+
}
|
|
360
|
+
else {
|
|
361
|
+
this.liveErrors.set(localThreadId, res.error ?? "live session did not become ready");
|
|
362
|
+
}
|
|
363
|
+
return ok;
|
|
364
|
+
};
|
|
365
|
+
const reservation = allowReservedForkTarget
|
|
366
|
+
? undefined
|
|
367
|
+
: this.forkReservations.get(localThreadId);
|
|
368
|
+
if (reservation) {
|
|
369
|
+
void reservation
|
|
370
|
+
.then(() => finish())
|
|
371
|
+
.then(resolve, () => resolve(false));
|
|
372
|
+
return;
|
|
292
373
|
}
|
|
293
|
-
resolve(
|
|
374
|
+
resolve(finish());
|
|
294
375
|
});
|
|
295
376
|
handle.transport.send({
|
|
296
377
|
t: "live.ensure",
|
|
297
378
|
reqId,
|
|
298
379
|
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 } : {}),
|
|
380
|
+
workspace: opts.workspace,
|
|
381
|
+
execution: opts.execution,
|
|
382
|
+
...(opts.cols ? { cols: opts.cols } : {}),
|
|
383
|
+
...(opts.rows ? { rows: opts.rows } : {}),
|
|
306
384
|
waitForReady,
|
|
307
385
|
});
|
|
308
386
|
});
|
|
@@ -317,11 +395,38 @@ export class RunnerManager {
|
|
|
317
395
|
* the session has no live forwarder (caller falls back to the run path).
|
|
318
396
|
*/
|
|
319
397
|
injectMessage(localThreadId, input) {
|
|
398
|
+
const sourceReservation = this.sourceForkReservations.get(localThreadId);
|
|
399
|
+
if (sourceReservation) {
|
|
400
|
+
return sourceReservation.then(() => this.injectMessage(localThreadId, input));
|
|
401
|
+
}
|
|
402
|
+
const reservation = this.forkReservations.get(localThreadId);
|
|
403
|
+
if (reservation) {
|
|
404
|
+
return reservation.then(() => this.injectMessage(localThreadId, input));
|
|
405
|
+
}
|
|
320
406
|
const handle = this.getOrSpawn(localThreadId);
|
|
321
407
|
handle.lastUsedAt = this.now();
|
|
322
408
|
const reqId = randomUUID();
|
|
323
409
|
return new Promise((resolve) => {
|
|
324
|
-
handle.live.set(reqId, (res) =>
|
|
410
|
+
handle.live.set(reqId, (res) => {
|
|
411
|
+
const outcome = res.outcome ?? "failed";
|
|
412
|
+
const finish = () => {
|
|
413
|
+
if (outcome === "injected") {
|
|
414
|
+
this.liveErrors.delete(localThreadId);
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
this.liveErrors.set(localThreadId, res.error ?? `live injection ${outcome}`);
|
|
418
|
+
}
|
|
419
|
+
return outcome;
|
|
420
|
+
};
|
|
421
|
+
const reservation = this.forkReservations.get(localThreadId);
|
|
422
|
+
if (reservation) {
|
|
423
|
+
void reservation
|
|
424
|
+
.then(() => finish())
|
|
425
|
+
.then(resolve, () => resolve("failed"));
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
resolve(finish());
|
|
429
|
+
});
|
|
325
430
|
handle.transport.send(typeof input === "string"
|
|
326
431
|
? { t: "inject", reqId, localThreadId, text: input }
|
|
327
432
|
: { t: "inject", reqId, localThreadId, input });
|
|
@@ -382,9 +487,82 @@ export class RunnerManager {
|
|
|
382
487
|
clearGoal(localThreadId) {
|
|
383
488
|
return this.forwardCap("clearGoal", [localThreadId], localThreadId);
|
|
384
489
|
}
|
|
385
|
-
forkSession(currentLocalThreadId, newLocalThreadId) {
|
|
386
|
-
|
|
387
|
-
|
|
490
|
+
forkSession(currentLocalThreadId, newLocalThreadId, options) {
|
|
491
|
+
if (currentLocalThreadId === newLocalThreadId) {
|
|
492
|
+
return Promise.resolve({
|
|
493
|
+
ok: false,
|
|
494
|
+
message: "source and target Session must differ",
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
const inflight = this.forkOperations.get(newLocalThreadId);
|
|
498
|
+
if (inflight) {
|
|
499
|
+
if (inflight.sourceSessionId === currentLocalThreadId) {
|
|
500
|
+
return inflight.operation.then(capabilityForkResult);
|
|
501
|
+
}
|
|
502
|
+
return Promise.resolve({
|
|
503
|
+
ok: false,
|
|
504
|
+
message: "target Session already has a fork in progress",
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
if (this.sourceForkReservations.has(currentLocalThreadId)) {
|
|
508
|
+
return Promise.resolve({
|
|
509
|
+
ok: false,
|
|
510
|
+
message: "source Session already has a fork in progress",
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
let releaseReservation;
|
|
514
|
+
const reservation = new Promise((resolve) => {
|
|
515
|
+
releaseReservation = resolve;
|
|
516
|
+
});
|
|
517
|
+
let releaseSource;
|
|
518
|
+
const sourceReservation = new Promise((resolve) => {
|
|
519
|
+
releaseSource = resolve;
|
|
520
|
+
});
|
|
521
|
+
this.forkReservations.set(newLocalThreadId, reservation);
|
|
522
|
+
this.sourceForkReservations.set(currentLocalThreadId, sourceReservation);
|
|
523
|
+
this.forkBufferedMessages.set(newLocalThreadId, []);
|
|
524
|
+
this.forkBufferedTerminalInputs.set(newLocalThreadId, []);
|
|
525
|
+
const retainedLiveOptions = this.liveOptions.get(newLocalThreadId);
|
|
526
|
+
const operation = (async () => {
|
|
527
|
+
await options.beforeProviderFork();
|
|
528
|
+
return this.performManagedFork(currentLocalThreadId, newLocalThreadId, options);
|
|
529
|
+
})()
|
|
530
|
+
.finally(() => {
|
|
531
|
+
const current = this.forkOperations.get(newLocalThreadId);
|
|
532
|
+
if (current?.operation === operation) {
|
|
533
|
+
this.forkOperations.delete(newLocalThreadId);
|
|
534
|
+
}
|
|
535
|
+
if (this.forkReservations.get(newLocalThreadId) === reservation) {
|
|
536
|
+
this.forkReservations.delete(newLocalThreadId);
|
|
537
|
+
}
|
|
538
|
+
if (this.sourceForkReservations.get(currentLocalThreadId) === sourceReservation) {
|
|
539
|
+
this.sourceForkReservations.delete(currentLocalThreadId);
|
|
540
|
+
}
|
|
541
|
+
const buffered = this.forkBufferedMessages.get(newLocalThreadId) ?? [];
|
|
542
|
+
this.forkBufferedMessages.delete(newLocalThreadId);
|
|
543
|
+
const bufferedTerminalInputs = this.forkBufferedTerminalInputs.get(newLocalThreadId) ?? [];
|
|
544
|
+
this.forkBufferedTerminalInputs.delete(newLocalThreadId);
|
|
545
|
+
for (const entry of buffered) {
|
|
546
|
+
this.deliverForkBufferedMessage(entry.handle, entry.message);
|
|
547
|
+
}
|
|
548
|
+
for (const entry of bufferedTerminalInputs) {
|
|
549
|
+
if (!entry.handle.dead)
|
|
550
|
+
entry.handle.transport.send(entry.message);
|
|
551
|
+
}
|
|
552
|
+
releaseReservation();
|
|
553
|
+
releaseSource();
|
|
554
|
+
queueMicrotask(() => {
|
|
555
|
+
if (this.liveOptions.get(newLocalThreadId) === retainedLiveOptions
|
|
556
|
+
&& !this.handles.has(newLocalThreadId)) {
|
|
557
|
+
this.liveOptions.delete(newLocalThreadId);
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
});
|
|
561
|
+
this.forkOperations.set(newLocalThreadId, {
|
|
562
|
+
sourceSessionId: currentLocalThreadId,
|
|
563
|
+
operation,
|
|
564
|
+
});
|
|
565
|
+
return operation.then(capabilityForkResult);
|
|
388
566
|
}
|
|
389
567
|
/**
|
|
390
568
|
* Backend-free runtime readiness (not part of `AgentCapabilities`; surfaced for
|
|
@@ -417,6 +595,7 @@ export class RunnerManager {
|
|
|
417
595
|
this.handles.clear();
|
|
418
596
|
this.liveSessionKeys.clear();
|
|
419
597
|
this.liveErrors.clear();
|
|
598
|
+
this.liveOptions.clear();
|
|
420
599
|
this.mirrorListener = null;
|
|
421
600
|
this.rotateListener = null;
|
|
422
601
|
const errors = results
|
|
@@ -442,10 +621,15 @@ export class RunnerManager {
|
|
|
442
621
|
handle.transport.send({ t: "cap", capId, name, args });
|
|
443
622
|
});
|
|
444
623
|
}
|
|
445
|
-
getOrSpawn(key) {
|
|
624
|
+
getOrSpawn(key, allowReservedForkTarget = false) {
|
|
446
625
|
if (this.stopping) {
|
|
447
626
|
throw new AgentRuntimeError("runner manager is stopped", 503, "runner_stopped");
|
|
448
627
|
}
|
|
628
|
+
if (key !== CAP_KEY &&
|
|
629
|
+
this.forkReservations.has(key) &&
|
|
630
|
+
!allowReservedForkTarget) {
|
|
631
|
+
throw new AgentRuntimeError("Session fork is still being committed", 409, "session_fork_pending");
|
|
632
|
+
}
|
|
449
633
|
this.reapIdle();
|
|
450
634
|
const existing = this.handles.get(key);
|
|
451
635
|
if (existing && !existing.dead) {
|
|
@@ -456,6 +640,204 @@ export class RunnerManager {
|
|
|
456
640
|
this.handles.set(key, handle);
|
|
457
641
|
return handle;
|
|
458
642
|
}
|
|
643
|
+
async performManagedFork(currentLocalThreadId, newLocalThreadId, options) {
|
|
644
|
+
const existingTarget = await this.sessionStore.get(newLocalThreadId);
|
|
645
|
+
if (existingTarget?.parentSessionId === currentLocalThreadId) {
|
|
646
|
+
return { ok: true, data: undefined };
|
|
647
|
+
}
|
|
648
|
+
if (existingTarget?.parentSessionId) {
|
|
649
|
+
return {
|
|
650
|
+
ok: false,
|
|
651
|
+
reason: "error",
|
|
652
|
+
message: "target Session already belongs to a different fork",
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
const targetHandle = this.handles.get(newLocalThreadId);
|
|
656
|
+
if (existingTarget) {
|
|
657
|
+
return {
|
|
658
|
+
ok: false,
|
|
659
|
+
reason: "error",
|
|
660
|
+
message: "target Session already has a Provider binding",
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
if (targetHandle && !targetHandle.dead) {
|
|
664
|
+
return {
|
|
665
|
+
ok: false,
|
|
666
|
+
reason: "error",
|
|
667
|
+
message: "target Session already has a runner",
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
if (options.execution.provider === "claude") {
|
|
671
|
+
return this.performClaudeManagedFork(currentLocalThreadId, newLocalThreadId, options);
|
|
672
|
+
}
|
|
673
|
+
// Runs on the SOURCE session's child (its home has the source rollout to
|
|
674
|
+
// fork). The Provider persists the native fork and target binding before
|
|
675
|
+
// returning. Only then is the target reservation released.
|
|
676
|
+
return this.forwardCap("forkSession", [
|
|
677
|
+
currentLocalThreadId,
|
|
678
|
+
newLocalThreadId,
|
|
679
|
+
{
|
|
680
|
+
workspace: structuredClone(options.workspace),
|
|
681
|
+
execution: structuredClone(options.execution),
|
|
682
|
+
},
|
|
683
|
+
], currentLocalThreadId);
|
|
684
|
+
}
|
|
685
|
+
async performClaudeManagedFork(currentLocalThreadId, newLocalThreadId, options) {
|
|
686
|
+
const source = await this.sessionStore.get(currentLocalThreadId);
|
|
687
|
+
const setIntent = this.sessionStore.setClaudeForkIntent?.bind(this.sessionStore);
|
|
688
|
+
const deleteIntent = this.sessionStore.deleteClaudeForkIntent?.bind(this.sessionStore);
|
|
689
|
+
if (!source?.codexSessionId || !setIntent || !deleteIntent) {
|
|
690
|
+
return {
|
|
691
|
+
ok: false,
|
|
692
|
+
reason: "unsupported",
|
|
693
|
+
message: "Claude fork persistence is unavailable",
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
const targetClaudeSessionId = randomUUID();
|
|
697
|
+
try {
|
|
698
|
+
await setIntent({
|
|
699
|
+
targetSessionId: newLocalThreadId,
|
|
700
|
+
sourceSessionId: currentLocalThreadId,
|
|
701
|
+
sourceClaudeSessionId: source.codexSessionId,
|
|
702
|
+
targetClaudeSessionId,
|
|
703
|
+
updatedAt: new Date().toISOString(),
|
|
704
|
+
});
|
|
705
|
+
const ready = await this.requestLiveSession(newLocalThreadId, {
|
|
706
|
+
workspace: structuredClone(options.workspace),
|
|
707
|
+
execution: structuredClone(options.execution),
|
|
708
|
+
}, true, true);
|
|
709
|
+
const target = ready ? await this.sessionStore.get(newLocalThreadId) : null;
|
|
710
|
+
if (!target ||
|
|
711
|
+
target.parentSessionId !== currentLocalThreadId ||
|
|
712
|
+
target.codexSessionId !== targetClaudeSessionId) {
|
|
713
|
+
throw new Error(this.lastLiveSessionError(newLocalThreadId) ??
|
|
714
|
+
"Claude did not materialize the requested fork");
|
|
715
|
+
}
|
|
716
|
+
return { ok: true, data: undefined };
|
|
717
|
+
}
|
|
718
|
+
catch (error) {
|
|
719
|
+
await deleteIntent(newLocalThreadId).catch(() => undefined);
|
|
720
|
+
const target = await this.sessionStore.get(newLocalThreadId).catch(() => null);
|
|
721
|
+
if (target?.parentSessionId === currentLocalThreadId) {
|
|
722
|
+
await this.sessionStore.delete(newLocalThreadId).catch(() => undefined);
|
|
723
|
+
}
|
|
724
|
+
const handle = this.handles.get(newLocalThreadId);
|
|
725
|
+
if (handle && !handle.dead) {
|
|
726
|
+
await this.terminateHandle(handle, "Claude fork materialization failed").catch(() => undefined);
|
|
727
|
+
}
|
|
728
|
+
return {
|
|
729
|
+
ok: false,
|
|
730
|
+
reason: "error",
|
|
731
|
+
message: error instanceof Error ? error.message : String(error),
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
bufferForkTargetMessage(handle, message) {
|
|
736
|
+
const directTarget = message.t === "mirror" ? message.sessionId : message.from;
|
|
737
|
+
const target = this.reservedForkTarget(handle, directTarget);
|
|
738
|
+
if (!target)
|
|
739
|
+
return false;
|
|
740
|
+
this.forkBufferedMessages.get(target)?.push({ handle, message });
|
|
741
|
+
return true;
|
|
742
|
+
}
|
|
743
|
+
reservedForkTarget(handle, directTarget) {
|
|
744
|
+
return this.forkReservations.has(directTarget)
|
|
745
|
+
? directTarget
|
|
746
|
+
: [...this.forkReservations.keys()].find((candidate) => this.handles.get(candidate) === handle);
|
|
747
|
+
}
|
|
748
|
+
deliverForkBufferedMessage(handle, message) {
|
|
749
|
+
if (handle.dead)
|
|
750
|
+
return;
|
|
751
|
+
if (message.t === "mirror") {
|
|
752
|
+
this.mirrorListener?.(message.sessionId, message.event);
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
this.deliverRotateMessage(handle, message);
|
|
756
|
+
}
|
|
757
|
+
deliverRotateMessage(handle, message) {
|
|
758
|
+
if (this.forkReservations.has(message.to) ||
|
|
759
|
+
this.sourceForkReservations.has(message.from)) {
|
|
760
|
+
void this.terminateHandle(handle, "conflicting Session rotation").catch((error) => logTerminationFailure(handle, error));
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
try {
|
|
764
|
+
// Rotate the runtime-local context before publishing the new Session
|
|
765
|
+
// alias to routing or observers. This prevents the new identity from
|
|
766
|
+
// briefly inheriting stale context after `/clear` or `/fork`.
|
|
767
|
+
handle.sessionContext?.rotate(message.to);
|
|
768
|
+
}
|
|
769
|
+
catch (error) {
|
|
770
|
+
const reason = `runner Session context rotation failed: ${errorMessage(error)}`;
|
|
771
|
+
void this.terminateHandle(handle, reason).catch((terminationError) => {
|
|
772
|
+
logTerminationFailure(handle, terminationError);
|
|
773
|
+
});
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
let releaseTarget;
|
|
777
|
+
const targetReservation = new Promise((resolve) => {
|
|
778
|
+
releaseTarget = resolve;
|
|
779
|
+
});
|
|
780
|
+
let releaseSource;
|
|
781
|
+
const sourceReservation = new Promise((resolve) => {
|
|
782
|
+
releaseSource = resolve;
|
|
783
|
+
});
|
|
784
|
+
this.forkReservations.set(message.to, targetReservation);
|
|
785
|
+
this.sourceForkReservations.set(message.from, sourceReservation);
|
|
786
|
+
this.forkBufferedMessages.set(message.to, []);
|
|
787
|
+
this.forkBufferedTerminalInputs.set(message.to, []);
|
|
788
|
+
// The physical pane has already rotated. Remove every old routing alias now:
|
|
789
|
+
// a later request for the source must spawn a fresh runner that resumes the
|
|
790
|
+
// source Provider binding, while existing terminal attachments continue to
|
|
791
|
+
// follow the transferred pane.
|
|
792
|
+
for (const [key, candidate] of this.handles) {
|
|
793
|
+
if (candidate !== handle)
|
|
794
|
+
continue;
|
|
795
|
+
this.handles.delete(key);
|
|
796
|
+
this.liveSessionKeys.delete(key);
|
|
797
|
+
this.liveOptions.delete(key);
|
|
798
|
+
}
|
|
799
|
+
const rotation = {
|
|
800
|
+
from: message.from,
|
|
801
|
+
to: message.to,
|
|
802
|
+
kind: message.kind,
|
|
803
|
+
workspace: message.workspace,
|
|
804
|
+
execution: message.execution,
|
|
805
|
+
...(message.parentSessionId ? { parentSessionId: message.parentSessionId } : {}),
|
|
806
|
+
};
|
|
807
|
+
void Promise.resolve(this.rotateListener?.(rotation))
|
|
808
|
+
.then(() => {
|
|
809
|
+
if (handle.dead)
|
|
810
|
+
return;
|
|
811
|
+
this.handles.set(message.to, handle);
|
|
812
|
+
this.liveSessionKeys.add(message.to);
|
|
813
|
+
this.liveOptions.set(message.to, {
|
|
814
|
+
workspace: structuredClone(message.workspace),
|
|
815
|
+
execution: structuredClone(message.execution),
|
|
816
|
+
});
|
|
817
|
+
for (const entry of this.forkBufferedMessages.get(message.to) ?? []) {
|
|
818
|
+
this.deliverForkBufferedMessage(entry.handle, entry.message);
|
|
819
|
+
}
|
|
820
|
+
for (const entry of this.forkBufferedTerminalInputs.get(message.to) ?? []) {
|
|
821
|
+
if (!entry.handle.dead)
|
|
822
|
+
entry.handle.transport.send(entry.message);
|
|
823
|
+
}
|
|
824
|
+
})
|
|
825
|
+
.catch((error) => {
|
|
826
|
+
void this.terminateHandle(handle, `Session rotation publication failed: ${errorMessage(error)}`).catch((terminationError) => logTerminationFailure(handle, terminationError));
|
|
827
|
+
})
|
|
828
|
+
.finally(() => {
|
|
829
|
+
if (this.forkReservations.get(message.to) === targetReservation) {
|
|
830
|
+
this.forkReservations.delete(message.to);
|
|
831
|
+
}
|
|
832
|
+
if (this.sourceForkReservations.get(message.from) === sourceReservation) {
|
|
833
|
+
this.sourceForkReservations.delete(message.from);
|
|
834
|
+
}
|
|
835
|
+
this.forkBufferedMessages.delete(message.to);
|
|
836
|
+
this.forkBufferedTerminalInputs.delete(message.to);
|
|
837
|
+
releaseTarget();
|
|
838
|
+
releaseSource();
|
|
839
|
+
});
|
|
840
|
+
}
|
|
459
841
|
spawnHandle(key) {
|
|
460
842
|
const args = this.runnerEntry.endsWith(".ts")
|
|
461
843
|
? ["--import", "tsx", this.runnerEntry]
|
|
@@ -550,37 +932,18 @@ export class RunnerManager {
|
|
|
550
932
|
return;
|
|
551
933
|
}
|
|
552
934
|
case "mirror":
|
|
935
|
+
if (handle.dead)
|
|
936
|
+
return;
|
|
937
|
+
if (this.bufferForkTargetMessage(handle, msg))
|
|
938
|
+
return;
|
|
553
939
|
this.mirrorListener?.(msg.sessionId, msg.event);
|
|
554
940
|
return;
|
|
555
941
|
case "rotate": {
|
|
556
942
|
if (handle.dead)
|
|
557
943
|
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
|
-
});
|
|
944
|
+
if (this.bufferForkTargetMessage(handle, msg))
|
|
569
945
|
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
|
-
});
|
|
946
|
+
this.deliverRotateMessage(handle, msg);
|
|
584
947
|
return;
|
|
585
948
|
}
|
|
586
949
|
case "live.ready":
|
|
@@ -614,6 +977,9 @@ export class RunnerManager {
|
|
|
614
977
|
if (h === handle) {
|
|
615
978
|
this.handles.delete(key);
|
|
616
979
|
this.liveSessionKeys.delete(key);
|
|
980
|
+
if (!this.forkReservations.has(key)) {
|
|
981
|
+
this.liveOptions.delete(key);
|
|
982
|
+
}
|
|
617
983
|
}
|
|
618
984
|
}
|
|
619
985
|
const tail = handle.stderr.join("\n");
|
|
@@ -836,3 +1202,14 @@ function logTerminationFailure(handle, error) {
|
|
|
836
1202
|
error: error instanceof Error ? error.message : String(error),
|
|
837
1203
|
}));
|
|
838
1204
|
}
|
|
1205
|
+
function capabilityForkResult(result) {
|
|
1206
|
+
return result.ok
|
|
1207
|
+
? { ok: true }
|
|
1208
|
+
: {
|
|
1209
|
+
ok: false,
|
|
1210
|
+
message: result.message ??
|
|
1211
|
+
(result.reason === "unsupported"
|
|
1212
|
+
? "Provider does not support Session fork"
|
|
1213
|
+
: "Provider Session is unavailable"),
|
|
1214
|
+
};
|
|
1215
|
+
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* (`live.ensure` / `inject` / `live.interrupt`) plus per-thread capabilities and
|
|
7
7
|
* terminal attachment; the reply channels mirror each request's `reqId`.
|
|
8
8
|
*/
|
|
9
|
-
import { AgentRuntimeError, type
|
|
9
|
+
import { AgentRuntimeError, type ResolvedExecutionSnapshot, type RuntimeUserInput, type SessionInteractionResolution, type SessionEvent, type SessionWorkspaceSnapshot } from "@rynx-ai/core";
|
|
10
10
|
import type { ResolveInteractionResult } from "../interactions.js";
|
|
11
11
|
/** A runtime error flattened for the wire; rebuilt parent-side as `AgentRuntimeError`. */
|
|
12
12
|
export interface WireError {
|
|
@@ -76,22 +76,12 @@ export type ToChild = {
|
|
|
76
76
|
t: "live.ensure";
|
|
77
77
|
reqId: string;
|
|
78
78
|
localThreadId: string;
|
|
79
|
-
|
|
79
|
+
/** The two immutable Session snapshots. Provider launch/resume only
|
|
80
|
+
* projects these values and never resolves a Project or Agent. */
|
|
81
|
+
workspace: SessionWorkspaceSnapshot;
|
|
82
|
+
execution: ResolvedExecutionSnapshot;
|
|
80
83
|
cols?: number;
|
|
81
84
|
rows?: number;
|
|
82
|
-
/** Runtime this session co-drives (resolved from the agent spec by the
|
|
83
|
-
* control plane), so live picks the right backend per agent — not the
|
|
84
|
-
* global default. Absent ⇒ host falls back to the store record / default. */
|
|
85
|
-
runtime?: AgentRuntimeId;
|
|
86
|
-
/** Model-advertised reasoning effort for the live TUI and turn/start. */
|
|
87
|
-
reasoningEffort?: ReasoningEffort;
|
|
88
|
-
/** The session's preset agent id, so the host can load its spec and apply
|
|
89
|
-
* the agent's model / skills / instructions to the live launch (not just
|
|
90
|
-
* the runtime). Absent for inline-config or agent-less sessions. */
|
|
91
|
-
agentName?: string;
|
|
92
|
-
/** An inline agent spec (console session created from an inline config
|
|
93
|
-
* rather than a preset id) — same purpose as `agentName`. */
|
|
94
|
-
agentSpec?: AgentSpec;
|
|
95
85
|
/** Setup terminals only need the Provider pane to exist. Message delivery
|
|
96
86
|
* keeps the default and waits until the native thread is actually bound. */
|
|
97
87
|
waitForReady?: boolean;
|
|
@@ -166,9 +156,8 @@ export type FromChild = {
|
|
|
166
156
|
from: string;
|
|
167
157
|
to: string;
|
|
168
158
|
kind: "clear" | "fork";
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
cwd?: string;
|
|
159
|
+
workspace: SessionWorkspaceSnapshot;
|
|
160
|
+
execution: ResolvedExecutionSnapshot;
|
|
172
161
|
parentSessionId?: string;
|
|
173
162
|
}
|
|
174
163
|
/** Result of a `live.ensure`: `ok` once the requested gate is reached (pane
|