@rynx-ai/runtime 0.1.11-beta.2 → 0.1.11-beta.20

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.
@@ -22,7 +22,9 @@ 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";
27
+ import { isManagedNativeProvider } from "./startup-policy.js";
26
28
  import { StdioRunnerTransport } from "./transport.js";
27
29
  /** Routing key for the shared capability runner (slash-command RPCs). */
28
30
  const CAP_KEY = "__cap__";
@@ -32,10 +34,29 @@ const STDERR_TAIL_LINES = 40;
32
34
  const DEFAULT_SHUTDOWN_GRACE_MS = 5_000;
33
35
  /** Time allowed for exit after SIGKILL before shutdown reports failure. */
34
36
  const DEFAULT_SHUTDOWN_KILL_GRACE_MS = 5_000;
35
- /** Setup-pane launch should acknowledge quickly; never pin its HTTP request. */
36
- const DEFAULT_LIVE_START_TIMEOUT_MS = 10_000;
37
- /** Thread readiness may legitimately wait through Provider startup. */
37
+ /** Setup-pane launch includes the app-server readiness phase but does not wait
38
+ * for native thread discovery. Keep enough headroom around the app-server's
39
+ * own 10s gate so scheduling/IPC overhead cannot win the same deadline. */
40
+ const DEFAULT_LIVE_START_TIMEOUT_MS = 30_000;
41
+ /** Legacy fallback for a provider without a native startup policy. */
38
42
  const DEFAULT_LIVE_READY_TIMEOUT_MS = 25_000;
43
+ /** Claude has no app-server bridge and keeps a parent-owned SessionStart
44
+ * deadline. Codex-lineage startup instead acknowledges pane/observer startup
45
+ * and lets thread discovery race injection. */
46
+ const DEFAULT_NATIVE_LIVE_START_TIMEOUT_MS = 60_000;
47
+ /** A submitted TUI command should become a mirrored turn or native rotation
48
+ * quickly. If it does not, the runner is fenced by a verified process-tree
49
+ * shutdown before maintenance may treat the submission as settled. */
50
+ const DEFAULT_TERMINAL_INPUT_HANDOFF_TIMEOUT_MS = 30_000;
51
+ /** Retry transient terminal-server cleanup without spinning forever. */
52
+ const DEFAULT_TERMINAL_INPUT_CLEANUP_RETRY_MS = 1_000;
53
+ const MAX_TERMINAL_INPUT_CLEANUP_RETRY_MS = 30_000;
54
+ /** A Provider that never closes its active response must not keep a detached
55
+ * runner + tmux server alive forever. This is intentionally much longer than
56
+ * the normal idle TTL so legitimate long-running turns are not treated as
57
+ * stale. User input/control activity refreshes the deadline; Provider output
58
+ * does not, because a runaway redraw loop is the failure mode this bounds. */
59
+ const DEFAULT_STALE_ACTIVE_TTL_MS = 60 * 60_000;
39
60
  /** Keep per-Session spawn context small enough to remain an environment handoff,
40
61
  * not an unbounded transport. */
41
62
  const MAX_SESSION_CONTEXT_ENV_ENTRIES = 32;
@@ -136,9 +157,12 @@ export class RunnerManager {
136
157
  sessionStore;
137
158
  runnerEntry;
138
159
  idleTtlMs;
160
+ staleActiveTtlMs;
139
161
  spawn;
140
162
  childEnv;
141
163
  sessionContextProvider;
164
+ admissionOpen;
165
+ admissionReserve;
142
166
  now;
143
167
  defaultRuntime;
144
168
  handles = new Map();
@@ -148,8 +172,14 @@ export class RunnerManager {
148
172
  reapTimer;
149
173
  shutdownGraceMs;
150
174
  shutdownKillGraceMs;
175
+ signalChild;
176
+ terminateTerminalServer;
151
177
  liveStartTimeoutMs;
152
178
  liveReadyTimeoutMs;
179
+ nativeLiveStartTimeoutMs;
180
+ liveInterruptTimeoutMs;
181
+ terminalInputHandoffTimeoutMs;
182
+ terminalInputCleanupRetryMs;
153
183
  stopping = false;
154
184
  stopPromise;
155
185
  /** Sink for mirrored {@link SessionEvent}s from every session's forwarder. */
@@ -175,20 +205,32 @@ export class RunnerManager {
175
205
  forkOperations = new Map();
176
206
  forkBufferedMessages = new Map();
177
207
  forkBufferedTerminalInputs = new Map();
208
+ /** Owner TUI submissions accepted by the parent but not yet represented by a
209
+ * mirrored response or a durably published native rotation. */
210
+ terminalInputHandoffs = new Map();
178
211
  constructor(opts) {
179
212
  this.config = opts.config;
180
213
  this.sessionStore = opts.sessionStore;
181
214
  this.runnerEntry = opts.runnerEntry ?? defaultRunnerEntry();
182
215
  this.idleTtlMs = opts.idleTtlMs ?? 300_000;
216
+ this.staleActiveTtlMs = Math.max(this.idleTtlMs, opts.staleActiveTtlMs ?? DEFAULT_STALE_ACTIVE_TTL_MS);
183
217
  this.spawn = opts.spawn ?? nodeSpawn;
184
218
  this.childEnv = opts.childEnv ?? {};
185
219
  this.sessionContextProvider = opts.sessionContextProvider;
220
+ this.admissionOpen = opts.admissionOpen ?? (() => true);
221
+ this.admissionReserve = opts.admissionReserve;
186
222
  this.now = opts.now ?? (() => Date.now());
187
- this.defaultRuntime = opts.config.AGENT_RUNTIME ?? "codex";
223
+ this.defaultRuntime = opts.config.DEFAULT_RUNTIME ?? "codex";
188
224
  this.shutdownGraceMs = Math.max(0, opts.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS);
189
225
  this.shutdownKillGraceMs = Math.max(0, opts.shutdownKillGraceMs ?? DEFAULT_SHUTDOWN_KILL_GRACE_MS);
226
+ this.signalChild = opts.signalChild ?? signalRunnerChild;
227
+ this.terminateTerminalServer = opts.terminateTerminalServer ?? terminateTmuxServer;
190
228
  this.liveStartTimeoutMs = Math.max(1, opts.liveStartTimeoutMs ?? DEFAULT_LIVE_START_TIMEOUT_MS);
191
229
  this.liveReadyTimeoutMs = Math.max(1, opts.liveReadyTimeoutMs ?? DEFAULT_LIVE_READY_TIMEOUT_MS);
230
+ this.nativeLiveStartTimeoutMs = Math.max(1, opts.nativeLiveStartTimeoutMs ?? DEFAULT_NATIVE_LIVE_START_TIMEOUT_MS);
231
+ this.liveInterruptTimeoutMs = Math.max(1, opts.liveInterruptTimeoutMs ?? 10_000);
232
+ this.terminalInputHandoffTimeoutMs = Math.max(1, opts.terminalInputHandoffTimeoutMs ?? DEFAULT_TERMINAL_INPUT_HANDOFF_TIMEOUT_MS);
233
+ this.terminalInputCleanupRetryMs = Math.max(1, opts.terminalInputCleanupRetryMs ?? DEFAULT_TERMINAL_INPUT_CLEANUP_RETRY_MS);
192
234
  const reapIntervalMs = opts.reapIntervalMs ?? 60_000;
193
235
  if (reapIntervalMs > 0) {
194
236
  this.reapTimer = setInterval(() => this.reapIdle(), reapIntervalMs);
@@ -216,6 +258,7 @@ export class RunnerManager {
216
258
  * and attach.
217
259
  */
218
260
  openLiveTerminal(localThreadId, opts) {
261
+ this.assertAdmissionOpen();
219
262
  if (this.forkReservations.has(localThreadId)) {
220
263
  throw new TerminalOpenError("Session fork is still being committed", "terminal_not_live");
221
264
  }
@@ -228,12 +271,38 @@ export class RunnerManager {
228
271
  openTerminalOnHandle(handle, localThreadId, opts) {
229
272
  handle.lastUsedAt = this.now();
230
273
  const attachId = randomUUID();
274
+ const inputTracker = {
275
+ buffer: "",
276
+ previousWasCarriageReturn: false,
277
+ bracketedPaste: false,
278
+ pendingEscape: "",
279
+ };
231
280
  const terminal = new ManagedTerminal(attachId, (msg) => {
281
+ if (msg.t === "term.input")
282
+ handle.lastUsedAt = this.now();
283
+ const handoffs = msg.t === "term.input" && opts.role === "owner"
284
+ ? this.beginTerminalInputHandoffs(handle, this.currentTerminalSessionId(handle, localThreadId), trackedTerminalSubmissions(inputTracker, Buffer.from(msg.dataB64, "base64").toString("utf8")))
285
+ : [];
286
+ const send = () => {
287
+ try {
288
+ handle.transport.send(msg);
289
+ }
290
+ catch (error) {
291
+ for (const handoff of handoffs) {
292
+ this.finishTerminalInputHandoff(handle, handoff);
293
+ }
294
+ throw error;
295
+ }
296
+ };
232
297
  const sourceReservation = this.sourceForkReservations.get(localThreadId);
233
298
  if (msg.t === "term.input" && sourceReservation) {
234
299
  void sourceReservation.then(() => {
235
300
  if (!handle.dead)
236
- handle.transport.send(msg);
301
+ send();
302
+ }).catch((error) => {
303
+ void this.terminateHandle(handle, `terminal input delivery failed: ${errorMessage(error)}`).catch((terminationError) => {
304
+ logTerminationFailure(handle, terminationError);
305
+ });
237
306
  });
238
307
  return;
239
308
  }
@@ -246,7 +315,7 @@ export class RunnerManager {
246
315
  return;
247
316
  }
248
317
  }
249
- handle.transport.send(msg);
318
+ send();
250
319
  }, () => handle.terminals.delete(attachId));
251
320
  handle.terminals.set(attachId, terminal);
252
321
  handle.transport.send({
@@ -284,20 +353,171 @@ export class RunnerManager {
284
353
  onRotate(listener) {
285
354
  this.rotateListener = listener;
286
355
  }
356
+ /** Accepted TUI submissions that have not crossed into an observable runtime
357
+ * state. The daemon folds this into runningTurns after closing admission. */
358
+ pendingTerminalInputCount() {
359
+ let count = 0;
360
+ for (const handoffs of this.terminalInputHandoffs.values()) {
361
+ count += handoffs.length;
362
+ }
363
+ return count;
364
+ }
365
+ beginTerminalInputHandoffs(handle, sessionId, kinds) {
366
+ if (kinds.length === 0)
367
+ return [];
368
+ const created = [];
369
+ try {
370
+ for (const kind of kinds) {
371
+ const handoff = {
372
+ sessionId,
373
+ kind,
374
+ reservation: this.reserveAdmission(),
375
+ timer: undefined,
376
+ };
377
+ handoff.timer = setTimeout(() => {
378
+ this.expireTerminalInputHandoffs(handle);
379
+ }, this.terminalInputHandoffTimeoutMs);
380
+ handoff.timer.unref?.();
381
+ const handoffs = this.terminalInputHandoffs.get(handle);
382
+ if (handoffs)
383
+ handoffs.push(handoff);
384
+ else
385
+ this.terminalInputHandoffs.set(handle, [handoff]);
386
+ created.push(handoff);
387
+ }
388
+ return created;
389
+ }
390
+ catch (error) {
391
+ for (const handoff of created) {
392
+ this.finishTerminalInputHandoff(handle, handoff);
393
+ }
394
+ throw error;
395
+ }
396
+ }
397
+ settleTerminalInputHandoff(handle, sessionId, kind) {
398
+ const handoff = this.terminalInputHandoffs
399
+ .get(handle)
400
+ ?.find((candidate) => candidate.sessionId === sessionId);
401
+ // Runtime observations settle accepted input in submission order. In
402
+ // particular, a response from the source Session while /clear or /fork is
403
+ // still publishing must not skip that rotation and release a later turn
404
+ // which has not yet been rebound or delivered.
405
+ if (handoff?.kind === kind)
406
+ this.finishTerminalInputHandoff(handle, handoff);
407
+ }
408
+ settleTerminalRotationHandoff(handle, sourceSessionId, targetSessionId) {
409
+ const handoffs = this.terminalInputHandoffs.get(handle);
410
+ const rotationIndex = handoffs?.findIndex((handoff) => handoff.sessionId === sourceSessionId && handoff.kind === "rotation") ?? -1;
411
+ if (!handoffs || rotationIndex < 0)
412
+ return;
413
+ // Turns submitted before /clear or /fork are superseded once the rotation
414
+ // is published. Inputs accepted afterwards belong to the transferred pane
415
+ // and must follow it to the target Session.
416
+ const rotation = handoffs[rotationIndex];
417
+ for (const handoff of [...handoffs.slice(0, rotationIndex)]) {
418
+ if (handoff.sessionId === sourceSessionId && handoff.kind === "turn") {
419
+ this.finishTerminalInputHandoff(handle, handoff);
420
+ }
421
+ }
422
+ if (rotation)
423
+ this.finishTerminalInputHandoff(handle, rotation);
424
+ for (const handoff of this.terminalInputHandoffs.get(handle) ?? []) {
425
+ if (handoff.sessionId === sourceSessionId)
426
+ handoff.sessionId = targetSessionId;
427
+ }
428
+ }
429
+ currentTerminalSessionId(handle, fallback) {
430
+ for (const [sessionId, candidate] of this.handles) {
431
+ if (candidate === handle && this.liveSessionKeys.has(sessionId))
432
+ return sessionId;
433
+ }
434
+ return fallback;
435
+ }
436
+ finishTerminalInputHandoff(handle, handoff) {
437
+ if (handoff.timer) {
438
+ clearTimeout(handoff.timer);
439
+ handoff.timer = undefined;
440
+ }
441
+ handoff.reservation?.release();
442
+ handoff.reservation = undefined;
443
+ const handoffs = this.terminalInputHandoffs.get(handle);
444
+ if (!handoffs)
445
+ return;
446
+ const index = handoffs.indexOf(handoff);
447
+ if (index >= 0)
448
+ handoffs.splice(index, 1);
449
+ if (handoffs.length === 0)
450
+ this.terminalInputHandoffs.delete(handle);
451
+ }
452
+ finishAllTerminalInputHandoffs(handle) {
453
+ if (handle.terminalCleanupRetryTimer) {
454
+ clearTimeout(handle.terminalCleanupRetryTimer);
455
+ delete handle.terminalCleanupRetryTimer;
456
+ }
457
+ handle.terminalCleanupRetryFailures = 0;
458
+ for (const handoff of [...(this.terminalInputHandoffs.get(handle) ?? [])]) {
459
+ this.finishTerminalInputHandoff(handle, handoff);
460
+ }
461
+ }
462
+ preserveTerminalInputHandoffsAsActivity(handle) {
463
+ for (const handoff of this.terminalInputHandoffs.get(handle) ?? []) {
464
+ if (handoff.timer) {
465
+ clearTimeout(handoff.timer);
466
+ handoff.timer = undefined;
467
+ }
468
+ handoff.reservation?.release();
469
+ handoff.reservation = undefined;
470
+ }
471
+ }
472
+ scheduleTerminalInputCleanupRetry(handle) {
473
+ if (handle.terminalCleanupRetryTimer ||
474
+ !this.terminalInputHandoffs.has(handle))
475
+ return;
476
+ const exponent = Math.min(handle.terminalCleanupRetryFailures - 1, 10);
477
+ const delay = Math.min(this.terminalInputCleanupRetryMs * (2 ** Math.max(0, exponent)), MAX_TERMINAL_INPUT_CLEANUP_RETRY_MS);
478
+ handle.terminalCleanupRetryTimer = setTimeout(() => {
479
+ delete handle.terminalCleanupRetryTimer;
480
+ if (!this.terminalInputHandoffs.has(handle))
481
+ return;
482
+ void this.terminateHandle(handle, "retrying unproven terminal input cleanup").catch((error) => {
483
+ logTerminationFailure(handle, error);
484
+ });
485
+ }, delay);
486
+ handle.terminalCleanupRetryTimer.unref?.();
487
+ }
488
+ expireTerminalInputHandoffs(handle) {
489
+ const handoffs = this.terminalInputHandoffs.get(handle);
490
+ if (!handoffs?.length)
491
+ return;
492
+ for (const handoff of handoffs) {
493
+ if (handoff.timer) {
494
+ clearTimeout(handoff.timer);
495
+ handoff.timer = undefined;
496
+ }
497
+ }
498
+ void this.terminateHandle(handle, "accepted terminal input did not become observable before maintenance timeout").catch((error) => {
499
+ logTerminationFailure(handle, error);
500
+ });
501
+ }
287
502
  /**
288
503
  * Eagerly bring up a session's codex-native live view (persistent forwarder +
289
504
  * detached `codex --remote` TUI) in its runner child, spawning the runner if
290
- * needed. Idempotent. Resolves true once the codex thread is bound; false for a
291
- * non-codex / non-live session (the caller then uses the normal run path).
505
+ * needed. Idempotent. For Codex-lineage fresh sessions, resolves after the
506
+ * app-server/observer and pane are launched; thread discovery continues in
507
+ * parallel with injection. Other providers retain their readiness gate.
292
508
  */
293
509
  ensureLiveSession(localThreadId, opts) {
294
- return this.requestLiveSession(localThreadId, opts, true);
510
+ const admission = this.reserveAdmission();
511
+ return this.requestLiveSession(localThreadId, opts, true)
512
+ .finally(() => admission.release());
295
513
  }
296
514
  /** Start the Provider pane without waiting for login/onboarding to create a
297
515
  * native thread. This is the setup-terminal gate; callers may attach as soon
298
516
  * as it resolves, while normal message delivery still uses ensureLiveSession. */
299
517
  startLiveSession(localThreadId, opts) {
300
- return this.requestLiveSession(localThreadId, opts, false);
518
+ const admission = this.reserveAdmission();
519
+ return this.requestLiveSession(localThreadId, opts, false)
520
+ .finally(() => admission.release());
301
521
  }
302
522
  requestLiveSession(localThreadId, opts, waitForReady, allowReservedForkTarget = false) {
303
523
  const sourceReservation = allowReservedForkTarget
@@ -323,12 +543,25 @@ export class RunnerManager {
323
543
  this.liveSessionKeys.add(localThreadId);
324
544
  const reqId = randomUUID();
325
545
  return new Promise((resolve) => {
326
- const timeoutMs = waitForReady ? this.liveReadyTimeoutMs : this.liveStartTimeoutMs;
546
+ const timeoutMs = !waitForReady
547
+ ? this.liveStartTimeoutMs
548
+ : opts.execution.provider === "claude"
549
+ ? this.nativeLiveStartTimeoutMs
550
+ : isManagedNativeProvider(opts.execution.provider)
551
+ ? this.liveStartTimeoutMs
552
+ : this.liveReadyTimeoutMs;
327
553
  const timeout = setTimeout(() => {
328
554
  if (!handle.live.delete(reqId))
329
555
  return;
330
556
  const finishTimeout = () => {
331
- const reason = `runner did not acknowledge ${waitForReady ? "live readiness" : "terminal start"} within ${timeoutMs}ms`;
557
+ const provider = opts.execution.provider;
558
+ const reason = !waitForReady
559
+ ? `runner did not acknowledge terminal start within ${timeoutMs}ms`
560
+ : provider === "claude"
561
+ ? `Claude SessionStart was not observed within ${Math.round(timeoutMs / 1_000)}s; the runner process was reset before retry`
562
+ : provider === "codex" || provider === "traex"
563
+ ? `${provider === "traex" ? "Traex" : "Codex"} app-server/observer/Terminal launch was not acknowledged within ${Math.round(timeoutMs / 1_000)}s; the runner process was reset before retry`
564
+ : `runner did not acknowledge live readiness within ${timeoutMs}ms`;
332
565
  this.liveErrors.set(localThreadId, reason);
333
566
  this.liveSessionKeys.delete(localThreadId);
334
567
  // A child that cannot answer a bounded control round-trip is unsafe
@@ -395,13 +628,18 @@ export class RunnerManager {
395
628
  * the session has no live forwarder (caller falls back to the run path).
396
629
  */
397
630
  injectMessage(localThreadId, input) {
631
+ const admission = this.reserveAdmission();
632
+ return this.injectMessageAdmitted(localThreadId, input)
633
+ .finally(() => admission.release());
634
+ }
635
+ injectMessageAdmitted(localThreadId, input) {
398
636
  const sourceReservation = this.sourceForkReservations.get(localThreadId);
399
637
  if (sourceReservation) {
400
- return sourceReservation.then(() => this.injectMessage(localThreadId, input));
638
+ return sourceReservation.then(() => this.injectMessageAdmitted(localThreadId, input));
401
639
  }
402
640
  const reservation = this.forkReservations.get(localThreadId);
403
641
  if (reservation) {
404
- return reservation.then(() => this.injectMessage(localThreadId, input));
642
+ return reservation.then(() => this.injectMessageAdmitted(localThreadId, input));
405
643
  }
406
644
  const handle = this.getOrSpawn(localThreadId);
407
645
  handle.lastUsedAt = this.now();
@@ -444,7 +682,16 @@ export class RunnerManager {
444
682
  handle.lastUsedAt = this.now();
445
683
  const reqId = randomUUID();
446
684
  return new Promise((resolve) => {
447
- handle.live.set(reqId, (res) => resolve(res.ok ?? false));
685
+ const timeout = setTimeout(() => {
686
+ if (!handle.live.delete(reqId))
687
+ return;
688
+ resolve(false);
689
+ }, this.liveInterruptTimeoutMs);
690
+ timeout.unref?.();
691
+ handle.live.set(reqId, (res) => {
692
+ clearTimeout(timeout);
693
+ resolve(res.ok ?? false);
694
+ });
448
695
  handle.transport.send({ t: "live.interrupt", reqId, localThreadId });
449
696
  });
450
697
  }
@@ -488,6 +735,7 @@ export class RunnerManager {
488
735
  return this.forwardCap("clearGoal", [localThreadId], localThreadId);
489
736
  }
490
737
  forkSession(currentLocalThreadId, newLocalThreadId, options) {
738
+ this.assertAdmissionOpen();
491
739
  if (currentLocalThreadId === newLocalThreadId) {
492
740
  return Promise.resolve({
493
741
  ok: false,
@@ -573,7 +821,7 @@ export class RunnerManager {
573
821
  }
574
822
  /** Stop the runner bound to one session (if any), rejecting its in-flight work. */
575
823
  stopRunner(localThreadId) {
576
- const handle = this.handles.get(localThreadId);
824
+ const handle = this.handleForCleanup(localThreadId);
577
825
  if (!handle) {
578
826
  return;
579
827
  }
@@ -581,6 +829,15 @@ export class RunnerManager {
581
829
  logTerminationFailure(handle, error);
582
830
  });
583
831
  }
832
+ /** Force-stop and join the runner bound to one Session. Unlike stopRunner,
833
+ * completion proves that the child process has exited. */
834
+ async terminateLiveSession(localThreadId) {
835
+ const handle = this.handleForCleanup(localThreadId);
836
+ if (!handle)
837
+ return false;
838
+ await this.terminateHandle(handle, "runner force-stopped");
839
+ return true;
840
+ }
584
841
  /** Stop every runner and join all child exits. Idempotent across concurrent calls. */
585
842
  stop() {
586
843
  if (this.stopPromise)
@@ -589,9 +846,12 @@ export class RunnerManager {
589
846
  if (this.reapTimer) {
590
847
  clearInterval(this.reapTimer);
591
848
  }
592
- const children = [...this.childHandles];
593
- this.stopPromise = (async () => {
594
- const results = await Promise.allSettled(children.map((handle) => this.terminateHandle(handle, "runner manager stopped")));
849
+ const children = new Set([
850
+ ...this.childHandles,
851
+ ...this.terminalInputHandoffs.keys(),
852
+ ]);
853
+ const attempt = (async () => {
854
+ const results = await Promise.allSettled([...children].map((handle) => this.terminateHandle(handle, "runner manager stopped")));
595
855
  this.handles.clear();
596
856
  this.liveSessionKeys.clear();
597
857
  this.liveErrors.clear();
@@ -606,9 +866,19 @@ export class RunnerManager {
606
866
  }
607
867
  this.childHandles.clear();
608
868
  })();
609
- return this.stopPromise;
869
+ this.stopPromise = attempt;
870
+ void attempt.catch(() => {
871
+ if (this.stopPromise === attempt)
872
+ this.stopPromise = undefined;
873
+ });
874
+ return attempt;
610
875
  }
611
876
  // ── internals ──────────────────────────────────────────────────────────────
877
+ handleForCleanup(localThreadId) {
878
+ return this.handles.get(localThreadId) ??
879
+ [...this.terminalInputHandoffs].find(([handle, handoffs]) => handle.key === localThreadId ||
880
+ handoffs.some((handoff) => handoff.sessionId === localThreadId))?.[0];
881
+ }
612
882
  /** Forward a capability to a runner child. Session-less caps (listModels/status)
613
883
  * use the shared `CAP_KEY` child; per-thread caps pass the session's key so they
614
884
  * run on that session's child (which owns its private CODEX_HOME). */
@@ -622,23 +892,48 @@ export class RunnerManager {
622
892
  });
623
893
  }
624
894
  getOrSpawn(key, allowReservedForkTarget = false) {
625
- if (this.stopping) {
626
- throw new AgentRuntimeError("runner manager is stopped", 503, "runner_stopped");
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
- }
633
- this.reapIdle();
634
- const existing = this.handles.get(key);
635
- if (existing && !existing.dead) {
636
- existing.lastUsedAt = this.now();
637
- return existing;
638
- }
639
- const handle = this.spawnHandle(key);
640
- this.handles.set(key, handle);
641
- return handle;
895
+ const admission = this.reserveAdmission();
896
+ try {
897
+ if (this.stopping) {
898
+ throw new AgentRuntimeError("runner manager is stopped", 503, "runner_stopped");
899
+ }
900
+ if (key !== CAP_KEY &&
901
+ this.forkReservations.has(key) &&
902
+ !allowReservedForkTarget) {
903
+ throw new AgentRuntimeError("Session fork is still being committed", 409, "session_fork_pending");
904
+ }
905
+ this.reapIdle();
906
+ const existing = this.handles.get(key);
907
+ if (existing && !existing.dead) {
908
+ existing.lastUsedAt = this.now();
909
+ return existing;
910
+ }
911
+ const handle = this.spawnHandle(key);
912
+ this.handles.set(key, handle);
913
+ return handle;
914
+ }
915
+ finally {
916
+ admission.release();
917
+ }
918
+ }
919
+ assertAdmissionOpen() {
920
+ const reservation = this.admissionReserve?.();
921
+ if (reservation) {
922
+ reservation.release();
923
+ return;
924
+ }
925
+ if (!this.admissionReserve && this.admissionOpen())
926
+ return;
927
+ throw new AgentRuntimeError("daemon maintenance is in progress", 503, "daemon_maintenance");
928
+ }
929
+ reserveAdmission() {
930
+ const reservation = this.admissionReserve?.();
931
+ if (reservation)
932
+ return reservation;
933
+ if (!this.admissionReserve && this.admissionOpen()) {
934
+ return { release() { } };
935
+ }
936
+ throw new AgentRuntimeError("daemon maintenance is in progress", 503, "daemon_maintenance");
642
937
  }
643
938
  async performManagedFork(currentLocalThreadId, newLocalThreadId, options) {
644
939
  const existingTarget = await this.sessionStore.get(newLocalThreadId);
@@ -749,7 +1044,11 @@ export class RunnerManager {
749
1044
  if (handle.dead)
750
1045
  return;
751
1046
  if (message.t === "mirror") {
1047
+ this.observeHandleRuntimeEvent(handle, message.event);
752
1048
  this.mirrorListener?.(message.sessionId, message.event);
1049
+ if (message.event.type === "response.created") {
1050
+ this.settleTerminalInputHandoff(handle, message.sessionId, "turn");
1051
+ }
753
1052
  return;
754
1053
  }
755
1054
  this.deliverRotateMessage(handle, message);
@@ -806,8 +1105,14 @@ export class RunnerManager {
806
1105
  };
807
1106
  void Promise.resolve(this.rotateListener?.(rotation))
808
1107
  .then(() => {
1108
+ // The server's rotation listener resolves only after the target Session
1109
+ // is published. Release the terminal admission handoff afterwards so a
1110
+ // maintenance snapshot cannot observe a gap with neither source work
1111
+ // nor the target Session.
1112
+ this.settleTerminalRotationHandoff(handle, message.from, message.to);
809
1113
  if (handle.dead)
810
1114
  return;
1115
+ handle.activeResponseIds.clear();
811
1116
  this.handles.set(message.to, handle);
812
1117
  this.liveSessionKeys.add(message.to);
813
1118
  this.liveOptions.set(message.to, {
@@ -845,10 +1150,12 @@ export class RunnerManager {
845
1150
  const sessionContext = key === CAP_KEY
846
1151
  ? undefined
847
1152
  : this.openSessionContext(key);
1153
+ const processGroup = process.platform !== "win32";
848
1154
  let child;
849
1155
  try {
850
1156
  child = this.spawn(process.execPath, args, {
851
1157
  stdio: ["pipe", "pipe", "pipe"],
1158
+ detached: processGroup,
852
1159
  // `RYNX_RUNNER_SESSION` = the child's session key (localThreadId, or `__cap__`
853
1160
  // for the shared capability child) so its LocalAgentHost scopes the private
854
1161
  // CODEX_HOME to this session. It is deliberately applied last.
@@ -872,8 +1179,11 @@ export class RunnerManager {
872
1179
  transport,
873
1180
  stderr: [],
874
1181
  lastUsedAt: this.now(),
1182
+ activeResponseIds: new Set(),
875
1183
  dead: false,
876
1184
  completion,
1185
+ terminalCleanupRetryFailures: 0,
1186
+ processGroup,
877
1187
  ...(sessionContext ? { sessionContext } : {}),
878
1188
  caps: new Map(),
879
1189
  terminals: new Map(),
@@ -936,7 +1246,14 @@ export class RunnerManager {
936
1246
  return;
937
1247
  if (this.bufferForkTargetMessage(handle, msg))
938
1248
  return;
1249
+ this.observeHandleRuntimeEvent(handle, msg.event);
939
1250
  this.mirrorListener?.(msg.sessionId, msg.event);
1251
+ if (msg.event.type === "response.created") {
1252
+ // The listener projects response.created into SessionRuntimeIndex
1253
+ // synchronously. Only then may the accepted terminal reservation
1254
+ // drain into a maintenance activity snapshot.
1255
+ this.settleTerminalInputHandoff(handle, msg.sessionId, "turn");
1256
+ }
940
1257
  return;
941
1258
  case "rotate": {
942
1259
  if (handle.dead)
@@ -965,7 +1282,7 @@ export class RunnerManager {
965
1282
  }
966
1283
  }
967
1284
  /** Mark a handle dead and reject every pending run/cap with the exit reason. */
968
- failHandle(handle, reason) {
1285
+ failHandle(handle, reason, opts = {}) {
969
1286
  if (handle.dead) {
970
1287
  return;
971
1288
  }
@@ -984,6 +1301,9 @@ export class RunnerManager {
984
1301
  }
985
1302
  const tail = handle.stderr.join("\n");
986
1303
  const message = tail ? `${reason}\n--- runner log tail ---\n${tail}` : reason;
1304
+ if (opts.failActiveResponses !== false) {
1305
+ this.failActiveResponses(handle, "runner_crashed", message);
1306
+ }
987
1307
  const error = new AgentRuntimeError(message, 500, "runner_crashed");
988
1308
  for (const pending of handle.caps.values()) {
989
1309
  pending.reject(error);
@@ -1002,7 +1322,11 @@ export class RunnerManager {
1002
1322
  terminateHandle(handle, reason) {
1003
1323
  if (handle.termination)
1004
1324
  return handle.termination;
1005
- handle.termination = (async () => {
1325
+ if (handle.terminalCleanupRetryTimer) {
1326
+ clearTimeout(handle.terminalCleanupRetryTimer);
1327
+ delete handle.terminalCleanupRetryTimer;
1328
+ }
1329
+ const attempt = (async () => {
1006
1330
  if (!handle.dead) {
1007
1331
  try {
1008
1332
  handle.transport.send({ t: "shutdown" });
@@ -1010,38 +1334,126 @@ export class RunnerManager {
1010
1334
  catch {
1011
1335
  // The process signal below remains the authoritative shutdown path.
1012
1336
  }
1013
- this.failHandle(handle, reason);
1337
+ this.failHandle(handle, reason, { failActiveResponses: false });
1014
1338
  }
1015
- signalChild(handle.child, "SIGTERM");
1016
- if (!(await waitForChildExit(handle.completion, this.shutdownGraceMs))) {
1017
- signalChild(handle.child, "SIGKILL");
1018
- if (!(await waitForChildExit(handle.completion, this.shutdownKillGraceMs))) {
1019
- throw new Error(`runner child ${handle.child.pid ?? handle.key} did not exit after SIGKILL`);
1020
- }
1339
+ this.signalChild(handle.child, "SIGTERM", handle.processGroup);
1340
+ let childExited = await waitForChildExit(handle.completion, this.shutdownGraceMs);
1341
+ if (!childExited) {
1342
+ this.signalChild(handle.child, "SIGKILL", handle.processGroup);
1343
+ childExited = await waitForChildExit(handle.completion, this.shutdownKillGraceMs);
1344
+ }
1345
+ const terminalStopped = handle.key === CAP_KEY
1346
+ ? true
1347
+ : await Promise.resolve(this.terminateTerminalServer(`${handle.key}-main`)).catch(() => false);
1348
+ if (!childExited) {
1349
+ throw new Error(`runner child ${handle.child.pid ?? handle.key} did not exit after SIGKILL`);
1350
+ }
1351
+ if (!terminalStopped) {
1352
+ throw new Error(`runner terminal ${handle.key}-main remained live after child exit`);
1021
1353
  }
1022
1354
  this.childHandles.delete(handle);
1023
1355
  })();
1024
- return handle.termination;
1356
+ handle.termination = attempt;
1357
+ void attempt.then(() => {
1358
+ this.finishAllTerminalInputHandoffs(handle);
1359
+ }, () => {
1360
+ if (handle.termination === attempt)
1361
+ delete handle.termination;
1362
+ handle.terminalCleanupRetryFailures += 1;
1363
+ // A failed cleanup must not pin closeAndDrain forever. Keep the
1364
+ // submission in the daemon activity snapshot, but release its gate
1365
+ // reservation so maintenance returns a structured busy result.
1366
+ this.preserveTerminalInputHandoffsAsActivity(handle);
1367
+ this.scheduleTerminalInputCleanupRetry(handle);
1368
+ });
1369
+ return attempt;
1025
1370
  }
1026
1371
  reapIdle() {
1027
1372
  const now = this.now();
1028
1373
  for (const [key, handle] of this.handles) {
1029
- if (handle.caps.size > 0 || handle.terminals.size > 0) {
1374
+ if (handle.caps.size > 0 || handle.terminals.size > 0 || handle.live.size > 0) {
1030
1375
  continue;
1031
1376
  }
1032
- // A live codex forwarder must outlive terminal detach / idle turns — it is
1033
- // the session's single event writer, so never reap a session that has one.
1034
- if (this.liveSessionKeys.has(key)) {
1377
+ const idleForMs = now - handle.lastUsedAt;
1378
+ if (idleForMs < this.idleTtlMs) {
1035
1379
  continue;
1036
1380
  }
1037
- if (now - handle.lastUsedAt < this.idleTtlMs) {
1381
+ const hasActiveResponse = handle.activeResponseIds.size > 0;
1382
+ if (hasActiveResponse && idleForMs < this.staleActiveTtlMs) {
1038
1383
  continue;
1039
1384
  }
1040
- void this.terminateHandle(handle, "idle runner reaped").catch((error) => {
1385
+ const reason = hasActiveResponse
1386
+ ? "stale active runner reaped"
1387
+ : this.liveSessionKeys.has(key)
1388
+ ? "idle live runner reaped"
1389
+ : "idle runner reaped";
1390
+ if (hasActiveResponse) {
1391
+ this.failStaleActiveResponses(handle, idleForMs);
1392
+ }
1393
+ void this.terminateHandle(handle, reason).catch((error) => {
1041
1394
  logTerminationFailure(handle, error);
1042
1395
  });
1043
1396
  }
1044
1397
  }
1398
+ /** Close server-side runtime state before fencing a Provider that stayed
1399
+ * active past its hard inactivity deadline. The child transport is closed by
1400
+ * terminateHandle immediately afterwards, so these are the final events for
1401
+ * the abandoned responses. */
1402
+ failStaleActiveResponses(handle, idleForMs) {
1403
+ this.failActiveResponses(handle, "stale_runner_reaped", `Runner was reaped after ${Math.floor(idleForMs / 1000)}s without user activity`);
1404
+ }
1405
+ failActiveResponses(handle, code, message) {
1406
+ const responseIds = [...handle.activeResponseIds];
1407
+ handle.activeResponseIds.clear();
1408
+ const sessionId = this.currentTerminalSessionId(handle, handle.key);
1409
+ for (const responseId of responseIds) {
1410
+ this.mirrorListener?.(sessionId, {
1411
+ type: "response.failed",
1412
+ responseId,
1413
+ error: {
1414
+ source: "execution",
1415
+ code,
1416
+ message,
1417
+ },
1418
+ });
1419
+ }
1420
+ }
1421
+ /** Track only response lifecycle, not output volume. Output deltas from a
1422
+ * runaway TUI/Provider must not refresh the stale-active deadline. */
1423
+ observeHandleRuntimeEvent(handle, event) {
1424
+ switch (event.type) {
1425
+ case "response.created":
1426
+ case "response.output_text.delta":
1427
+ case "response.reasoning_summary_text.delta":
1428
+ case "response.function_call_output.delta":
1429
+ case "response.output_item.done":
1430
+ handle.activeResponseIds.add(event.responseId);
1431
+ return;
1432
+ case "session.interaction.requested":
1433
+ handle.activeResponseIds.add(event.responseId);
1434
+ return;
1435
+ case "response.completed":
1436
+ case "response.failed":
1437
+ handle.activeResponseIds.delete(event.responseId);
1438
+ return;
1439
+ case "session.status":
1440
+ if (event.status === "running" && event.responseId) {
1441
+ handle.activeResponseIds.add(event.responseId);
1442
+ }
1443
+ else if (event.status !== "running") {
1444
+ if (event.responseId)
1445
+ handle.activeResponseIds.delete(event.responseId);
1446
+ else
1447
+ handle.activeResponseIds.clear();
1448
+ }
1449
+ return;
1450
+ case "session.rotated":
1451
+ handle.activeResponseIds.clear();
1452
+ return;
1453
+ default:
1454
+ return;
1455
+ }
1456
+ }
1045
1457
  openSessionContext(sessionId) {
1046
1458
  if (!this.sessionContextProvider)
1047
1459
  return undefined;
@@ -1060,6 +1472,70 @@ export class RunnerManager {
1060
1472
  }
1061
1473
  }
1062
1474
  }
1475
+ function trackedTerminalSubmissions(tracker, data) {
1476
+ const pasteStart = "\u001b[200~";
1477
+ const pasteEnd = "\u001b[201~";
1478
+ const input = tracker.pendingEscape + data;
1479
+ tracker.pendingEscape = "";
1480
+ const submissions = [];
1481
+ for (let index = 0; index < input.length; index += 1) {
1482
+ const character = input[index] ?? "";
1483
+ if (character === "\u001b") {
1484
+ const remaining = input.slice(index);
1485
+ if (remaining.startsWith(pasteStart)) {
1486
+ tracker.bracketedPaste = true;
1487
+ tracker.previousWasCarriageReturn = false;
1488
+ index += pasteStart.length - 1;
1489
+ continue;
1490
+ }
1491
+ if (remaining.startsWith(pasteEnd)) {
1492
+ tracker.bracketedPaste = false;
1493
+ tracker.previousWasCarriageReturn = false;
1494
+ index += pasteEnd.length - 1;
1495
+ continue;
1496
+ }
1497
+ if (pasteStart.startsWith(remaining) || pasteEnd.startsWith(remaining)) {
1498
+ tracker.pendingEscape = remaining;
1499
+ break;
1500
+ }
1501
+ }
1502
+ if (tracker.bracketedPaste) {
1503
+ tracker.buffer += character;
1504
+ tracker.previousWasCarriageReturn = false;
1505
+ continue;
1506
+ }
1507
+ if (character === "\n" && tracker.previousWasCarriageReturn) {
1508
+ tracker.previousWasCarriageReturn = false;
1509
+ continue;
1510
+ }
1511
+ tracker.previousWasCarriageReturn = character === "\r";
1512
+ if (character === "\r" || character === "\n") {
1513
+ const command = tracker.buffer.trim();
1514
+ tracker.buffer = "";
1515
+ if (command.length > 0 &&
1516
+ (command.includes("\u001b") ||
1517
+ !command.startsWith("/") ||
1518
+ /^\/(?:clear|fork)(?:\s|$)/u.test(command))) {
1519
+ submissions.push(/^\/(?:clear|fork)(?:\s|$)/u.test(command) ? "rotation" : "turn");
1520
+ }
1521
+ continue;
1522
+ }
1523
+ if (character === "\b" || character === "\u007f") {
1524
+ tracker.buffer = tracker.buffer.slice(0, -1);
1525
+ continue;
1526
+ }
1527
+ if (character === "\u0015" || character === "\u0003") {
1528
+ tracker.buffer = "";
1529
+ continue;
1530
+ }
1531
+ // Printable text and tabs are enough to distinguish work-producing prompts
1532
+ // from local TUI slash commands. Other control sequences are ignored.
1533
+ if (character === "\u001b" || character === "\t" || character >= " ") {
1534
+ tracker.buffer += character;
1535
+ }
1536
+ }
1537
+ return submissions;
1538
+ }
1063
1539
  function validateSessionContext(context) {
1064
1540
  if (!context || typeof context !== "object") {
1065
1541
  throw new Error("Session context environment provider must return an object");
@@ -1164,9 +1640,18 @@ function observeChildCompletion(child) {
1164
1640
  child.once("exit", onExit);
1165
1641
  });
1166
1642
  }
1167
- function signalChild(child, signal) {
1643
+ function signalRunnerChild(child, signal, processGroup) {
1168
1644
  if (childHasExited(child))
1169
1645
  return;
1646
+ if (processGroup && child.pid) {
1647
+ try {
1648
+ process.kill(-child.pid, signal);
1649
+ return;
1650
+ }
1651
+ catch {
1652
+ // The group may have exited between the completion check and the signal.
1653
+ }
1654
+ }
1170
1655
  try {
1171
1656
  child.kill(signal);
1172
1657
  }