@rynx-ai/runtime 0.1.11-beta.2 → 0.1.11-beta.4
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/host.d.ts +1 -0
- package/dist/host.js +15 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/runner/child.d.ts +5 -0
- package/dist/runner/child.js +72 -1
- package/dist/runner/manager.d.ts +52 -2
- package/dist/runner/manager.js +417 -39
- package/dist/terminal/tmux.d.ts +8 -0
- package/dist/terminal/tmux.js +36 -3
- package/package.json +2 -2
package/dist/host.d.ts
CHANGED
|
@@ -191,6 +191,7 @@ export declare class LocalAgentHost implements CodexCapabilities {
|
|
|
191
191
|
args: string[];
|
|
192
192
|
cwd: string;
|
|
193
193
|
env?: Record<string, string>;
|
|
194
|
+
skipTraexStartupPrompts?: boolean;
|
|
194
195
|
} | null>;
|
|
195
196
|
/**
|
|
196
197
|
* Bring up (idempotently) a session's persistent codex forwarder and emit its
|
package/dist/host.js
CHANGED
|
@@ -279,7 +279,7 @@ export class LocalAgentHost {
|
|
|
279
279
|
this.runtimeHomeSessionId = runtimeHomeSessionId ?? this.sessionId;
|
|
280
280
|
this.sessionStore = sessionStore;
|
|
281
281
|
this.allowedRoots = allowedRoots;
|
|
282
|
-
this.defaultRuntime = config.
|
|
282
|
+
this.defaultRuntime = config.DEFAULT_RUNTIME ?? "codex";
|
|
283
283
|
this.injectedCommandRunner = commandRunner;
|
|
284
284
|
this.injectedAppServerClient = appServerClient;
|
|
285
285
|
this.clock = now;
|
|
@@ -472,6 +472,9 @@ export class LocalAgentHost {
|
|
|
472
472
|
// to the same effective sandbox.
|
|
473
473
|
const sandbox = live?.sandbox ?? this.sessionSandbox ?? this.config.AGENT_SANDBOX;
|
|
474
474
|
const approvalPolicy = live?.approvalPolicy ?? this.sessionApprovalPolicy ?? this.config.AGENT_APPROVAL_POLICY;
|
|
475
|
+
const traexYolo = runtime === "traex" &&
|
|
476
|
+
sandbox === "danger-full-access" &&
|
|
477
|
+
approvalPolicy === "never";
|
|
475
478
|
const configOverrides = sandbox === "workspace-write"
|
|
476
479
|
? ["sandbox_workspace_write.network_access=true"]
|
|
477
480
|
: [];
|
|
@@ -500,9 +503,11 @@ export class LocalAgentHost {
|
|
|
500
503
|
remoteUrl,
|
|
501
504
|
...(activeThreadId ? { threadId: activeThreadId } : {}),
|
|
502
505
|
configOverrides,
|
|
506
|
+
codexArgs: traexYolo ? ["--dangerously-bypass-hook-trust"] : [],
|
|
503
507
|
additionalDirs: providerAdditionalDirs(live.workspace),
|
|
504
508
|
}),
|
|
505
509
|
cwd: live.workspace.cwd,
|
|
510
|
+
...(runtime === "traex" ? { skipTraexStartupPrompts: true } : {}),
|
|
506
511
|
// Share the app-server's private CODEX_HOME so the TUI inherits the same
|
|
507
512
|
// login/settings and skips the real home's update/NUX prompt. RYNX_SESSION_ID
|
|
508
513
|
// scopes agent-run CLIs (rynx-emulator) to this session at the daemon.
|
|
@@ -1633,8 +1638,16 @@ export class LocalAgentHost {
|
|
|
1633
1638
|
throw failedPluginSkill.reason;
|
|
1634
1639
|
}
|
|
1635
1640
|
const pluginSkills = materializedPluginSkills.flatMap((result) => result.status === "fulfilled" ? result.value : []);
|
|
1641
|
+
const selectedSkills = [...skillEnv.resolved, ...pluginSkills];
|
|
1642
|
+
const selectedNames = new Set();
|
|
1643
|
+
for (const skill of selectedSkills) {
|
|
1644
|
+
if (selectedNames.has(skill.name)) {
|
|
1645
|
+
throw new Error(`duplicate final Session skill name: ${skill.name}`);
|
|
1646
|
+
}
|
|
1647
|
+
selectedNames.add(skill.name);
|
|
1648
|
+
}
|
|
1636
1649
|
return {
|
|
1637
|
-
selectedSkills
|
|
1650
|
+
selectedSkills,
|
|
1638
1651
|
skillsCleanup: () => rm(sessionSkillsDir, { recursive: true, force: true }),
|
|
1639
1652
|
};
|
|
1640
1653
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -13,6 +13,6 @@ export type { InjectOutcome, TerminalOpenErrorCode, TerminalRole } from "./runne
|
|
|
13
13
|
export type { ResolveInteractionResult, RuntimeInteractionEvent, RuntimeInteractionListener, } from "./interactions.js";
|
|
14
14
|
export { probeRuntimeStatus } from "./runtime-status.js";
|
|
15
15
|
export { listRuntimeModels } from "./models-catalog.js";
|
|
16
|
-
export { TmuxTerminal, isTmuxAvailable } from "./terminal/tmux.js";
|
|
16
|
+
export { TmuxTerminal, isTmuxAvailable, terminateTmuxServer, tmuxSocketPath, } from "./terminal/tmux.js";
|
|
17
17
|
export type { TerminalAttachment, TmuxTerminalOptions } from "./terminal/tmux.js";
|
|
18
18
|
export { TerminalRegistry } from "./terminal/registry.js";
|
package/dist/index.js
CHANGED
|
@@ -12,5 +12,5 @@ export { RunnerManager, TerminalOpenError } from "./runner/manager.js";
|
|
|
12
12
|
export { probeRuntimeStatus } from "./runtime-status.js";
|
|
13
13
|
export { listRuntimeModels } from "./models-catalog.js";
|
|
14
14
|
// Live-terminal subsystem (Phase C): tmux-backed terminals + per-runner registry.
|
|
15
|
-
export { TmuxTerminal, isTmuxAvailable } from "./terminal/tmux.js";
|
|
15
|
+
export { TmuxTerminal, isTmuxAvailable, terminateTmuxServer, tmuxSocketPath, } from "./terminal/tmux.js";
|
|
16
16
|
export { TerminalRegistry } from "./terminal/registry.js";
|
package/dist/runner/child.d.ts
CHANGED
|
@@ -31,6 +31,7 @@ interface LiveCodexProvider {
|
|
|
31
31
|
args: string[];
|
|
32
32
|
cwd: string;
|
|
33
33
|
env?: Record<string, string>;
|
|
34
|
+
skipTraexStartupPrompts?: boolean;
|
|
34
35
|
} | null>;
|
|
35
36
|
ensureLiveCodexSession?(localThreadId: string, emit: (event: SessionEvent) => void, opts: {
|
|
36
37
|
workspace: SessionWorkspaceSnapshot;
|
|
@@ -71,6 +72,8 @@ export declare class RunnerSession {
|
|
|
71
72
|
/** Live terminals hosted by this session, and per-attach client handles. */
|
|
72
73
|
private readonly terminals;
|
|
73
74
|
private readonly attachments;
|
|
75
|
+
private readonly attachmentThreadIds;
|
|
76
|
+
private readonly traexStartupWatchers;
|
|
74
77
|
/** Opens are async; a close received before attach resolves tombstones the id. */
|
|
75
78
|
private readonly pendingTerminalOpens;
|
|
76
79
|
private readonly cancelledTerminalOpens;
|
|
@@ -101,6 +104,8 @@ export declare class RunnerSession {
|
|
|
101
104
|
* `codexTerminalSpec`. Shares the `${id}-main` terminal id with `term.open`,
|
|
102
105
|
* so the web attach reuses the same detached pane. */
|
|
103
106
|
private launchCodexPane;
|
|
107
|
+
private cancelTraexStartupWatcher;
|
|
108
|
+
private skipTraexStartupPrompts;
|
|
104
109
|
private stopLive;
|
|
105
110
|
/** Stop event forwarding, kill native terminals/hooks, then synchronously
|
|
106
111
|
* scrub provider handoff files before the child process is allowed to exit. */
|
package/dist/runner/child.js
CHANGED
|
@@ -7,6 +7,8 @@ export class RunnerSession {
|
|
|
7
7
|
/** Live terminals hosted by this session, and per-attach client handles. */
|
|
8
8
|
terminals = new TerminalRegistry();
|
|
9
9
|
attachments = new Map();
|
|
10
|
+
attachmentThreadIds = new Map();
|
|
11
|
+
traexStartupWatchers = new Map();
|
|
10
12
|
/** Opens are async; a close received before attach resolves tombstones the id. */
|
|
11
13
|
pendingTerminalOpens = new Set();
|
|
12
14
|
cancelledTerminalOpens = new Set();
|
|
@@ -46,6 +48,7 @@ export class RunnerSession {
|
|
|
46
48
|
});
|
|
47
49
|
return;
|
|
48
50
|
case "term.input":
|
|
51
|
+
this.cancelTraexStartupWatcher(this.attachmentThreadIds.get(msg.attachId));
|
|
49
52
|
this.attachments.get(msg.attachId)?.write(Buffer.from(msg.dataB64, "base64").toString("utf8"));
|
|
50
53
|
return;
|
|
51
54
|
case "term.resize":
|
|
@@ -57,6 +60,7 @@ export class RunnerSession {
|
|
|
57
60
|
}
|
|
58
61
|
const attachment = this.attachments.get(msg.attachId);
|
|
59
62
|
this.attachments.delete(msg.attachId);
|
|
63
|
+
this.attachmentThreadIds.delete(msg.attachId);
|
|
60
64
|
attachment?.kill();
|
|
61
65
|
return;
|
|
62
66
|
}
|
|
@@ -211,6 +215,8 @@ export class RunnerSession {
|
|
|
211
215
|
try {
|
|
212
216
|
const input = msg.input ?? msg.text;
|
|
213
217
|
const outcome = (await provider.injectMessage?.(msg.localThreadId, input)) ?? "notLive";
|
|
218
|
+
if (outcome === "injected")
|
|
219
|
+
this.cancelTraexStartupWatcher(msg.localThreadId);
|
|
214
220
|
this.transport.send({ t: "injected", reqId: msg.reqId, localThreadId: msg.localThreadId, outcome });
|
|
215
221
|
}
|
|
216
222
|
catch (error) {
|
|
@@ -248,7 +254,9 @@ export class RunnerSession {
|
|
|
248
254
|
const spec = await this.liveProvider.codexTerminalSpec?.(localThreadId);
|
|
249
255
|
if (!spec)
|
|
250
256
|
return;
|
|
251
|
-
const
|
|
257
|
+
const terminalId = `${localThreadId}-main`;
|
|
258
|
+
const coldStart = !this.terminals.has(terminalId);
|
|
259
|
+
const term = this.terminals.getOrCreate(terminalId, {
|
|
252
260
|
cwd: spec.cwd,
|
|
253
261
|
command: spec.command,
|
|
254
262
|
args: spec.args,
|
|
@@ -256,8 +264,68 @@ export class RunnerSession {
|
|
|
256
264
|
rows: rows ?? 40,
|
|
257
265
|
...(spec.env ? { env: spec.env } : {}),
|
|
258
266
|
});
|
|
267
|
+
if (coldStart && spec.skipTraexStartupPrompts) {
|
|
268
|
+
const watcher = Symbol(localThreadId);
|
|
269
|
+
this.traexStartupWatchers.set(localThreadId, watcher);
|
|
270
|
+
void this.skipTraexStartupPrompts(localThreadId, term, watcher);
|
|
271
|
+
}
|
|
259
272
|
this.liveProvider.attachTerminalInjector?.(localThreadId, term);
|
|
260
273
|
}
|
|
274
|
+
cancelTraexStartupWatcher(localThreadId) {
|
|
275
|
+
if (localThreadId)
|
|
276
|
+
this.traexStartupWatchers.delete(localThreadId);
|
|
277
|
+
}
|
|
278
|
+
async skipTraexStartupPrompts(localThreadId, terminal, watcher) {
|
|
279
|
+
const prompts = [
|
|
280
|
+
{
|
|
281
|
+
id: "welcome",
|
|
282
|
+
matches: (pane) => pane.includes("Welcome to TRAE CLI") && pane.includes("Press enter to continue"),
|
|
283
|
+
dismiss: () => terminal.sendEnter(),
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
id: "migration",
|
|
287
|
+
matches: (pane) => pane.includes("Legacy TRAE CLI data detected") &&
|
|
288
|
+
pane.includes("Would you like to migrate detected data to the new TRAE CLI storage?") &&
|
|
289
|
+
pane.includes("Select what to import:"),
|
|
290
|
+
dismiss: () => terminal.interrupt(),
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
id: "hooks",
|
|
294
|
+
matches: (pane) => pane.includes("Hooks need review") &&
|
|
295
|
+
pane.includes("hooks are new or changed.") &&
|
|
296
|
+
pane.includes("Trust all and continue") &&
|
|
297
|
+
pane.includes("Continue without trusting"),
|
|
298
|
+
dismiss: () => terminal.interrupt(),
|
|
299
|
+
},
|
|
300
|
+
];
|
|
301
|
+
const dismissed = new Set();
|
|
302
|
+
const deadline = Date.now() + 20_000;
|
|
303
|
+
const terminalId = `${localThreadId}-main`;
|
|
304
|
+
while (!this.shuttingDown &&
|
|
305
|
+
Date.now() < deadline &&
|
|
306
|
+
this.traexStartupWatchers.get(localThreadId) === watcher &&
|
|
307
|
+
this.terminals.get(terminalId) === terminal) {
|
|
308
|
+
const pane = terminal.capturePane();
|
|
309
|
+
// Once the normal composer is visible, startup is complete and there is
|
|
310
|
+
// no reason to keep polling the tmux pane for the remainder of the window.
|
|
311
|
+
if (pane.includes("TRAE CLI Next"))
|
|
312
|
+
break;
|
|
313
|
+
const prompt = prompts.find((candidate) => candidate.matches(pane));
|
|
314
|
+
if (prompt && !dismissed.has(prompt.id)) {
|
|
315
|
+
prompt.dismiss();
|
|
316
|
+
dismissed.add(prompt.id);
|
|
317
|
+
if (dismissed.size === prompts.length)
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
await new Promise((resolve) => {
|
|
321
|
+
const timer = setTimeout(resolve, 100);
|
|
322
|
+
timer.unref();
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
if (this.traexStartupWatchers.get(localThreadId) === watcher) {
|
|
326
|
+
this.traexStartupWatchers.delete(localThreadId);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
261
329
|
stopLive() {
|
|
262
330
|
for (const id of this.liveIds) {
|
|
263
331
|
this.liveProvider.stopLiveCodexSession?.(id, {
|
|
@@ -336,6 +404,8 @@ export class RunnerSession {
|
|
|
336
404
|
return;
|
|
337
405
|
}
|
|
338
406
|
this.attachments.set(msg.attachId, attachment);
|
|
407
|
+
if (msg.localThreadId)
|
|
408
|
+
this.attachmentThreadIds.set(msg.attachId, msg.localThreadId);
|
|
339
409
|
attachment.onData((chunk) => this.transport.send({
|
|
340
410
|
t: "term.data",
|
|
341
411
|
attachId: msg.attachId,
|
|
@@ -343,6 +413,7 @@ export class RunnerSession {
|
|
|
343
413
|
}));
|
|
344
414
|
attachment.onExit((info) => {
|
|
345
415
|
this.attachments.delete(msg.attachId);
|
|
416
|
+
this.attachmentThreadIds.delete(msg.attachId);
|
|
346
417
|
this.transport.send({ t: "term.exit", attachId: msg.attachId, exitCode: info.exitCode });
|
|
347
418
|
});
|
|
348
419
|
this.transport.send({ t: "term.opened", attachId: msg.attachId, role });
|
package/dist/runner/manager.d.ts
CHANGED
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
* transport drops in here later without touching `ConversationRuntime` or any
|
|
16
16
|
* channel.
|
|
17
17
|
*/
|
|
18
|
-
import { spawn as nodeSpawn } from "node:child_process";
|
|
19
|
-
import { type AgentCapabilities, type AgentRuntimeId, type AppConfig, type CapabilityResult, type ModelListResponse, type ResolvedExecutionSnapshot, type RuntimeUserInput, type SessionInteractionResolution, type SessionEvent, type SessionWorkspaceSnapshot, type ThreadGoal } from "@rynx-ai/core";
|
|
18
|
+
import { spawn as nodeSpawn, type ChildProcess } from "node:child_process";
|
|
19
|
+
import { type AdmissionReservation, type AgentCapabilities, type AgentRuntimeId, type AppConfig, type CapabilityResult, type ModelListResponse, type ResolvedExecutionSnapshot, type RuntimeUserInput, type SessionInteractionResolution, type SessionEvent, type SessionWorkspaceSnapshot, type ThreadGoal } from "@rynx-ai/core";
|
|
20
20
|
import { type CodexSessionStore } from "../host.js";
|
|
21
21
|
import type { ResolveInteractionResult } from "../interactions.js";
|
|
22
22
|
import { type InjectOutcome, type TerminalOpenErrorCode, type TerminalRole } from "./protocol.js";
|
|
@@ -53,8 +53,22 @@ export interface RunnerManagerOptions {
|
|
|
53
53
|
liveStartTimeoutMs?: number;
|
|
54
54
|
/** Max wait for a Provider-native thread to become ready. */
|
|
55
55
|
liveReadyTimeoutMs?: number;
|
|
56
|
+
/** Max wait for a native interrupt acknowledgement before reporting it
|
|
57
|
+
* unproven. Callers may then use the explicit force-stop path. */
|
|
58
|
+
liveInterruptTimeoutMs?: number;
|
|
59
|
+
/** Max wait for an accepted owner TUI submission to become observable as a
|
|
60
|
+
* mirrored response or a published native rotation. */
|
|
61
|
+
terminalInputHandoffTimeoutMs?: number;
|
|
62
|
+
/** Initial exponential backoff for retrying unproven terminal cleanup. */
|
|
63
|
+
terminalInputCleanupRetryMs?: number;
|
|
56
64
|
/** Injected for tests. */
|
|
57
65
|
spawn?: typeof nodeSpawn;
|
|
66
|
+
/** Injected for tests. Defaults to signaling the whole POSIX process group
|
|
67
|
+
* created for the runner child, with direct-child fallback. */
|
|
68
|
+
signalChild?: (child: ChildProcess, signal: NodeJS.Signals, processGroup: boolean) => void;
|
|
69
|
+
/** Injected for tests. Kills and verifies the deterministic private tmux
|
|
70
|
+
* server owned by a Session runner. */
|
|
71
|
+
terminateTerminalServer?: (terminalName: string) => boolean | Promise<boolean>;
|
|
58
72
|
now?: () => number;
|
|
59
73
|
/** Extra env merged into every spawned runner child — e.g. the control-plane
|
|
60
74
|
* URL + token so a claude-native PermissionRequest hook can POST back. */
|
|
@@ -62,6 +76,13 @@ export interface RunnerManagerOptions {
|
|
|
62
76
|
/** Optional lifecycle context for Session runners. The shared `__cap__`
|
|
63
77
|
* runner never opens one. */
|
|
64
78
|
sessionContextProvider?: RunnerSessionContextProvider;
|
|
79
|
+
/** Dynamic process-wide admission fence. Existing cancellation and read-only
|
|
80
|
+
* inspection remain available; every path that may spawn or attach execution
|
|
81
|
+
* checks this immediately before admission. */
|
|
82
|
+
admissionOpen?: () => boolean;
|
|
83
|
+
/** Atomically reserves one work-producing admission while it crosses the
|
|
84
|
+
* asynchronous boundary into an observable live runner/turn. */
|
|
85
|
+
admissionReserve?: () => AdmissionReservation | undefined;
|
|
65
86
|
}
|
|
66
87
|
/** A session rotation reported by a runner child (claude `/clear`·`/fork`): the
|
|
67
88
|
* new session `to` is now aliased to `from`'s runner; carries meta to carry over. */
|
|
@@ -118,6 +139,8 @@ export declare class RunnerManager implements AgentCapabilities {
|
|
|
118
139
|
private readonly spawn;
|
|
119
140
|
private readonly childEnv;
|
|
120
141
|
private readonly sessionContextProvider;
|
|
142
|
+
private readonly admissionOpen;
|
|
143
|
+
private readonly admissionReserve;
|
|
121
144
|
private readonly now;
|
|
122
145
|
private readonly defaultRuntime;
|
|
123
146
|
private readonly handles;
|
|
@@ -127,8 +150,13 @@ export declare class RunnerManager implements AgentCapabilities {
|
|
|
127
150
|
private readonly reapTimer;
|
|
128
151
|
private readonly shutdownGraceMs;
|
|
129
152
|
private readonly shutdownKillGraceMs;
|
|
153
|
+
private readonly signalChild;
|
|
154
|
+
private readonly terminateTerminalServer;
|
|
130
155
|
private readonly liveStartTimeoutMs;
|
|
131
156
|
private readonly liveReadyTimeoutMs;
|
|
157
|
+
private readonly liveInterruptTimeoutMs;
|
|
158
|
+
private readonly terminalInputHandoffTimeoutMs;
|
|
159
|
+
private readonly terminalInputCleanupRetryMs;
|
|
132
160
|
private stopping;
|
|
133
161
|
private stopPromise;
|
|
134
162
|
/** Sink for mirrored {@link SessionEvent}s from every session's forwarder. */
|
|
@@ -154,6 +182,9 @@ export declare class RunnerManager implements AgentCapabilities {
|
|
|
154
182
|
private readonly forkOperations;
|
|
155
183
|
private readonly forkBufferedMessages;
|
|
156
184
|
private readonly forkBufferedTerminalInputs;
|
|
185
|
+
/** Owner TUI submissions accepted by the parent but not yet represented by a
|
|
186
|
+
* mirrored response or a durably published native rotation. */
|
|
187
|
+
private readonly terminalInputHandoffs;
|
|
157
188
|
constructor(opts: RunnerManagerOptions);
|
|
158
189
|
/**
|
|
159
190
|
* Open a live terminal on the session's runner child (spawning it if needed).
|
|
@@ -181,6 +212,18 @@ export declare class RunnerManager implements AgentCapabilities {
|
|
|
181
212
|
/** Register the sink for session rotations (claude `/clear`·`/fork`): the server
|
|
182
213
|
* records the new session's meta (carry-over agent/model/title). */
|
|
183
214
|
onRotate(listener: (rotation: RotateInfo) => void | Promise<void>): void;
|
|
215
|
+
/** Accepted TUI submissions that have not crossed into an observable runtime
|
|
216
|
+
* state. The daemon folds this into runningTurns after closing admission. */
|
|
217
|
+
pendingTerminalInputCount(): number;
|
|
218
|
+
private beginTerminalInputHandoffs;
|
|
219
|
+
private settleTerminalInputHandoff;
|
|
220
|
+
private settleTerminalRotationHandoff;
|
|
221
|
+
private currentTerminalSessionId;
|
|
222
|
+
private finishTerminalInputHandoff;
|
|
223
|
+
private finishAllTerminalInputHandoffs;
|
|
224
|
+
private preserveTerminalInputHandoffsAsActivity;
|
|
225
|
+
private scheduleTerminalInputCleanupRetry;
|
|
226
|
+
private expireTerminalInputHandoffs;
|
|
184
227
|
/**
|
|
185
228
|
* Eagerly bring up a session's codex-native live view (persistent forwarder +
|
|
186
229
|
* detached `codex --remote` TUI) in its runner child, spawning the runner if
|
|
@@ -211,6 +254,7 @@ export declare class RunnerManager implements AgentCapabilities {
|
|
|
211
254
|
* the session has no live forwarder (caller falls back to the run path).
|
|
212
255
|
*/
|
|
213
256
|
injectMessage(localThreadId: string, input: RuntimeUserInput | string): Promise<InjectOutcome>;
|
|
257
|
+
private injectMessageAdmitted;
|
|
214
258
|
/**
|
|
215
259
|
* Interrupt a session's active live turn — the web Stop button (codex
|
|
216
260
|
* `turn/interrupt`, claude Escape). Best-effort: resolves false with NO spawn
|
|
@@ -243,13 +287,19 @@ export declare class RunnerManager implements AgentCapabilities {
|
|
|
243
287
|
}): Promise<import("../host.js").CodexRuntimeStatus>;
|
|
244
288
|
/** Stop the runner bound to one session (if any), rejecting its in-flight work. */
|
|
245
289
|
stopRunner(localThreadId: string): void;
|
|
290
|
+
/** Force-stop and join the runner bound to one Session. Unlike stopRunner,
|
|
291
|
+
* completion proves that the child process has exited. */
|
|
292
|
+
terminateLiveSession(localThreadId: string): Promise<boolean>;
|
|
246
293
|
/** Stop every runner and join all child exits. Idempotent across concurrent calls. */
|
|
247
294
|
stop(): Promise<void>;
|
|
295
|
+
private handleForCleanup;
|
|
248
296
|
/** Forward a capability to a runner child. Session-less caps (listModels/status)
|
|
249
297
|
* use the shared `CAP_KEY` child; per-thread caps pass the session's key so they
|
|
250
298
|
* run on that session's child (which owns its private CODEX_HOME). */
|
|
251
299
|
private forwardCap;
|
|
252
300
|
private getOrSpawn;
|
|
301
|
+
private assertAdmissionOpen;
|
|
302
|
+
private reserveAdmission;
|
|
253
303
|
private performManagedFork;
|
|
254
304
|
private performClaudeManagedFork;
|
|
255
305
|
private bufferForkTargetMessage;
|
package/dist/runner/manager.js
CHANGED
|
@@ -22,6 +22,7 @@ import { fileURLToPath } from "node:url";
|
|
|
22
22
|
import { AgentRuntimeError, } from "@rynx-ai/core";
|
|
23
23
|
import { listRuntimeModels } from "../models-catalog.js";
|
|
24
24
|
import { probeRuntimeStatus } from "../runtime-status.js";
|
|
25
|
+
import { terminateTmuxServer } from "../terminal/tmux.js";
|
|
25
26
|
import { fromWireError, } from "./protocol.js";
|
|
26
27
|
import { StdioRunnerTransport } from "./transport.js";
|
|
27
28
|
/** Routing key for the shared capability runner (slash-command RPCs). */
|
|
@@ -36,6 +37,13 @@ const DEFAULT_SHUTDOWN_KILL_GRACE_MS = 5_000;
|
|
|
36
37
|
const DEFAULT_LIVE_START_TIMEOUT_MS = 10_000;
|
|
37
38
|
/** Thread readiness may legitimately wait through Provider startup. */
|
|
38
39
|
const DEFAULT_LIVE_READY_TIMEOUT_MS = 25_000;
|
|
40
|
+
/** A submitted TUI command should become a mirrored turn or native rotation
|
|
41
|
+
* quickly. If it does not, the runner is fenced by a verified process-tree
|
|
42
|
+
* shutdown before maintenance may treat the submission as settled. */
|
|
43
|
+
const DEFAULT_TERMINAL_INPUT_HANDOFF_TIMEOUT_MS = 30_000;
|
|
44
|
+
/** Retry transient terminal-server cleanup without spinning forever. */
|
|
45
|
+
const DEFAULT_TERMINAL_INPUT_CLEANUP_RETRY_MS = 1_000;
|
|
46
|
+
const MAX_TERMINAL_INPUT_CLEANUP_RETRY_MS = 30_000;
|
|
39
47
|
/** Keep per-Session spawn context small enough to remain an environment handoff,
|
|
40
48
|
* not an unbounded transport. */
|
|
41
49
|
const MAX_SESSION_CONTEXT_ENV_ENTRIES = 32;
|
|
@@ -139,6 +147,8 @@ export class RunnerManager {
|
|
|
139
147
|
spawn;
|
|
140
148
|
childEnv;
|
|
141
149
|
sessionContextProvider;
|
|
150
|
+
admissionOpen;
|
|
151
|
+
admissionReserve;
|
|
142
152
|
now;
|
|
143
153
|
defaultRuntime;
|
|
144
154
|
handles = new Map();
|
|
@@ -148,8 +158,13 @@ export class RunnerManager {
|
|
|
148
158
|
reapTimer;
|
|
149
159
|
shutdownGraceMs;
|
|
150
160
|
shutdownKillGraceMs;
|
|
161
|
+
signalChild;
|
|
162
|
+
terminateTerminalServer;
|
|
151
163
|
liveStartTimeoutMs;
|
|
152
164
|
liveReadyTimeoutMs;
|
|
165
|
+
liveInterruptTimeoutMs;
|
|
166
|
+
terminalInputHandoffTimeoutMs;
|
|
167
|
+
terminalInputCleanupRetryMs;
|
|
153
168
|
stopping = false;
|
|
154
169
|
stopPromise;
|
|
155
170
|
/** Sink for mirrored {@link SessionEvent}s from every session's forwarder. */
|
|
@@ -175,6 +190,9 @@ export class RunnerManager {
|
|
|
175
190
|
forkOperations = new Map();
|
|
176
191
|
forkBufferedMessages = new Map();
|
|
177
192
|
forkBufferedTerminalInputs = new Map();
|
|
193
|
+
/** Owner TUI submissions accepted by the parent but not yet represented by a
|
|
194
|
+
* mirrored response or a durably published native rotation. */
|
|
195
|
+
terminalInputHandoffs = new Map();
|
|
178
196
|
constructor(opts) {
|
|
179
197
|
this.config = opts.config;
|
|
180
198
|
this.sessionStore = opts.sessionStore;
|
|
@@ -183,12 +201,19 @@ export class RunnerManager {
|
|
|
183
201
|
this.spawn = opts.spawn ?? nodeSpawn;
|
|
184
202
|
this.childEnv = opts.childEnv ?? {};
|
|
185
203
|
this.sessionContextProvider = opts.sessionContextProvider;
|
|
204
|
+
this.admissionOpen = opts.admissionOpen ?? (() => true);
|
|
205
|
+
this.admissionReserve = opts.admissionReserve;
|
|
186
206
|
this.now = opts.now ?? (() => Date.now());
|
|
187
|
-
this.defaultRuntime = opts.config.
|
|
207
|
+
this.defaultRuntime = opts.config.DEFAULT_RUNTIME ?? "codex";
|
|
188
208
|
this.shutdownGraceMs = Math.max(0, opts.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS);
|
|
189
209
|
this.shutdownKillGraceMs = Math.max(0, opts.shutdownKillGraceMs ?? DEFAULT_SHUTDOWN_KILL_GRACE_MS);
|
|
210
|
+
this.signalChild = opts.signalChild ?? signalRunnerChild;
|
|
211
|
+
this.terminateTerminalServer = opts.terminateTerminalServer ?? terminateTmuxServer;
|
|
190
212
|
this.liveStartTimeoutMs = Math.max(1, opts.liveStartTimeoutMs ?? DEFAULT_LIVE_START_TIMEOUT_MS);
|
|
191
213
|
this.liveReadyTimeoutMs = Math.max(1, opts.liveReadyTimeoutMs ?? DEFAULT_LIVE_READY_TIMEOUT_MS);
|
|
214
|
+
this.liveInterruptTimeoutMs = Math.max(1, opts.liveInterruptTimeoutMs ?? 10_000);
|
|
215
|
+
this.terminalInputHandoffTimeoutMs = Math.max(1, opts.terminalInputHandoffTimeoutMs ?? DEFAULT_TERMINAL_INPUT_HANDOFF_TIMEOUT_MS);
|
|
216
|
+
this.terminalInputCleanupRetryMs = Math.max(1, opts.terminalInputCleanupRetryMs ?? DEFAULT_TERMINAL_INPUT_CLEANUP_RETRY_MS);
|
|
192
217
|
const reapIntervalMs = opts.reapIntervalMs ?? 60_000;
|
|
193
218
|
if (reapIntervalMs > 0) {
|
|
194
219
|
this.reapTimer = setInterval(() => this.reapIdle(), reapIntervalMs);
|
|
@@ -216,6 +241,7 @@ export class RunnerManager {
|
|
|
216
241
|
* and attach.
|
|
217
242
|
*/
|
|
218
243
|
openLiveTerminal(localThreadId, opts) {
|
|
244
|
+
this.assertAdmissionOpen();
|
|
219
245
|
if (this.forkReservations.has(localThreadId)) {
|
|
220
246
|
throw new TerminalOpenError("Session fork is still being committed", "terminal_not_live");
|
|
221
247
|
}
|
|
@@ -228,12 +254,36 @@ export class RunnerManager {
|
|
|
228
254
|
openTerminalOnHandle(handle, localThreadId, opts) {
|
|
229
255
|
handle.lastUsedAt = this.now();
|
|
230
256
|
const attachId = randomUUID();
|
|
257
|
+
const inputTracker = {
|
|
258
|
+
buffer: "",
|
|
259
|
+
previousWasCarriageReturn: false,
|
|
260
|
+
bracketedPaste: false,
|
|
261
|
+
pendingEscape: "",
|
|
262
|
+
};
|
|
231
263
|
const terminal = new ManagedTerminal(attachId, (msg) => {
|
|
264
|
+
const handoffs = msg.t === "term.input" && opts.role === "owner"
|
|
265
|
+
? this.beginTerminalInputHandoffs(handle, this.currentTerminalSessionId(handle, localThreadId), trackedTerminalSubmissions(inputTracker, Buffer.from(msg.dataB64, "base64").toString("utf8")))
|
|
266
|
+
: [];
|
|
267
|
+
const send = () => {
|
|
268
|
+
try {
|
|
269
|
+
handle.transport.send(msg);
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
for (const handoff of handoffs) {
|
|
273
|
+
this.finishTerminalInputHandoff(handle, handoff);
|
|
274
|
+
}
|
|
275
|
+
throw error;
|
|
276
|
+
}
|
|
277
|
+
};
|
|
232
278
|
const sourceReservation = this.sourceForkReservations.get(localThreadId);
|
|
233
279
|
if (msg.t === "term.input" && sourceReservation) {
|
|
234
280
|
void sourceReservation.then(() => {
|
|
235
281
|
if (!handle.dead)
|
|
236
|
-
|
|
282
|
+
send();
|
|
283
|
+
}).catch((error) => {
|
|
284
|
+
void this.terminateHandle(handle, `terminal input delivery failed: ${errorMessage(error)}`).catch((terminationError) => {
|
|
285
|
+
logTerminationFailure(handle, terminationError);
|
|
286
|
+
});
|
|
237
287
|
});
|
|
238
288
|
return;
|
|
239
289
|
}
|
|
@@ -246,7 +296,7 @@ export class RunnerManager {
|
|
|
246
296
|
return;
|
|
247
297
|
}
|
|
248
298
|
}
|
|
249
|
-
|
|
299
|
+
send();
|
|
250
300
|
}, () => handle.terminals.delete(attachId));
|
|
251
301
|
handle.terminals.set(attachId, terminal);
|
|
252
302
|
handle.transport.send({
|
|
@@ -284,6 +334,152 @@ export class RunnerManager {
|
|
|
284
334
|
onRotate(listener) {
|
|
285
335
|
this.rotateListener = listener;
|
|
286
336
|
}
|
|
337
|
+
/** Accepted TUI submissions that have not crossed into an observable runtime
|
|
338
|
+
* state. The daemon folds this into runningTurns after closing admission. */
|
|
339
|
+
pendingTerminalInputCount() {
|
|
340
|
+
let count = 0;
|
|
341
|
+
for (const handoffs of this.terminalInputHandoffs.values()) {
|
|
342
|
+
count += handoffs.length;
|
|
343
|
+
}
|
|
344
|
+
return count;
|
|
345
|
+
}
|
|
346
|
+
beginTerminalInputHandoffs(handle, sessionId, kinds) {
|
|
347
|
+
if (kinds.length === 0)
|
|
348
|
+
return [];
|
|
349
|
+
const created = [];
|
|
350
|
+
try {
|
|
351
|
+
for (const kind of kinds) {
|
|
352
|
+
const handoff = {
|
|
353
|
+
sessionId,
|
|
354
|
+
kind,
|
|
355
|
+
reservation: this.reserveAdmission(),
|
|
356
|
+
timer: undefined,
|
|
357
|
+
};
|
|
358
|
+
handoff.timer = setTimeout(() => {
|
|
359
|
+
this.expireTerminalInputHandoffs(handle);
|
|
360
|
+
}, this.terminalInputHandoffTimeoutMs);
|
|
361
|
+
handoff.timer.unref?.();
|
|
362
|
+
const handoffs = this.terminalInputHandoffs.get(handle);
|
|
363
|
+
if (handoffs)
|
|
364
|
+
handoffs.push(handoff);
|
|
365
|
+
else
|
|
366
|
+
this.terminalInputHandoffs.set(handle, [handoff]);
|
|
367
|
+
created.push(handoff);
|
|
368
|
+
}
|
|
369
|
+
return created;
|
|
370
|
+
}
|
|
371
|
+
catch (error) {
|
|
372
|
+
for (const handoff of created) {
|
|
373
|
+
this.finishTerminalInputHandoff(handle, handoff);
|
|
374
|
+
}
|
|
375
|
+
throw error;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
settleTerminalInputHandoff(handle, sessionId, kind) {
|
|
379
|
+
const handoff = this.terminalInputHandoffs
|
|
380
|
+
.get(handle)
|
|
381
|
+
?.find((candidate) => candidate.sessionId === sessionId);
|
|
382
|
+
// Runtime observations settle accepted input in submission order. In
|
|
383
|
+
// particular, a response from the source Session while /clear or /fork is
|
|
384
|
+
// still publishing must not skip that rotation and release a later turn
|
|
385
|
+
// which has not yet been rebound or delivered.
|
|
386
|
+
if (handoff?.kind === kind)
|
|
387
|
+
this.finishTerminalInputHandoff(handle, handoff);
|
|
388
|
+
}
|
|
389
|
+
settleTerminalRotationHandoff(handle, sourceSessionId, targetSessionId) {
|
|
390
|
+
const handoffs = this.terminalInputHandoffs.get(handle);
|
|
391
|
+
const rotationIndex = handoffs?.findIndex((handoff) => handoff.sessionId === sourceSessionId && handoff.kind === "rotation") ?? -1;
|
|
392
|
+
if (!handoffs || rotationIndex < 0)
|
|
393
|
+
return;
|
|
394
|
+
// Turns submitted before /clear or /fork are superseded once the rotation
|
|
395
|
+
// is published. Inputs accepted afterwards belong to the transferred pane
|
|
396
|
+
// and must follow it to the target Session.
|
|
397
|
+
const rotation = handoffs[rotationIndex];
|
|
398
|
+
for (const handoff of [...handoffs.slice(0, rotationIndex)]) {
|
|
399
|
+
if (handoff.sessionId === sourceSessionId && handoff.kind === "turn") {
|
|
400
|
+
this.finishTerminalInputHandoff(handle, handoff);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
if (rotation)
|
|
404
|
+
this.finishTerminalInputHandoff(handle, rotation);
|
|
405
|
+
for (const handoff of this.terminalInputHandoffs.get(handle) ?? []) {
|
|
406
|
+
if (handoff.sessionId === sourceSessionId)
|
|
407
|
+
handoff.sessionId = targetSessionId;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
currentTerminalSessionId(handle, fallback) {
|
|
411
|
+
for (const [sessionId, candidate] of this.handles) {
|
|
412
|
+
if (candidate === handle && this.liveSessionKeys.has(sessionId))
|
|
413
|
+
return sessionId;
|
|
414
|
+
}
|
|
415
|
+
return fallback;
|
|
416
|
+
}
|
|
417
|
+
finishTerminalInputHandoff(handle, handoff) {
|
|
418
|
+
if (handoff.timer) {
|
|
419
|
+
clearTimeout(handoff.timer);
|
|
420
|
+
handoff.timer = undefined;
|
|
421
|
+
}
|
|
422
|
+
handoff.reservation?.release();
|
|
423
|
+
handoff.reservation = undefined;
|
|
424
|
+
const handoffs = this.terminalInputHandoffs.get(handle);
|
|
425
|
+
if (!handoffs)
|
|
426
|
+
return;
|
|
427
|
+
const index = handoffs.indexOf(handoff);
|
|
428
|
+
if (index >= 0)
|
|
429
|
+
handoffs.splice(index, 1);
|
|
430
|
+
if (handoffs.length === 0)
|
|
431
|
+
this.terminalInputHandoffs.delete(handle);
|
|
432
|
+
}
|
|
433
|
+
finishAllTerminalInputHandoffs(handle) {
|
|
434
|
+
if (handle.terminalCleanupRetryTimer) {
|
|
435
|
+
clearTimeout(handle.terminalCleanupRetryTimer);
|
|
436
|
+
delete handle.terminalCleanupRetryTimer;
|
|
437
|
+
}
|
|
438
|
+
handle.terminalCleanupRetryFailures = 0;
|
|
439
|
+
for (const handoff of [...(this.terminalInputHandoffs.get(handle) ?? [])]) {
|
|
440
|
+
this.finishTerminalInputHandoff(handle, handoff);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
preserveTerminalInputHandoffsAsActivity(handle) {
|
|
444
|
+
for (const handoff of this.terminalInputHandoffs.get(handle) ?? []) {
|
|
445
|
+
if (handoff.timer) {
|
|
446
|
+
clearTimeout(handoff.timer);
|
|
447
|
+
handoff.timer = undefined;
|
|
448
|
+
}
|
|
449
|
+
handoff.reservation?.release();
|
|
450
|
+
handoff.reservation = undefined;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
scheduleTerminalInputCleanupRetry(handle) {
|
|
454
|
+
if (handle.terminalCleanupRetryTimer ||
|
|
455
|
+
!this.terminalInputHandoffs.has(handle))
|
|
456
|
+
return;
|
|
457
|
+
const exponent = Math.min(handle.terminalCleanupRetryFailures - 1, 10);
|
|
458
|
+
const delay = Math.min(this.terminalInputCleanupRetryMs * (2 ** Math.max(0, exponent)), MAX_TERMINAL_INPUT_CLEANUP_RETRY_MS);
|
|
459
|
+
handle.terminalCleanupRetryTimer = setTimeout(() => {
|
|
460
|
+
delete handle.terminalCleanupRetryTimer;
|
|
461
|
+
if (!this.terminalInputHandoffs.has(handle))
|
|
462
|
+
return;
|
|
463
|
+
void this.terminateHandle(handle, "retrying unproven terminal input cleanup").catch((error) => {
|
|
464
|
+
logTerminationFailure(handle, error);
|
|
465
|
+
});
|
|
466
|
+
}, delay);
|
|
467
|
+
handle.terminalCleanupRetryTimer.unref?.();
|
|
468
|
+
}
|
|
469
|
+
expireTerminalInputHandoffs(handle) {
|
|
470
|
+
const handoffs = this.terminalInputHandoffs.get(handle);
|
|
471
|
+
if (!handoffs?.length)
|
|
472
|
+
return;
|
|
473
|
+
for (const handoff of handoffs) {
|
|
474
|
+
if (handoff.timer) {
|
|
475
|
+
clearTimeout(handoff.timer);
|
|
476
|
+
handoff.timer = undefined;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
void this.terminateHandle(handle, "accepted terminal input did not become observable before maintenance timeout").catch((error) => {
|
|
480
|
+
logTerminationFailure(handle, error);
|
|
481
|
+
});
|
|
482
|
+
}
|
|
287
483
|
/**
|
|
288
484
|
* Eagerly bring up a session's codex-native live view (persistent forwarder +
|
|
289
485
|
* detached `codex --remote` TUI) in its runner child, spawning the runner if
|
|
@@ -291,13 +487,17 @@ export class RunnerManager {
|
|
|
291
487
|
* non-codex / non-live session (the caller then uses the normal run path).
|
|
292
488
|
*/
|
|
293
489
|
ensureLiveSession(localThreadId, opts) {
|
|
294
|
-
|
|
490
|
+
const admission = this.reserveAdmission();
|
|
491
|
+
return this.requestLiveSession(localThreadId, opts, true)
|
|
492
|
+
.finally(() => admission.release());
|
|
295
493
|
}
|
|
296
494
|
/** Start the Provider pane without waiting for login/onboarding to create a
|
|
297
495
|
* native thread. This is the setup-terminal gate; callers may attach as soon
|
|
298
496
|
* as it resolves, while normal message delivery still uses ensureLiveSession. */
|
|
299
497
|
startLiveSession(localThreadId, opts) {
|
|
300
|
-
|
|
498
|
+
const admission = this.reserveAdmission();
|
|
499
|
+
return this.requestLiveSession(localThreadId, opts, false)
|
|
500
|
+
.finally(() => admission.release());
|
|
301
501
|
}
|
|
302
502
|
requestLiveSession(localThreadId, opts, waitForReady, allowReservedForkTarget = false) {
|
|
303
503
|
const sourceReservation = allowReservedForkTarget
|
|
@@ -395,13 +595,18 @@ export class RunnerManager {
|
|
|
395
595
|
* the session has no live forwarder (caller falls back to the run path).
|
|
396
596
|
*/
|
|
397
597
|
injectMessage(localThreadId, input) {
|
|
598
|
+
const admission = this.reserveAdmission();
|
|
599
|
+
return this.injectMessageAdmitted(localThreadId, input)
|
|
600
|
+
.finally(() => admission.release());
|
|
601
|
+
}
|
|
602
|
+
injectMessageAdmitted(localThreadId, input) {
|
|
398
603
|
const sourceReservation = this.sourceForkReservations.get(localThreadId);
|
|
399
604
|
if (sourceReservation) {
|
|
400
|
-
return sourceReservation.then(() => this.
|
|
605
|
+
return sourceReservation.then(() => this.injectMessageAdmitted(localThreadId, input));
|
|
401
606
|
}
|
|
402
607
|
const reservation = this.forkReservations.get(localThreadId);
|
|
403
608
|
if (reservation) {
|
|
404
|
-
return reservation.then(() => this.
|
|
609
|
+
return reservation.then(() => this.injectMessageAdmitted(localThreadId, input));
|
|
405
610
|
}
|
|
406
611
|
const handle = this.getOrSpawn(localThreadId);
|
|
407
612
|
handle.lastUsedAt = this.now();
|
|
@@ -444,7 +649,16 @@ export class RunnerManager {
|
|
|
444
649
|
handle.lastUsedAt = this.now();
|
|
445
650
|
const reqId = randomUUID();
|
|
446
651
|
return new Promise((resolve) => {
|
|
447
|
-
|
|
652
|
+
const timeout = setTimeout(() => {
|
|
653
|
+
if (!handle.live.delete(reqId))
|
|
654
|
+
return;
|
|
655
|
+
resolve(false);
|
|
656
|
+
}, this.liveInterruptTimeoutMs);
|
|
657
|
+
timeout.unref?.();
|
|
658
|
+
handle.live.set(reqId, (res) => {
|
|
659
|
+
clearTimeout(timeout);
|
|
660
|
+
resolve(res.ok ?? false);
|
|
661
|
+
});
|
|
448
662
|
handle.transport.send({ t: "live.interrupt", reqId, localThreadId });
|
|
449
663
|
});
|
|
450
664
|
}
|
|
@@ -488,6 +702,7 @@ export class RunnerManager {
|
|
|
488
702
|
return this.forwardCap("clearGoal", [localThreadId], localThreadId);
|
|
489
703
|
}
|
|
490
704
|
forkSession(currentLocalThreadId, newLocalThreadId, options) {
|
|
705
|
+
this.assertAdmissionOpen();
|
|
491
706
|
if (currentLocalThreadId === newLocalThreadId) {
|
|
492
707
|
return Promise.resolve({
|
|
493
708
|
ok: false,
|
|
@@ -573,7 +788,7 @@ export class RunnerManager {
|
|
|
573
788
|
}
|
|
574
789
|
/** Stop the runner bound to one session (if any), rejecting its in-flight work. */
|
|
575
790
|
stopRunner(localThreadId) {
|
|
576
|
-
const handle = this.
|
|
791
|
+
const handle = this.handleForCleanup(localThreadId);
|
|
577
792
|
if (!handle) {
|
|
578
793
|
return;
|
|
579
794
|
}
|
|
@@ -581,6 +796,15 @@ export class RunnerManager {
|
|
|
581
796
|
logTerminationFailure(handle, error);
|
|
582
797
|
});
|
|
583
798
|
}
|
|
799
|
+
/** Force-stop and join the runner bound to one Session. Unlike stopRunner,
|
|
800
|
+
* completion proves that the child process has exited. */
|
|
801
|
+
async terminateLiveSession(localThreadId) {
|
|
802
|
+
const handle = this.handleForCleanup(localThreadId);
|
|
803
|
+
if (!handle)
|
|
804
|
+
return false;
|
|
805
|
+
await this.terminateHandle(handle, "runner force-stopped");
|
|
806
|
+
return true;
|
|
807
|
+
}
|
|
584
808
|
/** Stop every runner and join all child exits. Idempotent across concurrent calls. */
|
|
585
809
|
stop() {
|
|
586
810
|
if (this.stopPromise)
|
|
@@ -589,9 +813,12 @@ export class RunnerManager {
|
|
|
589
813
|
if (this.reapTimer) {
|
|
590
814
|
clearInterval(this.reapTimer);
|
|
591
815
|
}
|
|
592
|
-
const children = [
|
|
593
|
-
|
|
594
|
-
|
|
816
|
+
const children = new Set([
|
|
817
|
+
...this.childHandles,
|
|
818
|
+
...this.terminalInputHandoffs.keys(),
|
|
819
|
+
]);
|
|
820
|
+
const attempt = (async () => {
|
|
821
|
+
const results = await Promise.allSettled([...children].map((handle) => this.terminateHandle(handle, "runner manager stopped")));
|
|
595
822
|
this.handles.clear();
|
|
596
823
|
this.liveSessionKeys.clear();
|
|
597
824
|
this.liveErrors.clear();
|
|
@@ -606,9 +833,19 @@ export class RunnerManager {
|
|
|
606
833
|
}
|
|
607
834
|
this.childHandles.clear();
|
|
608
835
|
})();
|
|
609
|
-
|
|
836
|
+
this.stopPromise = attempt;
|
|
837
|
+
void attempt.catch(() => {
|
|
838
|
+
if (this.stopPromise === attempt)
|
|
839
|
+
this.stopPromise = undefined;
|
|
840
|
+
});
|
|
841
|
+
return attempt;
|
|
610
842
|
}
|
|
611
843
|
// ── internals ──────────────────────────────────────────────────────────────
|
|
844
|
+
handleForCleanup(localThreadId) {
|
|
845
|
+
return this.handles.get(localThreadId) ??
|
|
846
|
+
[...this.terminalInputHandoffs].find(([handle, handoffs]) => handle.key === localThreadId ||
|
|
847
|
+
handoffs.some((handoff) => handoff.sessionId === localThreadId))?.[0];
|
|
848
|
+
}
|
|
612
849
|
/** Forward a capability to a runner child. Session-less caps (listModels/status)
|
|
613
850
|
* use the shared `CAP_KEY` child; per-thread caps pass the session's key so they
|
|
614
851
|
* run on that session's child (which owns its private CODEX_HOME). */
|
|
@@ -622,23 +859,48 @@ export class RunnerManager {
|
|
|
622
859
|
});
|
|
623
860
|
}
|
|
624
861
|
getOrSpawn(key, allowReservedForkTarget = false) {
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
existing
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
862
|
+
const admission = this.reserveAdmission();
|
|
863
|
+
try {
|
|
864
|
+
if (this.stopping) {
|
|
865
|
+
throw new AgentRuntimeError("runner manager is stopped", 503, "runner_stopped");
|
|
866
|
+
}
|
|
867
|
+
if (key !== CAP_KEY &&
|
|
868
|
+
this.forkReservations.has(key) &&
|
|
869
|
+
!allowReservedForkTarget) {
|
|
870
|
+
throw new AgentRuntimeError("Session fork is still being committed", 409, "session_fork_pending");
|
|
871
|
+
}
|
|
872
|
+
this.reapIdle();
|
|
873
|
+
const existing = this.handles.get(key);
|
|
874
|
+
if (existing && !existing.dead) {
|
|
875
|
+
existing.lastUsedAt = this.now();
|
|
876
|
+
return existing;
|
|
877
|
+
}
|
|
878
|
+
const handle = this.spawnHandle(key);
|
|
879
|
+
this.handles.set(key, handle);
|
|
880
|
+
return handle;
|
|
881
|
+
}
|
|
882
|
+
finally {
|
|
883
|
+
admission.release();
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
assertAdmissionOpen() {
|
|
887
|
+
const reservation = this.admissionReserve?.();
|
|
888
|
+
if (reservation) {
|
|
889
|
+
reservation.release();
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
if (!this.admissionReserve && this.admissionOpen())
|
|
893
|
+
return;
|
|
894
|
+
throw new AgentRuntimeError("daemon maintenance is in progress", 503, "daemon_maintenance");
|
|
895
|
+
}
|
|
896
|
+
reserveAdmission() {
|
|
897
|
+
const reservation = this.admissionReserve?.();
|
|
898
|
+
if (reservation)
|
|
899
|
+
return reservation;
|
|
900
|
+
if (!this.admissionReserve && this.admissionOpen()) {
|
|
901
|
+
return { release() { } };
|
|
902
|
+
}
|
|
903
|
+
throw new AgentRuntimeError("daemon maintenance is in progress", 503, "daemon_maintenance");
|
|
642
904
|
}
|
|
643
905
|
async performManagedFork(currentLocalThreadId, newLocalThreadId, options) {
|
|
644
906
|
const existingTarget = await this.sessionStore.get(newLocalThreadId);
|
|
@@ -750,6 +1012,9 @@ export class RunnerManager {
|
|
|
750
1012
|
return;
|
|
751
1013
|
if (message.t === "mirror") {
|
|
752
1014
|
this.mirrorListener?.(message.sessionId, message.event);
|
|
1015
|
+
if (message.event.type === "response.created") {
|
|
1016
|
+
this.settleTerminalInputHandoff(handle, message.sessionId, "turn");
|
|
1017
|
+
}
|
|
753
1018
|
return;
|
|
754
1019
|
}
|
|
755
1020
|
this.deliverRotateMessage(handle, message);
|
|
@@ -806,6 +1071,11 @@ export class RunnerManager {
|
|
|
806
1071
|
};
|
|
807
1072
|
void Promise.resolve(this.rotateListener?.(rotation))
|
|
808
1073
|
.then(() => {
|
|
1074
|
+
// The server's rotation listener resolves only after the target Session
|
|
1075
|
+
// is published. Release the terminal admission handoff afterwards so a
|
|
1076
|
+
// maintenance snapshot cannot observe a gap with neither source work
|
|
1077
|
+
// nor the target Session.
|
|
1078
|
+
this.settleTerminalRotationHandoff(handle, message.from, message.to);
|
|
809
1079
|
if (handle.dead)
|
|
810
1080
|
return;
|
|
811
1081
|
this.handles.set(message.to, handle);
|
|
@@ -845,10 +1115,12 @@ export class RunnerManager {
|
|
|
845
1115
|
const sessionContext = key === CAP_KEY
|
|
846
1116
|
? undefined
|
|
847
1117
|
: this.openSessionContext(key);
|
|
1118
|
+
const processGroup = process.platform !== "win32";
|
|
848
1119
|
let child;
|
|
849
1120
|
try {
|
|
850
1121
|
child = this.spawn(process.execPath, args, {
|
|
851
1122
|
stdio: ["pipe", "pipe", "pipe"],
|
|
1123
|
+
detached: processGroup,
|
|
852
1124
|
// `RYNX_RUNNER_SESSION` = the child's session key (localThreadId, or `__cap__`
|
|
853
1125
|
// for the shared capability child) so its LocalAgentHost scopes the private
|
|
854
1126
|
// CODEX_HOME to this session. It is deliberately applied last.
|
|
@@ -874,6 +1146,8 @@ export class RunnerManager {
|
|
|
874
1146
|
lastUsedAt: this.now(),
|
|
875
1147
|
dead: false,
|
|
876
1148
|
completion,
|
|
1149
|
+
terminalCleanupRetryFailures: 0,
|
|
1150
|
+
processGroup,
|
|
877
1151
|
...(sessionContext ? { sessionContext } : {}),
|
|
878
1152
|
caps: new Map(),
|
|
879
1153
|
terminals: new Map(),
|
|
@@ -937,6 +1211,12 @@ export class RunnerManager {
|
|
|
937
1211
|
if (this.bufferForkTargetMessage(handle, msg))
|
|
938
1212
|
return;
|
|
939
1213
|
this.mirrorListener?.(msg.sessionId, msg.event);
|
|
1214
|
+
if (msg.event.type === "response.created") {
|
|
1215
|
+
// The listener projects response.created into SessionRuntimeIndex
|
|
1216
|
+
// synchronously. Only then may the accepted terminal reservation
|
|
1217
|
+
// drain into a maintenance activity snapshot.
|
|
1218
|
+
this.settleTerminalInputHandoff(handle, msg.sessionId, "turn");
|
|
1219
|
+
}
|
|
940
1220
|
return;
|
|
941
1221
|
case "rotate": {
|
|
942
1222
|
if (handle.dead)
|
|
@@ -1002,7 +1282,11 @@ export class RunnerManager {
|
|
|
1002
1282
|
terminateHandle(handle, reason) {
|
|
1003
1283
|
if (handle.termination)
|
|
1004
1284
|
return handle.termination;
|
|
1005
|
-
handle.
|
|
1285
|
+
if (handle.terminalCleanupRetryTimer) {
|
|
1286
|
+
clearTimeout(handle.terminalCleanupRetryTimer);
|
|
1287
|
+
delete handle.terminalCleanupRetryTimer;
|
|
1288
|
+
}
|
|
1289
|
+
const attempt = (async () => {
|
|
1006
1290
|
if (!handle.dead) {
|
|
1007
1291
|
try {
|
|
1008
1292
|
handle.transport.send({ t: "shutdown" });
|
|
@@ -1012,16 +1296,37 @@ export class RunnerManager {
|
|
|
1012
1296
|
}
|
|
1013
1297
|
this.failHandle(handle, reason);
|
|
1014
1298
|
}
|
|
1015
|
-
signalChild(handle.child, "SIGTERM");
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1299
|
+
this.signalChild(handle.child, "SIGTERM", handle.processGroup);
|
|
1300
|
+
let childExited = await waitForChildExit(handle.completion, this.shutdownGraceMs);
|
|
1301
|
+
if (!childExited) {
|
|
1302
|
+
this.signalChild(handle.child, "SIGKILL", handle.processGroup);
|
|
1303
|
+
childExited = await waitForChildExit(handle.completion, this.shutdownKillGraceMs);
|
|
1304
|
+
}
|
|
1305
|
+
const terminalStopped = handle.key === CAP_KEY
|
|
1306
|
+
? true
|
|
1307
|
+
: await Promise.resolve(this.terminateTerminalServer(`${handle.key}-main`)).catch(() => false);
|
|
1308
|
+
if (!childExited) {
|
|
1309
|
+
throw new Error(`runner child ${handle.child.pid ?? handle.key} did not exit after SIGKILL`);
|
|
1310
|
+
}
|
|
1311
|
+
if (!terminalStopped) {
|
|
1312
|
+
throw new Error(`runner terminal ${handle.key}-main remained live after child exit`);
|
|
1021
1313
|
}
|
|
1022
1314
|
this.childHandles.delete(handle);
|
|
1023
1315
|
})();
|
|
1024
|
-
|
|
1316
|
+
handle.termination = attempt;
|
|
1317
|
+
void attempt.then(() => {
|
|
1318
|
+
this.finishAllTerminalInputHandoffs(handle);
|
|
1319
|
+
}, () => {
|
|
1320
|
+
if (handle.termination === attempt)
|
|
1321
|
+
delete handle.termination;
|
|
1322
|
+
handle.terminalCleanupRetryFailures += 1;
|
|
1323
|
+
// A failed cleanup must not pin closeAndDrain forever. Keep the
|
|
1324
|
+
// submission in the daemon activity snapshot, but release its gate
|
|
1325
|
+
// reservation so maintenance returns a structured busy result.
|
|
1326
|
+
this.preserveTerminalInputHandoffsAsActivity(handle);
|
|
1327
|
+
this.scheduleTerminalInputCleanupRetry(handle);
|
|
1328
|
+
});
|
|
1329
|
+
return attempt;
|
|
1025
1330
|
}
|
|
1026
1331
|
reapIdle() {
|
|
1027
1332
|
const now = this.now();
|
|
@@ -1060,6 +1365,70 @@ export class RunnerManager {
|
|
|
1060
1365
|
}
|
|
1061
1366
|
}
|
|
1062
1367
|
}
|
|
1368
|
+
function trackedTerminalSubmissions(tracker, data) {
|
|
1369
|
+
const pasteStart = "\u001b[200~";
|
|
1370
|
+
const pasteEnd = "\u001b[201~";
|
|
1371
|
+
const input = tracker.pendingEscape + data;
|
|
1372
|
+
tracker.pendingEscape = "";
|
|
1373
|
+
const submissions = [];
|
|
1374
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
1375
|
+
const character = input[index] ?? "";
|
|
1376
|
+
if (character === "\u001b") {
|
|
1377
|
+
const remaining = input.slice(index);
|
|
1378
|
+
if (remaining.startsWith(pasteStart)) {
|
|
1379
|
+
tracker.bracketedPaste = true;
|
|
1380
|
+
tracker.previousWasCarriageReturn = false;
|
|
1381
|
+
index += pasteStart.length - 1;
|
|
1382
|
+
continue;
|
|
1383
|
+
}
|
|
1384
|
+
if (remaining.startsWith(pasteEnd)) {
|
|
1385
|
+
tracker.bracketedPaste = false;
|
|
1386
|
+
tracker.previousWasCarriageReturn = false;
|
|
1387
|
+
index += pasteEnd.length - 1;
|
|
1388
|
+
continue;
|
|
1389
|
+
}
|
|
1390
|
+
if (pasteStart.startsWith(remaining) || pasteEnd.startsWith(remaining)) {
|
|
1391
|
+
tracker.pendingEscape = remaining;
|
|
1392
|
+
break;
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
if (tracker.bracketedPaste) {
|
|
1396
|
+
tracker.buffer += character;
|
|
1397
|
+
tracker.previousWasCarriageReturn = false;
|
|
1398
|
+
continue;
|
|
1399
|
+
}
|
|
1400
|
+
if (character === "\n" && tracker.previousWasCarriageReturn) {
|
|
1401
|
+
tracker.previousWasCarriageReturn = false;
|
|
1402
|
+
continue;
|
|
1403
|
+
}
|
|
1404
|
+
tracker.previousWasCarriageReturn = character === "\r";
|
|
1405
|
+
if (character === "\r" || character === "\n") {
|
|
1406
|
+
const command = tracker.buffer.trim();
|
|
1407
|
+
tracker.buffer = "";
|
|
1408
|
+
if (command.length > 0 &&
|
|
1409
|
+
(command.includes("\u001b") ||
|
|
1410
|
+
!command.startsWith("/") ||
|
|
1411
|
+
/^\/(?:clear|fork)(?:\s|$)/u.test(command))) {
|
|
1412
|
+
submissions.push(/^\/(?:clear|fork)(?:\s|$)/u.test(command) ? "rotation" : "turn");
|
|
1413
|
+
}
|
|
1414
|
+
continue;
|
|
1415
|
+
}
|
|
1416
|
+
if (character === "\b" || character === "\u007f") {
|
|
1417
|
+
tracker.buffer = tracker.buffer.slice(0, -1);
|
|
1418
|
+
continue;
|
|
1419
|
+
}
|
|
1420
|
+
if (character === "\u0015" || character === "\u0003") {
|
|
1421
|
+
tracker.buffer = "";
|
|
1422
|
+
continue;
|
|
1423
|
+
}
|
|
1424
|
+
// Printable text and tabs are enough to distinguish work-producing prompts
|
|
1425
|
+
// from local TUI slash commands. Other control sequences are ignored.
|
|
1426
|
+
if (character === "\u001b" || character === "\t" || character >= " ") {
|
|
1427
|
+
tracker.buffer += character;
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
return submissions;
|
|
1431
|
+
}
|
|
1063
1432
|
function validateSessionContext(context) {
|
|
1064
1433
|
if (!context || typeof context !== "object") {
|
|
1065
1434
|
throw new Error("Session context environment provider must return an object");
|
|
@@ -1164,9 +1533,18 @@ function observeChildCompletion(child) {
|
|
|
1164
1533
|
child.once("exit", onExit);
|
|
1165
1534
|
});
|
|
1166
1535
|
}
|
|
1167
|
-
function
|
|
1536
|
+
function signalRunnerChild(child, signal, processGroup) {
|
|
1168
1537
|
if (childHasExited(child))
|
|
1169
1538
|
return;
|
|
1539
|
+
if (processGroup && child.pid) {
|
|
1540
|
+
try {
|
|
1541
|
+
process.kill(-child.pid, signal);
|
|
1542
|
+
return;
|
|
1543
|
+
}
|
|
1544
|
+
catch {
|
|
1545
|
+
// The group may have exited between the completion check and the signal.
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1170
1548
|
try {
|
|
1171
1549
|
child.kill(signal);
|
|
1172
1550
|
}
|
package/dist/terminal/tmux.d.ts
CHANGED
|
@@ -43,6 +43,14 @@ export interface TmuxTerminalOptions {
|
|
|
43
43
|
/** Injectable for tests; defaults to the real `tmux` binary path. */
|
|
44
44
|
tmuxBin?: string;
|
|
45
45
|
}
|
|
46
|
+
/** Deterministic private socket owned by one terminal name. Parent-side
|
|
47
|
+
* shutdown uses the same mapping when the runner child is too wedged to clean
|
|
48
|
+
* up its own tmux server. */
|
|
49
|
+
export declare function tmuxSocketPath(name: string): string;
|
|
50
|
+
/** Kill one private tmux server and verify it no longer answers. Missing
|
|
51
|
+
* sockets are already stopped; an existing socket with an unusable tmux
|
|
52
|
+
* command is unproven and returns false. */
|
|
53
|
+
export declare function terminateTmuxServer(name: string, tmuxBin?: string): boolean;
|
|
46
54
|
/** Is a usable `tmux` on PATH? Cheap probe for the capability gate. */
|
|
47
55
|
export declare function isTmuxAvailable(tmuxBin?: string): boolean;
|
|
48
56
|
export declare class TmuxTerminal {
|
package/dist/terminal/tmux.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { execFile, execFileSync } from "node:child_process";
|
|
18
18
|
import { createHash } from "node:crypto";
|
|
19
|
-
import { mkdirSync } from "node:fs";
|
|
19
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
20
20
|
import { tmpdir } from "node:os";
|
|
21
21
|
import { join } from "node:path";
|
|
22
22
|
const TMUX_TARGET = "main";
|
|
@@ -26,6 +26,40 @@ const DEFAULT_ROWS = 24;
|
|
|
26
26
|
* wheel-scroll (tmux copy-mode, enabled by `mouse on`) reaches as far back as
|
|
27
27
|
* the terminal shows. Mirrors reference implementation's `TerminalEnvSpec.scrollback`. */
|
|
28
28
|
const DEFAULT_SCROLLBACK = 20000;
|
|
29
|
+
/** Deterministic private socket owned by one terminal name. Parent-side
|
|
30
|
+
* shutdown uses the same mapping when the runner child is too wedged to clean
|
|
31
|
+
* up its own tmux server. */
|
|
32
|
+
export function tmuxSocketPath(name) {
|
|
33
|
+
const key = createHash("sha256").update(name).digest("hex").slice(0, 16);
|
|
34
|
+
return join(tmpdir(), "rynx-term", `${key}.sock`);
|
|
35
|
+
}
|
|
36
|
+
/** Kill one private tmux server and verify it no longer answers. Missing
|
|
37
|
+
* sockets are already stopped; an existing socket with an unusable tmux
|
|
38
|
+
* command is unproven and returns false. */
|
|
39
|
+
export function terminateTmuxServer(name, tmuxBin = "tmux") {
|
|
40
|
+
const socketPath = tmuxSocketPath(name);
|
|
41
|
+
if (!existsSync(socketPath))
|
|
42
|
+
return true;
|
|
43
|
+
const base = ["-u", "-S", socketPath];
|
|
44
|
+
try {
|
|
45
|
+
execFileSync(tmuxBin, [...base, "kill-server"], { stdio: "ignore" });
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// A server may have exited between existsSync and kill-server. Verify below.
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
execFileSync(tmuxBin, [...base, "has-session", "-t", TMUX_TARGET], {
|
|
52
|
+
stdio: "ignore",
|
|
53
|
+
});
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
return !(error &&
|
|
58
|
+
typeof error === "object" &&
|
|
59
|
+
"code" in error &&
|
|
60
|
+
error.code === "ENOENT");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
29
63
|
/** Is a usable `tmux` on PATH? Cheap probe for the capability gate. */
|
|
30
64
|
export function isTmuxAvailable(tmuxBin = "tmux") {
|
|
31
65
|
try {
|
|
@@ -76,8 +110,7 @@ export class TmuxTerminal {
|
|
|
76
110
|
// under the unix-domain path limit (~104 chars on macOS) — a full session id
|
|
77
111
|
// like `sess_<uuid>-main` under $TMPDIR would overflow it and fail with
|
|
78
112
|
// "File name too long". The hash stays unique per terminal.
|
|
79
|
-
|
|
80
|
-
this.socketPath = join(dir, `${key}.sock`);
|
|
113
|
+
this.socketPath = tmuxSocketPath(opts.name);
|
|
81
114
|
}
|
|
82
115
|
/** tmux argv prefix targeting this terminal's private server. */
|
|
83
116
|
base() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rynx-ai/runtime",
|
|
3
|
-
"version": "0.1.11-beta.
|
|
3
|
+
"version": "0.1.11-beta.4",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/rynx-ai/rynx.git",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"node-pty": "^1.0.0",
|
|
28
28
|
"ws": "^8.21.0",
|
|
29
|
-
"@rynx-ai/core": "0.1.11-beta.
|
|
29
|
+
"@rynx-ai/core": "0.1.11-beta.4"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"@types/ws": "^8.18.1"
|