@rynx-ai/runtime 0.1.0 → 0.1.9
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/executor.d.ts +3 -5
- package/dist/claude/executor.js +3 -5
- package/dist/claude/native-bridge.d.ts +74 -17
- package/dist/claude/native-bridge.js +225 -30
- package/dist/claude/native-hook-main.js +291 -39
- package/dist/claude/native-hooks.d.ts +3 -2
- package/dist/claude/native-hooks.js +15 -6
- package/dist/claude/native-integration.d.ts +78 -5
- package/dist/claude/native-integration.js +417 -26
- package/dist/claude/settings.d.ts +8 -0
- package/dist/claude/settings.js +50 -0
- package/dist/claude/transcript.d.ts +2 -2
- package/dist/claude/transcript.js +3 -3
- package/dist/codex/rollout-synth.js +1 -1
- package/dist/codex-app-server/client.d.ts +26 -40
- package/dist/codex-app-server/client.js +1128 -99
- package/dist/codex-app-server/forwarder.d.ts +7 -7
- package/dist/codex-app-server/forwarder.js +11 -5
- package/dist/codex-app-server/mapping.d.ts +1 -1
- package/dist/codex-app-server/mapping.js +27 -2
- package/dist/codex-app-server/protocol.d.ts +238 -4
- package/dist/codex-app-server/transport.d.ts +20 -5
- package/dist/codex-app-server/transport.js +93 -40
- package/dist/codex-app-server/ws-channel.d.ts +3 -3
- package/dist/codex-app-server/ws-channel.js +23 -7
- package/dist/codex-child-env.js +33 -0
- package/dist/codex-home.d.ts +6 -6
- package/dist/codex-home.js +8 -9
- package/dist/codex-session-store.d.ts +2 -1
- package/dist/host.d.ts +34 -33
- package/dist/host.js +531 -91
- package/dist/index.d.ts +4 -3
- package/dist/index.js +1 -1
- package/dist/interactions.d.ts +61 -0
- package/dist/interactions.js +236 -0
- package/dist/models-catalog.d.ts +1 -1
- package/dist/models-catalog.js +1 -1
- package/dist/runner/child.d.ts +9 -1
- package/dist/runner/child.js +93 -15
- package/dist/runner/manager.d.ts +59 -10
- package/dist/runner/manager.js +385 -41
- package/dist/runner/protocol.d.ts +18 -7
- package/dist/runner-main.js +9 -6
- package/dist/runtime-status.js +1 -1
- package/dist/terminal/claude-tui.d.ts +8 -3
- package/dist/terminal/claude-tui.js +6 -2
- package/dist/terminal/codex-tui.d.ts +3 -3
- package/dist/terminal/codex-tui.js +1 -1
- package/dist/terminal/registry.d.ts +1 -1
- package/dist/terminal/registry.js +1 -1
- package/dist/terminal/tmux.d.ts +6 -6
- package/dist/terminal/tmux.js +10 -10
- package/package.json +3 -3
package/dist/runner/manager.js
CHANGED
|
@@ -28,17 +28,40 @@ import { StdioRunnerTransport } from "./transport.js";
|
|
|
28
28
|
const CAP_KEY = "__cap__";
|
|
29
29
|
/** Max stderr lines retained per handle for the crash exit-report tail. */
|
|
30
30
|
const STDERR_TAIL_LINES = 40;
|
|
31
|
+
/** Time allowed for a runner to exit after SIGTERM before SIGKILL escalation. */
|
|
32
|
+
const DEFAULT_SHUTDOWN_GRACE_MS = 5_000;
|
|
33
|
+
/** Time allowed for exit after SIGKILL before shutdown reports failure. */
|
|
34
|
+
const DEFAULT_SHUTDOWN_KILL_GRACE_MS = 5_000;
|
|
35
|
+
/** Keep per-Session spawn context small enough to remain an environment handoff,
|
|
36
|
+
* not an unbounded transport. */
|
|
37
|
+
const MAX_SESSION_CONTEXT_ENV_ENTRIES = 32;
|
|
38
|
+
const MAX_SESSION_CONTEXT_ENV_KEY_BYTES = 128;
|
|
39
|
+
const MAX_SESSION_CONTEXT_ENV_VALUE_BYTES = 8_192;
|
|
40
|
+
const MAX_SESSION_CONTEXT_ENV_TOTAL_BYTES = 32_768;
|
|
41
|
+
/** A typed attach failure so transport adapters can distinguish an expected
|
|
42
|
+
* absent pane from an infrastructure failure without matching error strings. */
|
|
43
|
+
export class TerminalOpenError extends Error {
|
|
44
|
+
code;
|
|
45
|
+
name = "TerminalOpenError";
|
|
46
|
+
constructor(message, code) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.code = code;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
31
51
|
class ManagedTerminal {
|
|
32
52
|
attachId;
|
|
33
53
|
sendMsg;
|
|
54
|
+
onClose;
|
|
34
55
|
ready;
|
|
35
56
|
dataListener;
|
|
36
57
|
exitListener;
|
|
37
58
|
resolveReady;
|
|
38
59
|
rejectReady;
|
|
39
|
-
|
|
60
|
+
state = "opening";
|
|
61
|
+
constructor(attachId, sendMsg, onClose) {
|
|
40
62
|
this.attachId = attachId;
|
|
41
63
|
this.sendMsg = sendMsg;
|
|
64
|
+
this.onClose = onClose;
|
|
42
65
|
this.ready = new Promise((resolve, reject) => {
|
|
43
66
|
this.resolveReady = resolve;
|
|
44
67
|
this.rejectReady = reject;
|
|
@@ -59,21 +82,49 @@ class ManagedTerminal {
|
|
|
59
82
|
this.sendMsg({ t: "term.resize", attachId: this.attachId, cols, rows });
|
|
60
83
|
}
|
|
61
84
|
close() {
|
|
85
|
+
if (this.state === "closed")
|
|
86
|
+
return;
|
|
87
|
+
const wasOpening = this.state === "opening";
|
|
88
|
+
this.state = "closed";
|
|
62
89
|
this.sendMsg({ t: "term.close", attachId: this.attachId });
|
|
90
|
+
this.onClose();
|
|
91
|
+
if (wasOpening) {
|
|
92
|
+
this.rejectReady(new TerminalOpenError("terminal attachment was closed while opening", "terminal_open_failed"));
|
|
93
|
+
}
|
|
63
94
|
}
|
|
64
95
|
// ── internal (driven by onChildMessage) ──
|
|
65
96
|
_opened(role) {
|
|
97
|
+
if (this.state !== "opening")
|
|
98
|
+
return;
|
|
99
|
+
this.state = "opened";
|
|
66
100
|
this.resolveReady({ role });
|
|
67
101
|
}
|
|
68
102
|
_data(dataB64) {
|
|
69
103
|
this.dataListener?.(Buffer.from(dataB64, "base64").toString("utf8"));
|
|
70
104
|
}
|
|
71
105
|
_exit(exitCode) {
|
|
72
|
-
this.
|
|
106
|
+
if (this.state === "closed")
|
|
107
|
+
return;
|
|
108
|
+
const wasOpening = this.state === "opening";
|
|
109
|
+
this.state = "closed";
|
|
110
|
+
if (wasOpening) {
|
|
111
|
+
this.rejectReady(new TerminalOpenError(`terminal exited before opening (code=${exitCode})`, "terminal_open_failed"));
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
this.exitListener?.({ exitCode });
|
|
115
|
+
}
|
|
73
116
|
}
|
|
74
|
-
_fail(message) {
|
|
75
|
-
this.
|
|
76
|
-
|
|
117
|
+
_fail(message, code = "terminal_open_failed") {
|
|
118
|
+
if (this.state === "closed")
|
|
119
|
+
return;
|
|
120
|
+
const wasOpening = this.state === "opening";
|
|
121
|
+
this.state = "closed";
|
|
122
|
+
if (wasOpening) {
|
|
123
|
+
this.rejectReady(new TerminalOpenError(message, code));
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
this.exitListener?.({ exitCode: 1 });
|
|
127
|
+
}
|
|
77
128
|
}
|
|
78
129
|
}
|
|
79
130
|
export class RunnerManager {
|
|
@@ -83,16 +134,26 @@ export class RunnerManager {
|
|
|
83
134
|
idleTtlMs;
|
|
84
135
|
spawn;
|
|
85
136
|
childEnv;
|
|
137
|
+
sessionContextProvider;
|
|
86
138
|
now;
|
|
87
139
|
defaultRuntime;
|
|
88
140
|
handles = new Map();
|
|
141
|
+
/** Every spawned child that has not exited (or failed to spawn), including
|
|
142
|
+
* handles already removed from routing by stopRunner/idle reap. */
|
|
143
|
+
childHandles = new Set();
|
|
89
144
|
reapTimer;
|
|
145
|
+
shutdownGraceMs;
|
|
146
|
+
shutdownKillGraceMs;
|
|
147
|
+
stopping = false;
|
|
148
|
+
stopPromise;
|
|
90
149
|
/** Sink for mirrored {@link SessionEvent}s from every session's forwarder. */
|
|
91
150
|
mirrorListener = null;
|
|
92
151
|
/** Sink for session rotations (claude `/clear`·`/fork`) — server records meta. */
|
|
93
152
|
rotateListener = null;
|
|
94
153
|
/** Session keys with a live codex forwarder — never reaped while present. */
|
|
95
154
|
liveSessionKeys = new Set();
|
|
155
|
+
/** Last live-start error per local session, surfaced by the control API. */
|
|
156
|
+
liveErrors = new Map();
|
|
96
157
|
constructor(opts) {
|
|
97
158
|
this.config = opts.config;
|
|
98
159
|
this.sessionStore = opts.sessionStore;
|
|
@@ -100,8 +161,11 @@ export class RunnerManager {
|
|
|
100
161
|
this.idleTtlMs = opts.idleTtlMs ?? 300_000;
|
|
101
162
|
this.spawn = opts.spawn ?? nodeSpawn;
|
|
102
163
|
this.childEnv = opts.childEnv ?? {};
|
|
164
|
+
this.sessionContextProvider = opts.sessionContextProvider;
|
|
103
165
|
this.now = opts.now ?? (() => Date.now());
|
|
104
166
|
this.defaultRuntime = opts.config.AGENT_RUNTIME ?? "codex";
|
|
167
|
+
this.shutdownGraceMs = Math.max(0, opts.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS);
|
|
168
|
+
this.shutdownKillGraceMs = Math.max(0, opts.shutdownKillGraceMs ?? DEFAULT_SHUTDOWN_KILL_GRACE_MS);
|
|
105
169
|
const reapIntervalMs = opts.reapIntervalMs ?? 60_000;
|
|
106
170
|
if (reapIntervalMs > 0) {
|
|
107
171
|
this.reapTimer = setInterval(() => this.reapIdle(), reapIntervalMs);
|
|
@@ -121,9 +185,24 @@ export class RunnerManager {
|
|
|
121
185
|
*/
|
|
122
186
|
openTerminal(localThreadId, opts) {
|
|
123
187
|
const handle = this.getOrSpawn(localThreadId);
|
|
188
|
+
return this.openTerminalOnHandle(handle, localThreadId, opts);
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Attach to a Session runner that is already live. Unlike {@link openTerminal},
|
|
192
|
+
* this never creates a child, including when liveness changes between lookup
|
|
193
|
+
* and attach.
|
|
194
|
+
*/
|
|
195
|
+
openLiveTerminal(localThreadId, opts) {
|
|
196
|
+
const handle = this.handles.get(localThreadId);
|
|
197
|
+
if (!handle || handle.dead || !this.liveSessionKeys.has(localThreadId)) {
|
|
198
|
+
throw new TerminalOpenError("terminal not live", "terminal_not_live");
|
|
199
|
+
}
|
|
200
|
+
return this.openTerminalOnHandle(handle, localThreadId, opts);
|
|
201
|
+
}
|
|
202
|
+
openTerminalOnHandle(handle, localThreadId, opts) {
|
|
124
203
|
handle.lastUsedAt = this.now();
|
|
125
204
|
const attachId = randomUUID();
|
|
126
|
-
const terminal = new ManagedTerminal(attachId, (msg) => handle.transport.send(msg));
|
|
205
|
+
const terminal = new ManagedTerminal(attachId, (msg) => handle.transport.send(msg), () => handle.terminals.delete(attachId));
|
|
127
206
|
handle.terminals.set(attachId, terminal);
|
|
128
207
|
handle.transport.send({
|
|
129
208
|
t: "term.open",
|
|
@@ -140,6 +219,11 @@ export class RunnerManager {
|
|
|
140
219
|
});
|
|
141
220
|
return terminal;
|
|
142
221
|
}
|
|
222
|
+
/** Whether this process already owns a live runner for the Session. Read-only; never spawns. */
|
|
223
|
+
hasLiveSession(localThreadId) {
|
|
224
|
+
const handle = this.handles.get(localThreadId);
|
|
225
|
+
return Boolean(handle && !handle.dead && this.liveSessionKeys.has(localThreadId));
|
|
226
|
+
}
|
|
143
227
|
/**
|
|
144
228
|
* Register the sink for mirrored {@link SessionEvent}s produced by every
|
|
145
229
|
* session's persistent codex forwarder (web- AND TUI-initiated turns). The
|
|
@@ -165,7 +249,16 @@ export class RunnerManager {
|
|
|
165
249
|
this.liveSessionKeys.add(localThreadId);
|
|
166
250
|
const reqId = randomUUID();
|
|
167
251
|
return new Promise((resolve) => {
|
|
168
|
-
handle.live.set(reqId, (res) =>
|
|
252
|
+
handle.live.set(reqId, (res) => {
|
|
253
|
+
const ok = res.ok ?? false;
|
|
254
|
+
if (ok) {
|
|
255
|
+
this.liveErrors.delete(localThreadId);
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
this.liveErrors.set(localThreadId, res.error ?? "live session did not become ready");
|
|
259
|
+
}
|
|
260
|
+
resolve(ok);
|
|
261
|
+
});
|
|
169
262
|
handle.transport.send({
|
|
170
263
|
t: "live.ensure",
|
|
171
264
|
reqId,
|
|
@@ -174,13 +267,17 @@ export class RunnerManager {
|
|
|
174
267
|
...(opts?.cols ? { cols: opts.cols } : {}),
|
|
175
268
|
...(opts?.rows ? { rows: opts.rows } : {}),
|
|
176
269
|
...(opts?.runtime ? { runtime: opts.runtime } : {}),
|
|
270
|
+
...(opts?.reasoningEffort ? { reasoningEffort: opts.reasoningEffort } : {}),
|
|
177
271
|
...(opts?.agentName ? { agentName: opts.agentName } : {}),
|
|
178
272
|
...(opts?.agentSpec ? { agentSpec: opts.agentSpec } : {}),
|
|
179
273
|
});
|
|
180
274
|
});
|
|
181
275
|
}
|
|
276
|
+
lastLiveSessionError(localThreadId) {
|
|
277
|
+
return this.liveErrors.get(localThreadId);
|
|
278
|
+
}
|
|
182
279
|
/**
|
|
183
|
-
* Inject a user turn into a session's live codex thread —
|
|
280
|
+
* Inject a user turn into a session's live codex thread — reference implementation's
|
|
184
281
|
* single-writer web send (`turn/start` / `turn/steer`); the forwarder mirrors
|
|
185
282
|
* all output. Resolves true when the app-server accepted the turn, false when
|
|
186
283
|
* the session has no live forwarder (caller falls back to the run path).
|
|
@@ -210,17 +307,29 @@ export class RunnerManager {
|
|
|
210
307
|
handle.transport.send({ t: "live.interrupt", reqId, localThreadId });
|
|
211
308
|
});
|
|
212
309
|
}
|
|
213
|
-
/**
|
|
214
|
-
|
|
215
|
-
* child (Phase D). Best-effort: no-op if the runner isn't live (the pending
|
|
216
|
-
* approval would have died with it). The child routes it to its codex client.
|
|
217
|
-
*/
|
|
218
|
-
resolveApproval(localThreadId, approvalId, decision) {
|
|
310
|
+
/** Resolve a native question/approval without ever spawning a new runner. */
|
|
311
|
+
resolveInteraction(localThreadId, interactionId, resolution) {
|
|
219
312
|
const handle = this.handles.get(localThreadId);
|
|
220
313
|
if (!handle || handle.dead) {
|
|
221
|
-
return;
|
|
314
|
+
return Promise.resolve({ disposition: "not_found" });
|
|
222
315
|
}
|
|
223
|
-
handle.
|
|
316
|
+
handle.lastUsedAt = this.now();
|
|
317
|
+
const reqId = randomUUID();
|
|
318
|
+
return new Promise((resolve) => {
|
|
319
|
+
handle.live.set(reqId, (reply) => {
|
|
320
|
+
resolve(reply.interactionResult ?? {
|
|
321
|
+
disposition: "invalid",
|
|
322
|
+
message: reply.error ?? "runner did not return an interaction result",
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
handle.transport.send({
|
|
326
|
+
t: "interaction.resolve",
|
|
327
|
+
reqId,
|
|
328
|
+
localThreadId,
|
|
329
|
+
interactionId,
|
|
330
|
+
resolution,
|
|
331
|
+
});
|
|
332
|
+
});
|
|
224
333
|
}
|
|
225
334
|
// ── AgentCapabilities ──────────────────────────────────────────────────────
|
|
226
335
|
async listModels(runtime) {
|
|
@@ -254,20 +363,35 @@ export class RunnerManager {
|
|
|
254
363
|
if (!handle) {
|
|
255
364
|
return;
|
|
256
365
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
366
|
+
void this.terminateHandle(handle, "runner stopped").catch((error) => {
|
|
367
|
+
logTerminationFailure(handle, error);
|
|
368
|
+
});
|
|
260
369
|
}
|
|
261
|
-
/**
|
|
262
|
-
|
|
370
|
+
/** Stop every runner and join all child exits. Idempotent across concurrent calls. */
|
|
371
|
+
stop() {
|
|
372
|
+
if (this.stopPromise)
|
|
373
|
+
return this.stopPromise;
|
|
374
|
+
this.stopping = true;
|
|
263
375
|
if (this.reapTimer) {
|
|
264
376
|
clearInterval(this.reapTimer);
|
|
265
377
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
378
|
+
const children = [...this.childHandles];
|
|
379
|
+
this.stopPromise = (async () => {
|
|
380
|
+
const results = await Promise.allSettled(children.map((handle) => this.terminateHandle(handle, "runner manager stopped")));
|
|
381
|
+
this.handles.clear();
|
|
382
|
+
this.liveSessionKeys.clear();
|
|
383
|
+
this.liveErrors.clear();
|
|
384
|
+
this.mirrorListener = null;
|
|
385
|
+
this.rotateListener = null;
|
|
386
|
+
const errors = results
|
|
387
|
+
.filter((result) => result.status === "rejected")
|
|
388
|
+
.map((result) => result.reason);
|
|
389
|
+
if (errors.length > 0) {
|
|
390
|
+
throw new AggregateError(errors, "runner manager shutdown failed");
|
|
391
|
+
}
|
|
392
|
+
this.childHandles.clear();
|
|
393
|
+
})();
|
|
394
|
+
return this.stopPromise;
|
|
271
395
|
}
|
|
272
396
|
// ── internals ──────────────────────────────────────────────────────────────
|
|
273
397
|
/** Forward a capability to a runner child. Session-less caps (listModels/status)
|
|
@@ -283,6 +407,9 @@ export class RunnerManager {
|
|
|
283
407
|
});
|
|
284
408
|
}
|
|
285
409
|
getOrSpawn(key) {
|
|
410
|
+
if (this.stopping) {
|
|
411
|
+
throw new AgentRuntimeError("runner manager is stopped", 503, "runner_stopped");
|
|
412
|
+
}
|
|
286
413
|
this.reapIdle();
|
|
287
414
|
const existing = this.handles.get(key);
|
|
288
415
|
if (existing && !existing.dead) {
|
|
@@ -297,13 +424,29 @@ export class RunnerManager {
|
|
|
297
424
|
const args = this.runnerEntry.endsWith(".ts")
|
|
298
425
|
? ["--import", "tsx", this.runnerEntry]
|
|
299
426
|
: [this.runnerEntry];
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
427
|
+
const sessionContext = key === CAP_KEY
|
|
428
|
+
? undefined
|
|
429
|
+
: this.openSessionContext(key);
|
|
430
|
+
let child;
|
|
431
|
+
try {
|
|
432
|
+
child = this.spawn(process.execPath, args, {
|
|
433
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
434
|
+
// `RYNX_RUNNER_SESSION` = the child's session key (localThreadId, or `__cap__`
|
|
435
|
+
// for the shared capability child) so its LocalAgentHost scopes the private
|
|
436
|
+
// CODEX_HOME to this session. It is deliberately applied last.
|
|
437
|
+
env: {
|
|
438
|
+
...process.env,
|
|
439
|
+
...this.childEnv,
|
|
440
|
+
...(sessionContext?.childEnv ?? {}),
|
|
441
|
+
RYNX_RUNNER_SESSION: key,
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
catch (error) {
|
|
446
|
+
closeSessionContext(sessionContext);
|
|
447
|
+
throw error;
|
|
448
|
+
}
|
|
449
|
+
const completion = observeChildCompletion(child);
|
|
307
450
|
const transport = new StdioRunnerTransport(child.stdout, child.stdin);
|
|
308
451
|
const handle = {
|
|
309
452
|
key,
|
|
@@ -312,10 +455,14 @@ export class RunnerManager {
|
|
|
312
455
|
stderr: [],
|
|
313
456
|
lastUsedAt: this.now(),
|
|
314
457
|
dead: false,
|
|
458
|
+
completion,
|
|
459
|
+
...(sessionContext ? { sessionContext } : {}),
|
|
315
460
|
caps: new Map(),
|
|
316
461
|
terminals: new Map(),
|
|
317
462
|
live: new Map(),
|
|
318
463
|
};
|
|
464
|
+
this.childHandles.add(handle);
|
|
465
|
+
void completion.then(() => this.childHandles.delete(handle));
|
|
319
466
|
transport.onMessage((msg) => this.onChildMessage(handle, msg));
|
|
320
467
|
if (child.stderr) {
|
|
321
468
|
const rl = readline.createInterface({ input: child.stderr, crlfDelay: Infinity });
|
|
@@ -363,13 +510,28 @@ export class RunnerManager {
|
|
|
363
510
|
case "term.error": {
|
|
364
511
|
const terminal = handle.terminals.get(msg.attachId);
|
|
365
512
|
handle.terminals.delete(msg.attachId);
|
|
366
|
-
terminal?._fail(msg.message);
|
|
513
|
+
terminal?._fail(msg.message, msg.code);
|
|
367
514
|
return;
|
|
368
515
|
}
|
|
369
516
|
case "mirror":
|
|
370
517
|
this.mirrorListener?.(msg.sessionId, msg.event);
|
|
371
518
|
return;
|
|
372
519
|
case "rotate": {
|
|
520
|
+
if (handle.dead)
|
|
521
|
+
return;
|
|
522
|
+
try {
|
|
523
|
+
// Rotate the runtime-local context before publishing the new Session
|
|
524
|
+
// alias to routing or observers. This prevents the new identity from
|
|
525
|
+
// briefly inheriting stale context after `/clear` or `/fork`.
|
|
526
|
+
handle.sessionContext?.rotate(msg.to);
|
|
527
|
+
}
|
|
528
|
+
catch (error) {
|
|
529
|
+
const reason = `runner Session context rotation failed: ${errorMessage(error)}`;
|
|
530
|
+
void this.terminateHandle(handle, reason).catch((terminationError) => {
|
|
531
|
+
logTerminationFailure(handle, terminationError);
|
|
532
|
+
});
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
373
535
|
// Terminal transfer: alias the new session to THIS runner so its injection
|
|
374
536
|
// (and live/approval) route to the same child that still owns the pane.
|
|
375
537
|
this.handles.set(msg.to, handle);
|
|
@@ -395,6 +557,12 @@ export class RunnerManager {
|
|
|
395
557
|
: { ok: msg.ok, error: msg.error });
|
|
396
558
|
return;
|
|
397
559
|
}
|
|
560
|
+
case "interaction.resolved": {
|
|
561
|
+
const resolve = handle.live.get(msg.reqId);
|
|
562
|
+
handle.live.delete(msg.reqId);
|
|
563
|
+
resolve?.({ interactionResult: msg.result });
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
398
566
|
}
|
|
399
567
|
}
|
|
400
568
|
/** Mark a handle dead and reject every pending run/cap with the exit reason. */
|
|
@@ -403,11 +571,14 @@ export class RunnerManager {
|
|
|
403
571
|
return;
|
|
404
572
|
}
|
|
405
573
|
handle.dead = true;
|
|
574
|
+
closeOwnedSessionContext(handle);
|
|
406
575
|
// Drop every key mapping to this handle — its launch key AND any rotation
|
|
407
576
|
// aliases (claude `/clear`·`/fork` terminal transfer).
|
|
408
577
|
for (const [key, h] of this.handles) {
|
|
409
|
-
if (h === handle)
|
|
578
|
+
if (h === handle) {
|
|
410
579
|
this.handles.delete(key);
|
|
580
|
+
this.liveSessionKeys.delete(key);
|
|
581
|
+
}
|
|
411
582
|
}
|
|
412
583
|
const tail = handle.stderr.join("\n");
|
|
413
584
|
const message = tail ? `${reason}\n--- runner log tail ---\n${tail}` : reason;
|
|
@@ -424,9 +595,32 @@ export class RunnerManager {
|
|
|
424
595
|
handle.caps.clear();
|
|
425
596
|
handle.terminals.clear();
|
|
426
597
|
handle.live.clear();
|
|
427
|
-
this.liveSessionKeys.delete(handle.key);
|
|
428
598
|
handle.transport.close();
|
|
429
599
|
}
|
|
600
|
+
terminateHandle(handle, reason) {
|
|
601
|
+
if (handle.termination)
|
|
602
|
+
return handle.termination;
|
|
603
|
+
handle.termination = (async () => {
|
|
604
|
+
if (!handle.dead) {
|
|
605
|
+
try {
|
|
606
|
+
handle.transport.send({ t: "shutdown" });
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
// The process signal below remains the authoritative shutdown path.
|
|
610
|
+
}
|
|
611
|
+
this.failHandle(handle, reason);
|
|
612
|
+
}
|
|
613
|
+
signalChild(handle.child, "SIGTERM");
|
|
614
|
+
if (!(await waitForChildExit(handle.completion, this.shutdownGraceMs))) {
|
|
615
|
+
signalChild(handle.child, "SIGKILL");
|
|
616
|
+
if (!(await waitForChildExit(handle.completion, this.shutdownKillGraceMs))) {
|
|
617
|
+
throw new Error(`runner child ${handle.child.pid ?? handle.key} did not exit after SIGKILL`);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
this.childHandles.delete(handle);
|
|
621
|
+
})();
|
|
622
|
+
return handle.termination;
|
|
623
|
+
}
|
|
430
624
|
reapIdle() {
|
|
431
625
|
const now = this.now();
|
|
432
626
|
for (const [key, handle] of this.handles) {
|
|
@@ -441,14 +635,106 @@ export class RunnerManager {
|
|
|
441
635
|
if (now - handle.lastUsedAt < this.idleTtlMs) {
|
|
442
636
|
continue;
|
|
443
637
|
}
|
|
444
|
-
handle.
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
638
|
+
void this.terminateHandle(handle, "idle runner reaped").catch((error) => {
|
|
639
|
+
logTerminationFailure(handle, error);
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
openSessionContext(sessionId) {
|
|
644
|
+
if (!this.sessionContextProvider)
|
|
645
|
+
return undefined;
|
|
646
|
+
const context = this.sessionContextProvider.open(sessionId);
|
|
647
|
+
try {
|
|
648
|
+
validateSessionContext(context);
|
|
649
|
+
return {
|
|
650
|
+
childEnv: Object.freeze({ ...context.childEnv }),
|
|
651
|
+
rotate: (newSessionId) => context.rotate(newSessionId),
|
|
652
|
+
close: () => context.close(),
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
catch (error) {
|
|
656
|
+
closeSessionContext(context);
|
|
657
|
+
throw error;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
function validateSessionContext(context) {
|
|
662
|
+
if (!context || typeof context !== "object") {
|
|
663
|
+
throw new Error("Session context environment provider must return an object");
|
|
664
|
+
}
|
|
665
|
+
if (typeof context.rotate !== "function" || typeof context.close !== "function") {
|
|
666
|
+
throw new Error("Session context environment must provide rotate() and close()");
|
|
667
|
+
}
|
|
668
|
+
if (!context.childEnv || typeof context.childEnv !== "object" || Array.isArray(context.childEnv)) {
|
|
669
|
+
throw new Error("Session context environment must be a record");
|
|
670
|
+
}
|
|
671
|
+
const entries = Object.entries(context.childEnv);
|
|
672
|
+
if (entries.length > MAX_SESSION_CONTEXT_ENV_ENTRIES) {
|
|
673
|
+
throw new Error(`Session context environment exceeds ${MAX_SESSION_CONTEXT_ENV_ENTRIES} entries`);
|
|
674
|
+
}
|
|
675
|
+
let totalBytes = 0;
|
|
676
|
+
for (const [key, value] of entries) {
|
|
677
|
+
const normalizedKey = key.toUpperCase();
|
|
678
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
|
679
|
+
throw new Error(`Session context environment key is invalid: ${JSON.stringify(key)}`);
|
|
680
|
+
}
|
|
681
|
+
if (isReservedSessionContextEnvKey(normalizedKey)) {
|
|
682
|
+
throw new Error(`Session context environment key is reserved: ${key}`);
|
|
683
|
+
}
|
|
684
|
+
if (typeof value !== "string" || value.includes("\0")) {
|
|
685
|
+
throw new Error(`Session context environment value is invalid for ${key}`);
|
|
686
|
+
}
|
|
687
|
+
const keyBytes = Buffer.byteLength(key);
|
|
688
|
+
const valueBytes = Buffer.byteLength(value);
|
|
689
|
+
if (keyBytes > MAX_SESSION_CONTEXT_ENV_KEY_BYTES) {
|
|
690
|
+
throw new Error(`Session context environment key is too large: ${key}`);
|
|
691
|
+
}
|
|
692
|
+
if (valueBytes > MAX_SESSION_CONTEXT_ENV_VALUE_BYTES) {
|
|
693
|
+
throw new Error(`Session context environment value is too large for ${key}`);
|
|
449
694
|
}
|
|
695
|
+
totalBytes += keyBytes + valueBytes;
|
|
696
|
+
if (totalBytes > MAX_SESSION_CONTEXT_ENV_TOTAL_BYTES) {
|
|
697
|
+
throw new Error("Session context environment is too large");
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
function isReservedSessionContextEnvKey(key) {
|
|
702
|
+
return (key.startsWith("RYNX_RUNNER_") ||
|
|
703
|
+
key === "NODE_OPTIONS" ||
|
|
704
|
+
key === "NODE_PATH" ||
|
|
705
|
+
key === "NODE_UNIQUE_ID" ||
|
|
706
|
+
key.startsWith("NODE_CHANNEL_") ||
|
|
707
|
+
key === "ELECTRON_RUN_AS_NODE" ||
|
|
708
|
+
key === "LD_PRELOAD" ||
|
|
709
|
+
key === "LD_LIBRARY_PATH" ||
|
|
710
|
+
key === "DYLD_INSERT_LIBRARIES" ||
|
|
711
|
+
key === "DYLD_LIBRARY_PATH");
|
|
712
|
+
}
|
|
713
|
+
function closeOwnedSessionContext(handle) {
|
|
714
|
+
const context = handle.sessionContext;
|
|
715
|
+
if (!context)
|
|
716
|
+
return;
|
|
717
|
+
handle.sessionContext = undefined;
|
|
718
|
+
closeSessionContext(context);
|
|
719
|
+
}
|
|
720
|
+
function closeSessionContext(context) {
|
|
721
|
+
if (!context || typeof context.close !== "function")
|
|
722
|
+
return;
|
|
723
|
+
try {
|
|
724
|
+
context.close();
|
|
725
|
+
}
|
|
726
|
+
catch (error) {
|
|
727
|
+
console.error(JSON.stringify({
|
|
728
|
+
level: "error",
|
|
729
|
+
type: "runner",
|
|
730
|
+
event: "session_context_close_failed",
|
|
731
|
+
error: errorMessage(error),
|
|
732
|
+
}));
|
|
450
733
|
}
|
|
451
734
|
}
|
|
735
|
+
function errorMessage(error) {
|
|
736
|
+
return error instanceof Error ? error.message : String(error);
|
|
737
|
+
}
|
|
452
738
|
function defaultRunnerEntry() {
|
|
453
739
|
if (process.env.RYNX_RUNNER_ENTRY?.trim()) {
|
|
454
740
|
return process.env.RYNX_RUNNER_ENTRY.trim();
|
|
@@ -456,3 +742,61 @@ function defaultRunnerEntry() {
|
|
|
456
742
|
// dist/runner/manager.js → dist/runner-main.js
|
|
457
743
|
return fileURLToPath(new URL("../runner-main.js", import.meta.url));
|
|
458
744
|
}
|
|
745
|
+
function observeChildCompletion(child) {
|
|
746
|
+
if (childHasExited(child))
|
|
747
|
+
return Promise.resolve();
|
|
748
|
+
return new Promise((resolve) => {
|
|
749
|
+
const onExit = () => {
|
|
750
|
+
child.off("error", onSpawnError);
|
|
751
|
+
resolve();
|
|
752
|
+
};
|
|
753
|
+
const onSpawnError = () => {
|
|
754
|
+
// A successfully spawned process may emit `error` when a later kill fails;
|
|
755
|
+
// that is not completion. A spawn failure has no pid and no future exit.
|
|
756
|
+
if (child.pid !== undefined)
|
|
757
|
+
return;
|
|
758
|
+
child.off("exit", onExit);
|
|
759
|
+
resolve();
|
|
760
|
+
};
|
|
761
|
+
child.once("error", onSpawnError);
|
|
762
|
+
child.once("exit", onExit);
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
function signalChild(child, signal) {
|
|
766
|
+
if (childHasExited(child))
|
|
767
|
+
return;
|
|
768
|
+
try {
|
|
769
|
+
child.kill(signal);
|
|
770
|
+
}
|
|
771
|
+
catch {
|
|
772
|
+
// The bounded completion wait distinguishes an exit race from a stuck child.
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
function childHasExited(child) {
|
|
776
|
+
return ((child.exitCode !== null && child.exitCode !== undefined) ||
|
|
777
|
+
(child.signalCode !== null && child.signalCode !== undefined));
|
|
778
|
+
}
|
|
779
|
+
function waitForChildExit(completion, timeoutMs) {
|
|
780
|
+
return new Promise((resolve) => {
|
|
781
|
+
let settled = false;
|
|
782
|
+
const finish = (exited) => {
|
|
783
|
+
if (settled)
|
|
784
|
+
return;
|
|
785
|
+
settled = true;
|
|
786
|
+
clearTimeout(timer);
|
|
787
|
+
resolve(exited);
|
|
788
|
+
};
|
|
789
|
+
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
790
|
+
void completion.then(() => finish(true));
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
function logTerminationFailure(handle, error) {
|
|
794
|
+
console.error(JSON.stringify({
|
|
795
|
+
level: "error",
|
|
796
|
+
type: "runner",
|
|
797
|
+
event: "termination_failed",
|
|
798
|
+
key: handle.key,
|
|
799
|
+
pid: handle.child.pid,
|
|
800
|
+
error: error instanceof Error ? error.message : String(error),
|
|
801
|
+
}));
|
|
802
|
+
}
|