@rynx-ai/runtime 0.1.11-beta.6 → 0.1.11-beta.8

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.
@@ -7,7 +7,11 @@ function normalizeTraexPane(pane) {
7
7
  return pane.toLowerCase().replace(/\s+/g, " ").trim();
8
8
  }
9
9
  function isTerminalProtocolResponse(input) {
10
- return /^(?:\x1b\[(?:(?:\?|>)[0-9;]*c|[0-9;]*(?:n|R|t)|[IO]))+$/.test(input);
10
+ // xterm answers terminal queries through the same onData channel as real
11
+ // keystrokes. CSI carries device/focus/position reports; OSC carries color
12
+ // query replies such as `OSC 10;rgb:... ST` and `OSC 11;rgb:... ST`.
13
+ // Neither is evidence that the user has taken over startup prompt handling.
14
+ return /^(?:\x1b\[(?:(?:\?|>)[0-9;]*c|[0-9;]*(?:n|R|t)|[IO])|\x1b\][0-9]+;[^\x07\x1b]*(?:\x07|\x1b\\))+$/.test(input);
11
15
  }
12
16
  export class RunnerSession {
13
17
  transport;
@@ -43,6 +43,9 @@ export interface RunnerManagerOptions {
43
43
  runnerEntry?: string;
44
44
  /** Idle TTL (ms) after which an unused runner is reaped. */
45
45
  idleTtlMs?: number;
46
+ /** Maximum time without user/control activity that an active response may
47
+ * protect a detached runner from idle reaping. */
48
+ staleActiveTtlMs?: number;
46
49
  /** Background reap sweep interval (ms); `0` disables the timer (tests). */
47
50
  reapIntervalMs?: number;
48
51
  /** Grace period between SIGTERM and SIGKILL during runner shutdown. */
@@ -136,6 +139,7 @@ export declare class RunnerManager implements AgentCapabilities {
136
139
  private readonly sessionStore;
137
140
  private readonly runnerEntry;
138
141
  private readonly idleTtlMs;
142
+ private readonly staleActiveTtlMs;
139
143
  private readonly spawn;
140
144
  private readonly childEnv;
141
145
  private readonly sessionContextProvider;
@@ -312,5 +316,13 @@ export declare class RunnerManager implements AgentCapabilities {
312
316
  private failHandle;
313
317
  private terminateHandle;
314
318
  private reapIdle;
319
+ /** Close server-side runtime state before fencing a Provider that stayed
320
+ * active past its hard inactivity deadline. The child transport is closed by
321
+ * terminateHandle immediately afterwards, so these are the final events for
322
+ * the abandoned responses. */
323
+ private failStaleActiveResponses;
324
+ /** Track only response lifecycle, not output volume. Output deltas from a
325
+ * runaway TUI/Provider must not refresh the stale-active deadline. */
326
+ private observeHandleRuntimeEvent;
315
327
  private openSessionContext;
316
328
  }
@@ -44,6 +44,12 @@ const DEFAULT_TERMINAL_INPUT_HANDOFF_TIMEOUT_MS = 30_000;
44
44
  /** Retry transient terminal-server cleanup without spinning forever. */
45
45
  const DEFAULT_TERMINAL_INPUT_CLEANUP_RETRY_MS = 1_000;
46
46
  const MAX_TERMINAL_INPUT_CLEANUP_RETRY_MS = 30_000;
47
+ /** A Provider that never closes its active response must not keep a detached
48
+ * runner + tmux server alive forever. This is intentionally much longer than
49
+ * the normal idle TTL so legitimate long-running turns are not treated as
50
+ * stale. User input/control activity refreshes the deadline; Provider output
51
+ * does not, because a runaway redraw loop is the failure mode this bounds. */
52
+ const DEFAULT_STALE_ACTIVE_TTL_MS = 60 * 60_000;
47
53
  /** Keep per-Session spawn context small enough to remain an environment handoff,
48
54
  * not an unbounded transport. */
49
55
  const MAX_SESSION_CONTEXT_ENV_ENTRIES = 32;
@@ -144,6 +150,7 @@ export class RunnerManager {
144
150
  sessionStore;
145
151
  runnerEntry;
146
152
  idleTtlMs;
153
+ staleActiveTtlMs;
147
154
  spawn;
148
155
  childEnv;
149
156
  sessionContextProvider;
@@ -198,6 +205,7 @@ export class RunnerManager {
198
205
  this.sessionStore = opts.sessionStore;
199
206
  this.runnerEntry = opts.runnerEntry ?? defaultRunnerEntry();
200
207
  this.idleTtlMs = opts.idleTtlMs ?? 300_000;
208
+ this.staleActiveTtlMs = Math.max(this.idleTtlMs, opts.staleActiveTtlMs ?? DEFAULT_STALE_ACTIVE_TTL_MS);
201
209
  this.spawn = opts.spawn ?? nodeSpawn;
202
210
  this.childEnv = opts.childEnv ?? {};
203
211
  this.sessionContextProvider = opts.sessionContextProvider;
@@ -261,6 +269,8 @@ export class RunnerManager {
261
269
  pendingEscape: "",
262
270
  };
263
271
  const terminal = new ManagedTerminal(attachId, (msg) => {
272
+ if (msg.t === "term.input")
273
+ handle.lastUsedAt = this.now();
264
274
  const handoffs = msg.t === "term.input" && opts.role === "owner"
265
275
  ? this.beginTerminalInputHandoffs(handle, this.currentTerminalSessionId(handle, localThreadId), trackedTerminalSubmissions(inputTracker, Buffer.from(msg.dataB64, "base64").toString("utf8")))
266
276
  : [];
@@ -1011,6 +1021,7 @@ export class RunnerManager {
1011
1021
  if (handle.dead)
1012
1022
  return;
1013
1023
  if (message.t === "mirror") {
1024
+ this.observeHandleRuntimeEvent(handle, message.event);
1014
1025
  this.mirrorListener?.(message.sessionId, message.event);
1015
1026
  if (message.event.type === "response.created") {
1016
1027
  this.settleTerminalInputHandoff(handle, message.sessionId, "turn");
@@ -1078,6 +1089,7 @@ export class RunnerManager {
1078
1089
  this.settleTerminalRotationHandoff(handle, message.from, message.to);
1079
1090
  if (handle.dead)
1080
1091
  return;
1092
+ handle.activeResponseIds.clear();
1081
1093
  this.handles.set(message.to, handle);
1082
1094
  this.liveSessionKeys.add(message.to);
1083
1095
  this.liveOptions.set(message.to, {
@@ -1144,6 +1156,7 @@ export class RunnerManager {
1144
1156
  transport,
1145
1157
  stderr: [],
1146
1158
  lastUsedAt: this.now(),
1159
+ activeResponseIds: new Set(),
1147
1160
  dead: false,
1148
1161
  completion,
1149
1162
  terminalCleanupRetryFailures: 0,
@@ -1210,6 +1223,7 @@ export class RunnerManager {
1210
1223
  return;
1211
1224
  if (this.bufferForkTargetMessage(handle, msg))
1212
1225
  return;
1226
+ this.observeHandleRuntimeEvent(handle, msg.event);
1213
1227
  this.mirrorListener?.(msg.sessionId, msg.event);
1214
1228
  if (msg.event.type === "response.created") {
1215
1229
  // The listener projects response.created into SessionRuntimeIndex
@@ -1331,22 +1345,86 @@ export class RunnerManager {
1331
1345
  reapIdle() {
1332
1346
  const now = this.now();
1333
1347
  for (const [key, handle] of this.handles) {
1334
- if (handle.caps.size > 0 || handle.terminals.size > 0) {
1348
+ if (handle.caps.size > 0 || handle.terminals.size > 0 || handle.live.size > 0) {
1335
1349
  continue;
1336
1350
  }
1337
- // A live codex forwarder must outlive terminal detach / idle turns — it is
1338
- // the session's single event writer, so never reap a session that has one.
1339
- if (this.liveSessionKeys.has(key)) {
1351
+ const idleForMs = now - handle.lastUsedAt;
1352
+ if (idleForMs < this.idleTtlMs) {
1340
1353
  continue;
1341
1354
  }
1342
- if (now - handle.lastUsedAt < this.idleTtlMs) {
1355
+ const hasActiveResponse = handle.activeResponseIds.size > 0;
1356
+ if (hasActiveResponse && idleForMs < this.staleActiveTtlMs) {
1343
1357
  continue;
1344
1358
  }
1345
- void this.terminateHandle(handle, "idle runner reaped").catch((error) => {
1359
+ const reason = hasActiveResponse
1360
+ ? "stale active runner reaped"
1361
+ : this.liveSessionKeys.has(key)
1362
+ ? "idle live runner reaped"
1363
+ : "idle runner reaped";
1364
+ if (hasActiveResponse) {
1365
+ this.failStaleActiveResponses(handle, idleForMs);
1366
+ }
1367
+ void this.terminateHandle(handle, reason).catch((error) => {
1346
1368
  logTerminationFailure(handle, error);
1347
1369
  });
1348
1370
  }
1349
1371
  }
1372
+ /** Close server-side runtime state before fencing a Provider that stayed
1373
+ * active past its hard inactivity deadline. The child transport is closed by
1374
+ * terminateHandle immediately afterwards, so these are the final events for
1375
+ * the abandoned responses. */
1376
+ failStaleActiveResponses(handle, idleForMs) {
1377
+ const responseIds = [...handle.activeResponseIds];
1378
+ handle.activeResponseIds.clear();
1379
+ const sessionId = this.currentTerminalSessionId(handle, handle.key);
1380
+ for (const responseId of responseIds) {
1381
+ this.mirrorListener?.(sessionId, {
1382
+ type: "response.failed",
1383
+ responseId,
1384
+ error: {
1385
+ source: "execution",
1386
+ code: "stale_runner_reaped",
1387
+ message: `Runner was reaped after ${Math.floor(idleForMs / 1000)}s without user activity`,
1388
+ },
1389
+ });
1390
+ }
1391
+ }
1392
+ /** Track only response lifecycle, not output volume. Output deltas from a
1393
+ * runaway TUI/Provider must not refresh the stale-active deadline. */
1394
+ observeHandleRuntimeEvent(handle, event) {
1395
+ switch (event.type) {
1396
+ case "response.created":
1397
+ case "response.output_text.delta":
1398
+ case "response.reasoning_summary_text.delta":
1399
+ case "response.function_call_output.delta":
1400
+ case "response.output_item.done":
1401
+ handle.activeResponseIds.add(event.responseId);
1402
+ return;
1403
+ case "session.interaction.requested":
1404
+ handle.activeResponseIds.add(event.responseId);
1405
+ return;
1406
+ case "response.completed":
1407
+ case "response.failed":
1408
+ handle.activeResponseIds.delete(event.responseId);
1409
+ return;
1410
+ case "session.status":
1411
+ if (event.status === "running" && event.responseId) {
1412
+ handle.activeResponseIds.add(event.responseId);
1413
+ }
1414
+ else if (event.status !== "running") {
1415
+ if (event.responseId)
1416
+ handle.activeResponseIds.delete(event.responseId);
1417
+ else
1418
+ handle.activeResponseIds.clear();
1419
+ }
1420
+ return;
1421
+ case "session.rotated":
1422
+ handle.activeResponseIds.clear();
1423
+ return;
1424
+ default:
1425
+ return;
1426
+ }
1427
+ }
1350
1428
  openSessionContext(sessionId) {
1351
1429
  if (!this.sessionContextProvider)
1352
1430
  return undefined;
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import { execFile, execFileSync } from "node:child_process";
18
18
  import { createHash } from "node:crypto";
19
- import { existsSync, mkdirSync } from "node:fs";
19
+ import { existsSync, mkdirSync, unlinkSync } from "node:fs";
20
20
  import { tmpdir } from "node:os";
21
21
  import { join } from "node:path";
22
22
  const TMUX_TARGET = "main";
@@ -54,10 +54,28 @@ export function terminateTmuxServer(name, tmuxBin = "tmux") {
54
54
  return false;
55
55
  }
56
56
  catch (error) {
57
- return !(error &&
58
- typeof error === "object" &&
59
- "code" in error &&
60
- error.code === "ENOENT");
57
+ // Only a real tmux exit status proves the server/session is absent. Spawn
58
+ // failures (missing/unexecutable tmux) leave liveness unproven and must not
59
+ // unlink a socket that could still belong to a live server.
60
+ if (!error ||
61
+ typeof error !== "object" ||
62
+ !("status" in error) ||
63
+ typeof error.status !== "number") {
64
+ return false;
65
+ }
66
+ try {
67
+ // tmux custom-socket servers can exit while leaving the filesystem entry
68
+ // behind. Remove that verified-stale socket so process/socket inventories
69
+ // do not accumulate dead terminals forever.
70
+ unlinkSync(socketPath);
71
+ return true;
72
+ }
73
+ catch (unlinkError) {
74
+ return Boolean(unlinkError &&
75
+ typeof unlinkError === "object" &&
76
+ "code" in unlinkError &&
77
+ unlinkError.code === "ENOENT");
78
+ }
61
79
  }
62
80
  }
63
81
  /** Is a usable `tmux` on PATH? Cheap probe for the capability gate. */
@@ -389,12 +407,7 @@ export class TmuxTerminal {
389
407
  }
390
408
  /** Kill the tmux server (ends the session and all attaches). */
391
409
  kill() {
392
- try {
393
- execFileSync(this.tmuxBin, [...this.base(), "kill-server"], { stdio: "ignore" });
394
- }
395
- catch {
396
- // Already gone — nothing to clean up.
397
- }
410
+ terminateTmuxServer(this.name, this.tmuxBin);
398
411
  this.started = false;
399
412
  }
400
413
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rynx-ai/runtime",
3
- "version": "0.1.11-beta.6",
3
+ "version": "0.1.11-beta.8",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/rynx-ai/rynx.git",
@@ -24,9 +24,9 @@
24
24
  }
25
25
  },
26
26
  "dependencies": {
27
- "node-pty": "^1.0.0",
27
+ "node-pty": "1.2.0-beta.15",
28
28
  "ws": "^8.21.0",
29
- "@rynx-ai/core": "0.1.11-beta.6"
29
+ "@rynx-ai/core": "0.1.11-beta.8"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/ws": "^8.18.1"