@rynx-ai/runtime 0.1.11-beta.5 → 0.1.11-beta.51
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 +19 -5
- package/dist/claude/executor.js +56 -12
- package/dist/claude/models.d.ts +0 -5
- package/dist/claude/models.js +1 -7
- package/dist/claude/native-bridge.d.ts +103 -1
- package/dist/claude/native-bridge.js +460 -30
- package/dist/claude/native-hook-main.js +81 -1
- package/dist/claude/native-hooks.js +7 -0
- package/dist/claude/native-integration.d.ts +178 -26
- package/dist/claude/native-integration.js +1571 -170
- package/dist/claude/session-status.d.ts +39 -0
- package/dist/claude/session-status.js +163 -0
- package/dist/claude/transcript-clone.d.ts +18 -0
- package/dist/claude/transcript-clone.js +497 -0
- package/dist/claude/transcript.d.ts +27 -4
- package/dist/claude/transcript.js +183 -46
- package/dist/codex-app-server/client.d.ts +10 -6
- package/dist/codex-app-server/client.js +67 -15
- package/dist/codex-app-server/forwarder.d.ts +96 -3
- package/dist/codex-app-server/forwarder.js +542 -57
- package/dist/codex-app-server/mapping.d.ts +3 -6
- package/dist/codex-app-server/mapping.js +228 -36
- package/dist/codex-app-server/mcp-startup.d.ts +13 -0
- package/dist/codex-app-server/mcp-startup.js +63 -0
- package/dist/codex-app-server/process-registry.d.ts +36 -0
- package/dist/codex-app-server/process-registry.js +320 -0
- package/dist/codex-app-server/protocol.d.ts +68 -7
- package/dist/codex-app-server/transport.d.ts +2 -2
- package/dist/codex-app-server/transport.js +4 -0
- package/dist/codex-app-server/ws-channel.d.ts +8 -1
- package/dist/codex-app-server/ws-channel.js +113 -28
- package/dist/codex-child-env.js +3 -32
- package/dist/codex-home.d.ts +35 -3
- package/dist/codex-home.js +323 -18
- package/dist/codex-session-store.d.ts +23 -0
- package/dist/codex-session-store.js +21 -0
- package/dist/host.d.ts +106 -48
- package/dist/host.js +2024 -640
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/input-resources.d.ts +4 -0
- package/dist/input-resources.js +21 -5
- package/dist/models-catalog.js +12 -3
- package/dist/runner/child.d.ts +105 -28
- package/dist/runner/child.js +1625 -100
- package/dist/runner/manager.d.ts +114 -46
- package/dist/runner/manager.js +1523 -446
- package/dist/runner/protocol.d.ts +212 -24
- package/dist/runner/protocol.js +5 -0
- package/dist/runner/startup-policy.d.ts +7 -0
- package/dist/runner/startup-policy.js +10 -0
- package/dist/runner/transport.d.ts +18 -2
- package/dist/runner/transport.js +82 -3
- package/dist/runner-main.js +8 -3
- package/dist/terminal/codex-tui.d.ts +4 -0
- package/dist/terminal/codex-tui.js +5 -0
- package/dist/terminal/control-parser.d.ts +39 -0
- package/dist/terminal/control-parser.js +172 -0
- package/dist/terminal/registry.d.ts +18 -15
- package/dist/terminal/registry.js +44 -23
- package/dist/terminal/spool.d.ts +47 -0
- package/dist/terminal/spool.js +231 -0
- package/dist/terminal/tmux.d.ts +126 -74
- package/dist/terminal/tmux.js +807 -211
- package/package.json +4 -4
package/dist/runner/manager.js
CHANGED
|
@@ -18,13 +18,17 @@
|
|
|
18
18
|
import { spawn as nodeSpawn } from "node:child_process";
|
|
19
19
|
import { randomUUID } from "node:crypto";
|
|
20
20
|
import readline from "node:readline";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { diagnosticEvents, sessionDiagnosticDir, teeDiagnosticStream } from "@rynx-ai/core";
|
|
21
23
|
import { fileURLToPath } from "node:url";
|
|
22
24
|
import { AgentRuntimeError, } from "@rynx-ai/core";
|
|
23
25
|
import { listRuntimeModels } from "../models-catalog.js";
|
|
24
26
|
import { probeRuntimeStatus } from "../runtime-status.js";
|
|
25
|
-
import { terminateTmuxServer } from "../terminal/tmux.js";
|
|
26
|
-
import { fromWireError, } from "./protocol.js";
|
|
27
|
+
import { terminateTmuxServer, tmuxHasAttachedClient, tmuxWindowActivityAt, } from "../terminal/tmux.js";
|
|
28
|
+
import { RUNNER_IMAGE_CHUNK_CHARS, RUNNER_IMAGE_MAX_RESULT_CHARS, fromWireError, } from "./protocol.js";
|
|
29
|
+
import { isCodexLineageProvider, isManagedNativeProvider } from "./startup-policy.js";
|
|
27
30
|
import { StdioRunnerTransport } from "./transport.js";
|
|
31
|
+
import { cloneClaudeTranscript } from "../claude/transcript-clone.js";
|
|
28
32
|
/** Routing key for the shared capability runner (slash-command RPCs). */
|
|
29
33
|
const CAP_KEY = "__cap__";
|
|
30
34
|
/** Max stderr lines retained per handle for the crash exit-report tail. */
|
|
@@ -33,23 +37,26 @@ const STDERR_TAIL_LINES = 40;
|
|
|
33
37
|
const DEFAULT_SHUTDOWN_GRACE_MS = 5_000;
|
|
34
38
|
/** Time allowed for exit after SIGKILL before shutdown reports failure. */
|
|
35
39
|
const DEFAULT_SHUTDOWN_KILL_GRACE_MS = 5_000;
|
|
36
|
-
/**
|
|
37
|
-
|
|
38
|
-
|
|
40
|
+
/** Legacy setup-pane acknowledgement deadline for providers that do not own
|
|
41
|
+
* phase-specific startup errors. Codex/Traex are deliberately excluded. */
|
|
42
|
+
const DEFAULT_LIVE_START_TIMEOUT_MS = 30_000;
|
|
43
|
+
/** Legacy fallback for a provider without a native startup policy. */
|
|
39
44
|
const DEFAULT_LIVE_READY_TIMEOUT_MS = 25_000;
|
|
40
|
-
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
const
|
|
44
|
-
/**
|
|
45
|
-
const
|
|
46
|
-
|
|
45
|
+
/** Claude has no app-server bridge and keeps a parent-owned SessionStart
|
|
46
|
+
* deadline. Codex-lineage startup instead acknowledges pane/observer startup
|
|
47
|
+
* and lets thread discovery race injection. */
|
|
48
|
+
const DEFAULT_NATIVE_LIVE_START_TIMEOUT_MS = 60_000;
|
|
49
|
+
/** Full idle window before an inactive native pane becomes reapable. */
|
|
50
|
+
const DEFAULT_NATIVE_PANE_IDLE_TTL_MS = 60 * 60_000;
|
|
51
|
+
/** tmux output this recent independently proves that a native pane is busy. */
|
|
52
|
+
const DEFAULT_NATIVE_PANE_OUTPUT_BUSY_WINDOW_MS = 120_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;
|
|
50
56
|
const MAX_SESSION_CONTEXT_ENV_KEY_BYTES = 128;
|
|
51
57
|
const MAX_SESSION_CONTEXT_ENV_VALUE_BYTES = 8_192;
|
|
52
58
|
const MAX_SESSION_CONTEXT_ENV_TOTAL_BYTES = 32_768;
|
|
59
|
+
const MAX_TERMINAL_RESPONSE_IDS = 2_000;
|
|
53
60
|
/** A typed attach failure so transport adapters can distinguish an expected
|
|
54
61
|
* absent pane from an infrastructure failure without matching error strings. */
|
|
55
62
|
export class TerminalOpenError extends Error {
|
|
@@ -65,11 +72,16 @@ class ManagedTerminal {
|
|
|
65
72
|
sendMsg;
|
|
66
73
|
onClose;
|
|
67
74
|
ready;
|
|
68
|
-
|
|
69
|
-
|
|
75
|
+
readerDone;
|
|
76
|
+
resizeListener;
|
|
77
|
+
dimensions;
|
|
78
|
+
role = "read-only";
|
|
79
|
+
seedBytes = 0;
|
|
70
80
|
resolveReady;
|
|
71
81
|
rejectReady;
|
|
82
|
+
resolveReaderDone;
|
|
72
83
|
state = "opening";
|
|
84
|
+
pending = new Map();
|
|
73
85
|
constructor(attachId, sendMsg, onClose) {
|
|
74
86
|
this.attachId = attachId;
|
|
75
87
|
this.sendMsg = sendMsg;
|
|
@@ -78,20 +90,75 @@ class ManagedTerminal {
|
|
|
78
90
|
this.resolveReady = resolve;
|
|
79
91
|
this.rejectReady = reject;
|
|
80
92
|
});
|
|
93
|
+
this.readerDone = new Promise((resolve) => {
|
|
94
|
+
this.resolveReaderDone = resolve;
|
|
95
|
+
});
|
|
81
96
|
// Never surface an unhandled rejection if no caller awaits `ready`.
|
|
82
97
|
void this.ready.catch(() => undefined);
|
|
83
98
|
}
|
|
84
|
-
|
|
85
|
-
this.
|
|
99
|
+
onResize(listener) {
|
|
100
|
+
this.resizeListener = listener;
|
|
101
|
+
if (this.dimensions)
|
|
102
|
+
queueMicrotask(() => listener(this.dimensions));
|
|
103
|
+
}
|
|
104
|
+
readSeed(offset, maxBytes) {
|
|
105
|
+
if (this.state !== "prepared")
|
|
106
|
+
return Promise.reject(new Error("terminal is not prepared"));
|
|
107
|
+
return this.requestRead("seed", {
|
|
108
|
+
t: "term.seed.read",
|
|
109
|
+
attachId: this.attachId,
|
|
110
|
+
reqId: randomUUID(),
|
|
111
|
+
offset,
|
|
112
|
+
maxBytes,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
start() {
|
|
116
|
+
if (this.state !== "prepared")
|
|
117
|
+
return Promise.reject(new Error("terminal is not prepared"));
|
|
118
|
+
this.state = "starting";
|
|
119
|
+
const reqId = randomUUID();
|
|
120
|
+
return this.request("start", { t: "term.start", attachId: this.attachId, reqId })
|
|
121
|
+
.then(() => {
|
|
122
|
+
if (this.state === "starting")
|
|
123
|
+
this.state = "started";
|
|
124
|
+
});
|
|
86
125
|
}
|
|
87
|
-
|
|
88
|
-
this.
|
|
126
|
+
read(offset, maxBytes) {
|
|
127
|
+
if (this.state !== "started")
|
|
128
|
+
return Promise.reject(new Error("terminal has not started"));
|
|
129
|
+
return this.requestRead("read", {
|
|
130
|
+
t: "term.read",
|
|
131
|
+
attachId: this.attachId,
|
|
132
|
+
reqId: randomUUID(),
|
|
133
|
+
offset,
|
|
134
|
+
maxBytes,
|
|
135
|
+
});
|
|
89
136
|
}
|
|
90
137
|
write(data) {
|
|
91
|
-
this.
|
|
138
|
+
if (this.state !== "started" || this.role !== "owner")
|
|
139
|
+
return Promise.resolve();
|
|
140
|
+
const bytes = typeof data === "string" ? Buffer.from(data, "utf8") : Buffer.from(data);
|
|
141
|
+
if (bytes.byteLength === 0)
|
|
142
|
+
return Promise.resolve();
|
|
143
|
+
const reqId = randomUUID();
|
|
144
|
+
return this.request("input", {
|
|
145
|
+
t: "term.input",
|
|
146
|
+
attachId: this.attachId,
|
|
147
|
+
reqId,
|
|
148
|
+
dataB64: bytes.toString("base64"),
|
|
149
|
+
}).then(() => undefined);
|
|
92
150
|
}
|
|
93
151
|
resize(cols, rows) {
|
|
94
|
-
this.
|
|
152
|
+
if (this.state !== "started")
|
|
153
|
+
return Promise.resolve();
|
|
154
|
+
const reqId = randomUUID();
|
|
155
|
+
return this.request("resize", {
|
|
156
|
+
t: "term.resize",
|
|
157
|
+
attachId: this.attachId,
|
|
158
|
+
reqId,
|
|
159
|
+
cols,
|
|
160
|
+
rows,
|
|
161
|
+
}).then(() => undefined);
|
|
95
162
|
}
|
|
96
163
|
close() {
|
|
97
164
|
if (this.state === "closed")
|
|
@@ -100,43 +167,96 @@ class ManagedTerminal {
|
|
|
100
167
|
this.state = "closed";
|
|
101
168
|
this.sendMsg({ t: "term.close", attachId: this.attachId });
|
|
102
169
|
this.onClose();
|
|
170
|
+
this.rejectPending(new Error("terminal attachment was closed"));
|
|
103
171
|
if (wasOpening) {
|
|
104
172
|
this.rejectReady(new TerminalOpenError("terminal attachment was closed while opening", "terminal_open_failed"));
|
|
105
173
|
}
|
|
174
|
+
this.resolveReaderDone({ finalOffset: 0, reason: "client_closed", exitCode: 0 });
|
|
106
175
|
}
|
|
107
176
|
// ── internal (driven by onChildMessage) ──
|
|
108
|
-
|
|
177
|
+
_prepared(role, seedBytes, cols, rows) {
|
|
109
178
|
if (this.state !== "opening")
|
|
110
179
|
return;
|
|
111
|
-
this.state = "
|
|
112
|
-
this.
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
this.
|
|
180
|
+
this.state = "prepared";
|
|
181
|
+
this.role = role;
|
|
182
|
+
this.seedBytes = seedBytes;
|
|
183
|
+
this.dimensions = cols !== undefined && rows !== undefined ? { cols, rows } : undefined;
|
|
184
|
+
this.resolveReady({
|
|
185
|
+
role,
|
|
186
|
+
seedBytes,
|
|
187
|
+
...(this.dimensions ? { dimensions: this.dimensions } : {}),
|
|
188
|
+
});
|
|
116
189
|
}
|
|
117
|
-
|
|
190
|
+
_dimensions(cols, rows) {
|
|
118
191
|
if (this.state === "closed")
|
|
119
192
|
return;
|
|
120
|
-
|
|
121
|
-
this.
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
193
|
+
this.dimensions = { cols, rows };
|
|
194
|
+
this.resizeListener?.(this.dimensions);
|
|
195
|
+
}
|
|
196
|
+
_started(reqId) {
|
|
197
|
+
this.resolvePending(reqId, "start", undefined);
|
|
198
|
+
}
|
|
199
|
+
_chunk(reqId, operation, dataB64, nextOffset, done, finalOffset) {
|
|
200
|
+
this.resolvePending(reqId, operation, {
|
|
201
|
+
data: Uint8Array.from(Buffer.from(dataB64, "base64")),
|
|
202
|
+
nextOffset,
|
|
203
|
+
done,
|
|
204
|
+
...(finalOffset === undefined ? {} : { finalOffset }),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
_ack(reqId, operation) {
|
|
208
|
+
this.resolvePending(reqId, operation, undefined);
|
|
128
209
|
}
|
|
129
|
-
|
|
210
|
+
_readerDone(info) {
|
|
211
|
+
this.resolveReaderDone(info);
|
|
212
|
+
}
|
|
213
|
+
_fail(message, code = "terminal_open_failed", reqId) {
|
|
130
214
|
if (this.state === "closed")
|
|
131
215
|
return;
|
|
216
|
+
if (reqId) {
|
|
217
|
+
const pending = this.pending.get(reqId);
|
|
218
|
+
if (pending) {
|
|
219
|
+
this.pending.delete(reqId);
|
|
220
|
+
pending.reject(new TerminalOpenError(message, code));
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
132
224
|
const wasOpening = this.state === "opening";
|
|
133
225
|
this.state = "closed";
|
|
226
|
+
this.rejectPending(new TerminalOpenError(message, code));
|
|
134
227
|
if (wasOpening) {
|
|
135
228
|
this.rejectReady(new TerminalOpenError(message, code));
|
|
136
229
|
}
|
|
137
|
-
|
|
138
|
-
|
|
230
|
+
this.resolveReaderDone({ finalOffset: 0, reason: "internal", exitCode: 1 });
|
|
231
|
+
}
|
|
232
|
+
requestRead(operation, message) {
|
|
233
|
+
return this.request(operation, message);
|
|
234
|
+
}
|
|
235
|
+
request(operation, message) {
|
|
236
|
+
return new Promise((resolve, reject) => {
|
|
237
|
+
this.pending.set(message.reqId, { operation, resolve, reject });
|
|
238
|
+
try {
|
|
239
|
+
this.sendMsg(message);
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
this.pending.delete(message.reqId);
|
|
243
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
resolvePending(reqId, operation, value) {
|
|
248
|
+
const pending = this.pending.get(reqId);
|
|
249
|
+
if (!pending || pending.operation !== operation) {
|
|
250
|
+
this._fail(`terminal response ${reqId} is duplicate or out of order`);
|
|
251
|
+
return;
|
|
139
252
|
}
|
|
253
|
+
this.pending.delete(reqId);
|
|
254
|
+
pending.resolve(value);
|
|
255
|
+
}
|
|
256
|
+
rejectPending(error) {
|
|
257
|
+
for (const pending of this.pending.values())
|
|
258
|
+
pending.reject(error);
|
|
259
|
+
this.pending.clear();
|
|
140
260
|
}
|
|
141
261
|
}
|
|
142
262
|
export class RunnerManager {
|
|
@@ -144,6 +264,8 @@ export class RunnerManager {
|
|
|
144
264
|
sessionStore;
|
|
145
265
|
runnerEntry;
|
|
146
266
|
idleTtlMs;
|
|
267
|
+
nativePaneIdleTtlMs;
|
|
268
|
+
nativePaneOutputBusyWindowMs;
|
|
147
269
|
spawn;
|
|
148
270
|
childEnv;
|
|
149
271
|
sessionContextProvider;
|
|
@@ -160,15 +282,27 @@ export class RunnerManager {
|
|
|
160
282
|
shutdownKillGraceMs;
|
|
161
283
|
signalChild;
|
|
162
284
|
terminateTerminalServer;
|
|
285
|
+
terminalWindowActivityAt;
|
|
286
|
+
terminalHasAttachedClient;
|
|
287
|
+
wallNow;
|
|
163
288
|
liveStartTimeoutMs;
|
|
164
289
|
liveReadyTimeoutMs;
|
|
290
|
+
nativeLiveStartTimeoutMs;
|
|
165
291
|
liveInterruptTimeoutMs;
|
|
166
|
-
|
|
167
|
-
terminalInputCleanupRetryMs;
|
|
292
|
+
reapPromise = null;
|
|
168
293
|
stopping = false;
|
|
169
294
|
stopPromise;
|
|
170
295
|
/** Sink for mirrored {@link SessionEvent}s from every session's forwarder. */
|
|
171
296
|
mirrorListener = null;
|
|
297
|
+
/** Settles process-local projections only when an ordinary durable item has
|
|
298
|
+
* exhausted its permanent retry budget. */
|
|
299
|
+
mirrorAbandonListener = null;
|
|
300
|
+
/** Clears process-live optimistic state when /clear permanently moves the
|
|
301
|
+
* native pane away from the old Session. */
|
|
302
|
+
mirrorSupersedeListener = null;
|
|
303
|
+
/** Synchronous persistence hook for native collaboration-mode reflection.
|
|
304
|
+
* It runs before the event reaches the Session bus. */
|
|
305
|
+
collaborationModeListener = null;
|
|
172
306
|
/** Sink for session rotations (claude `/clear`·`/fork`) — server records meta. */
|
|
173
307
|
rotateListener = null;
|
|
174
308
|
/** Session keys with a live codex forwarder — never reaped while present. */
|
|
@@ -190,33 +324,37 @@ export class RunnerManager {
|
|
|
190
324
|
forkOperations = new Map();
|
|
191
325
|
forkBufferedMessages = new Map();
|
|
192
326
|
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();
|
|
196
327
|
constructor(opts) {
|
|
197
328
|
this.config = opts.config;
|
|
198
329
|
this.sessionStore = opts.sessionStore;
|
|
199
330
|
this.runnerEntry = opts.runnerEntry ?? defaultRunnerEntry();
|
|
200
331
|
this.idleTtlMs = opts.idleTtlMs ?? 300_000;
|
|
332
|
+
this.nativePaneIdleTtlMs = opts.nativePaneIdleTtlMs ?? DEFAULT_NATIVE_PANE_IDLE_TTL_MS;
|
|
333
|
+
this.nativePaneOutputBusyWindowMs = Math.max(0, opts.nativePaneOutputBusyWindowMs ?? DEFAULT_NATIVE_PANE_OUTPUT_BUSY_WINDOW_MS);
|
|
201
334
|
this.spawn = opts.spawn ?? nodeSpawn;
|
|
202
335
|
this.childEnv = opts.childEnv ?? {};
|
|
203
336
|
this.sessionContextProvider = opts.sessionContextProvider;
|
|
204
337
|
this.admissionOpen = opts.admissionOpen ?? (() => true);
|
|
205
338
|
this.admissionReserve = opts.admissionReserve;
|
|
206
339
|
this.now = opts.now ?? (() => Date.now());
|
|
340
|
+
this.wallNow = opts.wallNow ?? (() => Date.now());
|
|
207
341
|
this.defaultRuntime = opts.config.DEFAULT_RUNTIME ?? "codex";
|
|
208
342
|
this.shutdownGraceMs = Math.max(0, opts.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS);
|
|
209
343
|
this.shutdownKillGraceMs = Math.max(0, opts.shutdownKillGraceMs ?? DEFAULT_SHUTDOWN_KILL_GRACE_MS);
|
|
210
344
|
this.signalChild = opts.signalChild ?? signalRunnerChild;
|
|
211
|
-
this.terminateTerminalServer = opts.terminateTerminalServer ??
|
|
345
|
+
this.terminateTerminalServer = opts.terminateTerminalServer ??
|
|
346
|
+
((name, ownerPid) => terminateTmuxServer(name, undefined, ownerPid));
|
|
347
|
+
this.terminalWindowActivityAt = opts.terminalWindowActivityAt ??
|
|
348
|
+
((name, ownerPid) => tmuxWindowActivityAt(name, undefined, ownerPid));
|
|
349
|
+
this.terminalHasAttachedClient = opts.terminalHasAttachedClient ??
|
|
350
|
+
((name, ownerPid) => tmuxHasAttachedClient(name, undefined, ownerPid));
|
|
212
351
|
this.liveStartTimeoutMs = Math.max(1, opts.liveStartTimeoutMs ?? DEFAULT_LIVE_START_TIMEOUT_MS);
|
|
213
352
|
this.liveReadyTimeoutMs = Math.max(1, opts.liveReadyTimeoutMs ?? DEFAULT_LIVE_READY_TIMEOUT_MS);
|
|
353
|
+
this.nativeLiveStartTimeoutMs = Math.max(1, opts.nativeLiveStartTimeoutMs ?? DEFAULT_NATIVE_LIVE_START_TIMEOUT_MS);
|
|
214
354
|
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);
|
|
217
355
|
const reapIntervalMs = opts.reapIntervalMs ?? 60_000;
|
|
218
356
|
if (reapIntervalMs > 0) {
|
|
219
|
-
this.reapTimer = setInterval(() => this.reapIdle(), reapIntervalMs);
|
|
357
|
+
this.reapTimer = setInterval(() => void this.reapIdle(), reapIntervalMs);
|
|
220
358
|
this.reapTimer.unref?.();
|
|
221
359
|
}
|
|
222
360
|
else {
|
|
@@ -227,9 +365,8 @@ export class RunnerManager {
|
|
|
227
365
|
/**
|
|
228
366
|
* Open a live terminal on the session's runner child (spawning it if needed).
|
|
229
367
|
* Phase C hosts one terminal ("main") per session; the returned handle is a
|
|
230
|
-
* single attach client
|
|
231
|
-
*
|
|
232
|
-
* bridge calls it directly.
|
|
368
|
+
* single attach client with the requested `owner` (read-write) or `read-only`
|
|
369
|
+
* role. Not part of `AgentExecutor`; the WS bridge calls it directly.
|
|
233
370
|
*/
|
|
234
371
|
openTerminal(localThreadId, opts) {
|
|
235
372
|
const handle = this.getOrSpawn(localThreadId);
|
|
@@ -254,24 +391,22 @@ export class RunnerManager {
|
|
|
254
391
|
openTerminalOnHandle(handle, localThreadId, opts) {
|
|
255
392
|
handle.lastUsedAt = this.now();
|
|
256
393
|
const attachId = randomUUID();
|
|
257
|
-
const inputTracker = {
|
|
258
|
-
buffer: "",
|
|
259
|
-
previousWasCarriageReturn: false,
|
|
260
|
-
bracketedPaste: false,
|
|
261
|
-
pendingEscape: "",
|
|
262
|
-
};
|
|
263
394
|
const terminal = new ManagedTerminal(attachId, (msg) => {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
395
|
+
if (msg.t === "term.input")
|
|
396
|
+
handle.lastUsedAt = this.now();
|
|
397
|
+
if (msg.t === "term.input" && opts.role === "owner") {
|
|
398
|
+
handle.terminalWrites.set(msg.reqId, {
|
|
399
|
+
attachId,
|
|
400
|
+
reservation: this.reserveAdmission(),
|
|
401
|
+
});
|
|
402
|
+
}
|
|
267
403
|
const send = () => {
|
|
268
404
|
try {
|
|
269
405
|
handle.transport.send(msg);
|
|
270
406
|
}
|
|
271
407
|
catch (error) {
|
|
272
|
-
|
|
273
|
-
this.
|
|
274
|
-
}
|
|
408
|
+
if (msg.t === "term.input")
|
|
409
|
+
this.finishTerminalWrite(handle, msg.reqId);
|
|
275
410
|
throw error;
|
|
276
411
|
}
|
|
277
412
|
};
|
|
@@ -329,162 +464,58 @@ export class RunnerManager {
|
|
|
329
464
|
onMirror(listener) {
|
|
330
465
|
this.mirrorListener = listener;
|
|
331
466
|
}
|
|
467
|
+
onMirrorAbandon(listener) {
|
|
468
|
+
this.mirrorAbandonListener = listener;
|
|
469
|
+
}
|
|
470
|
+
onMirrorSupersede(listener) {
|
|
471
|
+
this.mirrorSupersedeListener = listener;
|
|
472
|
+
}
|
|
473
|
+
onCollaborationMode(listener) {
|
|
474
|
+
this.collaborationModeListener = listener;
|
|
475
|
+
}
|
|
332
476
|
/** Register the sink for session rotations (claude `/clear`·`/fork`): the server
|
|
333
477
|
* records the new session's meta (carry-over agent/model/title). */
|
|
334
478
|
onRotate(listener) {
|
|
335
479
|
this.rotateListener = listener;
|
|
336
480
|
}
|
|
337
|
-
/**
|
|
338
|
-
|
|
339
|
-
pendingTerminalInputCount() {
|
|
481
|
+
/** Actual transport writes and ownership transfers, independent of turn lifecycle. */
|
|
482
|
+
pendingNativeOperationCount() {
|
|
340
483
|
let count = 0;
|
|
341
|
-
for (const
|
|
342
|
-
count +=
|
|
484
|
+
for (const handle of this.childHandles) {
|
|
485
|
+
count += handle.terminalWrites.size;
|
|
486
|
+
if (handle.pendingRotation)
|
|
487
|
+
count += 1;
|
|
488
|
+
if (handle.dead)
|
|
489
|
+
count += 1;
|
|
343
490
|
}
|
|
344
491
|
return count;
|
|
345
492
|
}
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
}
|
|
493
|
+
finishTerminalWrite(handle, reqId) {
|
|
494
|
+
const write = handle.terminalWrites.get(reqId);
|
|
495
|
+
handle.terminalWrites.delete(reqId);
|
|
496
|
+
write?.reservation.release();
|
|
377
497
|
}
|
|
378
|
-
|
|
379
|
-
const
|
|
380
|
-
.
|
|
381
|
-
|
|
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);
|
|
498
|
+
finishTerminalWrites(handle, attachId) {
|
|
499
|
+
for (const [reqId, write] of handle.terminalWrites) {
|
|
500
|
+
if (attachId === undefined || write.attachId === attachId) {
|
|
501
|
+
this.finishTerminalWrite(handle, reqId);
|
|
401
502
|
}
|
|
402
503
|
}
|
|
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
504
|
}
|
|
410
505
|
currentTerminalSessionId(handle, fallback) {
|
|
411
506
|
for (const [sessionId, candidate] of this.handles) {
|
|
412
507
|
if (candidate === handle && this.liveSessionKeys.has(sessionId))
|
|
413
508
|
return sessionId;
|
|
414
509
|
}
|
|
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
|
-
});
|
|
510
|
+
return handle.activeSessionId || fallback;
|
|
482
511
|
}
|
|
483
512
|
/**
|
|
484
513
|
* Eagerly bring up a session's codex-native live view (persistent forwarder +
|
|
485
514
|
* detached `codex --remote` TUI) in its runner child, spawning the runner if
|
|
486
|
-
* needed. Idempotent.
|
|
487
|
-
*
|
|
515
|
+
* needed. Idempotent. For Codex-lineage sessions, resolves after backend
|
|
516
|
+
* preload and pane launch; known-thread observer attach and fresh-thread
|
|
517
|
+
* discovery continue in the background. Other providers retain their
|
|
518
|
+
* readiness gate.
|
|
488
519
|
*/
|
|
489
520
|
ensureLiveSession(localThreadId, opts) {
|
|
490
521
|
const admission = this.reserveAdmission();
|
|
@@ -523,42 +554,66 @@ export class RunnerManager {
|
|
|
523
554
|
this.liveSessionKeys.add(localThreadId);
|
|
524
555
|
const reqId = randomUUID();
|
|
525
556
|
return new Promise((resolve) => {
|
|
526
|
-
const
|
|
527
|
-
const
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
557
|
+
const provider = opts.execution.provider;
|
|
558
|
+
const timeoutMs = isCodexLineageProvider(provider)
|
|
559
|
+
? null
|
|
560
|
+
: !waitForReady
|
|
561
|
+
? this.liveStartTimeoutMs
|
|
562
|
+
: provider === "claude"
|
|
563
|
+
? this.nativeLiveStartTimeoutMs
|
|
564
|
+
: this.liveReadyTimeoutMs;
|
|
565
|
+
const timeout = timeoutMs === null
|
|
566
|
+
? undefined
|
|
567
|
+
: setTimeout(() => {
|
|
568
|
+
if (!handle.live.delete(reqId))
|
|
569
|
+
return;
|
|
570
|
+
const finishTimeout = () => {
|
|
571
|
+
const reason = !waitForReady
|
|
572
|
+
? `runner did not acknowledge terminal start within ${timeoutMs}ms`
|
|
573
|
+
: provider === "claude"
|
|
574
|
+
? `Claude SessionStart was not observed within ${Math.round(timeoutMs / 1_000)}s; the runner process was reset before retry`
|
|
575
|
+
: `runner did not acknowledge live readiness within ${timeoutMs}ms`;
|
|
576
|
+
this.liveErrors.set(localThreadId, {
|
|
577
|
+
message: reason,
|
|
578
|
+
code: provider === "claude"
|
|
579
|
+
? "claude_session_start_timeout"
|
|
580
|
+
: "native_runner_ack_timeout",
|
|
581
|
+
statusCode: 503,
|
|
582
|
+
});
|
|
583
|
+
this.liveSessionKeys.delete(localThreadId);
|
|
584
|
+
// Legacy providers without phase-owned startup errors retain the
|
|
585
|
+
// bounded control guard. Codex/Traex never enter this branch.
|
|
586
|
+
void this.terminateHandle(handle, reason).catch((error) => {
|
|
587
|
+
logTerminationFailure(handle, error);
|
|
588
|
+
});
|
|
589
|
+
return false;
|
|
590
|
+
};
|
|
591
|
+
const reservation = allowReservedForkTarget
|
|
592
|
+
? undefined
|
|
593
|
+
: this.forkReservations.get(localThreadId);
|
|
594
|
+
if (reservation) {
|
|
595
|
+
void reservation
|
|
596
|
+
.then(() => finishTimeout())
|
|
597
|
+
.then(resolve, () => resolve(false));
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
resolve(finishTimeout());
|
|
601
|
+
}, timeoutMs);
|
|
602
|
+
timeout?.unref?.();
|
|
553
603
|
handle.live.set(reqId, (res) => {
|
|
554
|
-
|
|
604
|
+
if (timeout)
|
|
605
|
+
clearTimeout(timeout);
|
|
555
606
|
const ok = res.ok ?? false;
|
|
556
607
|
const finish = () => {
|
|
557
608
|
if (ok) {
|
|
558
609
|
this.liveErrors.delete(localThreadId);
|
|
559
610
|
}
|
|
560
611
|
else {
|
|
561
|
-
this.liveErrors.set(localThreadId, res.error ??
|
|
612
|
+
this.liveErrors.set(localThreadId, res.error ?? {
|
|
613
|
+
message: "live session did not become ready",
|
|
614
|
+
code: "native_readiness_failed",
|
|
615
|
+
statusCode: 503,
|
|
616
|
+
});
|
|
562
617
|
}
|
|
563
618
|
return ok;
|
|
564
619
|
};
|
|
@@ -586,7 +641,11 @@ export class RunnerManager {
|
|
|
586
641
|
});
|
|
587
642
|
}
|
|
588
643
|
lastLiveSessionError(localThreadId) {
|
|
589
|
-
return this.liveErrors.get(localThreadId);
|
|
644
|
+
return this.liveErrors.get(localThreadId)?.message;
|
|
645
|
+
}
|
|
646
|
+
lastLiveSessionFailure(localThreadId) {
|
|
647
|
+
const failure = this.liveErrors.get(localThreadId);
|
|
648
|
+
return failure ? { ...failure } : undefined;
|
|
590
649
|
}
|
|
591
650
|
/**
|
|
592
651
|
* Inject a user turn into a session's live codex thread — reference implementation's
|
|
@@ -594,47 +653,63 @@ export class RunnerManager {
|
|
|
594
653
|
* all output. Resolves true when the app-server accepted the turn, false when
|
|
595
654
|
* the session has no live forwarder (caller falls back to the run path).
|
|
596
655
|
*/
|
|
597
|
-
injectMessage(localThreadId, input) {
|
|
656
|
+
injectMessage(localThreadId, input, options) {
|
|
598
657
|
const admission = this.reserveAdmission();
|
|
599
|
-
return this.injectMessageAdmitted(localThreadId, input)
|
|
658
|
+
return this.injectMessageAdmitted(localThreadId, input, options)
|
|
600
659
|
.finally(() => admission.release());
|
|
601
660
|
}
|
|
602
|
-
injectMessageAdmitted(localThreadId, input) {
|
|
661
|
+
injectMessageAdmitted(localThreadId, input, options) {
|
|
662
|
+
const rotatingHandle = this.handles.get(localThreadId);
|
|
663
|
+
if (rotatingHandle?.pendingRotation?.from === localThreadId &&
|
|
664
|
+
!rotatingHandle.dead) {
|
|
665
|
+
// Reject input addressed to the old active Session during
|
|
666
|
+
// cutover. Do not wait and then silently reopen the retired Session: the
|
|
667
|
+
// user can retry after the client follows session.rotated.
|
|
668
|
+
return Promise.resolve({ outcome: "notReady" });
|
|
669
|
+
}
|
|
603
670
|
const sourceReservation = this.sourceForkReservations.get(localThreadId);
|
|
604
671
|
if (sourceReservation) {
|
|
605
|
-
return sourceReservation.then(() => this.injectMessageAdmitted(localThreadId, input));
|
|
672
|
+
return sourceReservation.then(() => this.injectMessageAdmitted(localThreadId, input, options));
|
|
606
673
|
}
|
|
607
674
|
const reservation = this.forkReservations.get(localThreadId);
|
|
608
675
|
if (reservation) {
|
|
609
|
-
return reservation.then(() => this.injectMessageAdmitted(localThreadId, input));
|
|
676
|
+
return reservation.then(() => this.injectMessageAdmitted(localThreadId, input, options));
|
|
610
677
|
}
|
|
611
678
|
const handle = this.getOrSpawn(localThreadId);
|
|
612
679
|
handle.lastUsedAt = this.now();
|
|
613
680
|
const reqId = randomUUID();
|
|
614
681
|
return new Promise((resolve) => {
|
|
615
682
|
handle.live.set(reqId, (res) => {
|
|
616
|
-
const
|
|
683
|
+
const result = res.result ?? { outcome: "failed" };
|
|
617
684
|
const finish = () => {
|
|
618
|
-
if (outcome === "injected") {
|
|
685
|
+
if (result.outcome === "injected" || result.outcome === "steered") {
|
|
619
686
|
this.liveErrors.delete(localThreadId);
|
|
620
687
|
}
|
|
621
688
|
else {
|
|
622
|
-
this.liveErrors.set(localThreadId, res.error ??
|
|
689
|
+
this.liveErrors.set(localThreadId, res.error ?? {
|
|
690
|
+
message: `live injection ${result.outcome}`,
|
|
691
|
+
code: result.outcome === "notReady"
|
|
692
|
+
? "native_thread_not_ready"
|
|
693
|
+
: result.outcome === "notLive"
|
|
694
|
+
? "native_session_not_live"
|
|
695
|
+
: "native_message_injection_failed",
|
|
696
|
+
statusCode: 503,
|
|
697
|
+
});
|
|
623
698
|
}
|
|
624
|
-
return
|
|
699
|
+
return result;
|
|
625
700
|
};
|
|
626
701
|
const reservation = this.forkReservations.get(localThreadId);
|
|
627
702
|
if (reservation) {
|
|
628
703
|
void reservation
|
|
629
704
|
.then(() => finish())
|
|
630
|
-
.then(resolve, () => resolve("failed"));
|
|
705
|
+
.then(resolve, () => resolve({ outcome: "failed" }));
|
|
631
706
|
return;
|
|
632
707
|
}
|
|
633
708
|
resolve(finish());
|
|
634
709
|
});
|
|
635
710
|
handle.transport.send(typeof input === "string"
|
|
636
|
-
? { t: "inject", reqId, localThreadId, text: input }
|
|
637
|
-
: { t: "inject", reqId, localThreadId, input });
|
|
711
|
+
? { t: "inject", reqId, localThreadId, text: input, ...(options ? { options } : {}) }
|
|
712
|
+
: { t: "inject", reqId, localThreadId, input, ...(options ? { options } : {}) });
|
|
638
713
|
});
|
|
639
714
|
}
|
|
640
715
|
/**
|
|
@@ -674,7 +749,7 @@ export class RunnerManager {
|
|
|
674
749
|
handle.live.set(reqId, (reply) => {
|
|
675
750
|
resolve(reply.interactionResult ?? {
|
|
676
751
|
disposition: "invalid",
|
|
677
|
-
message: reply.error ?? "runner did not return an interaction result",
|
|
752
|
+
message: reply.error?.message ?? "runner did not return an interaction result",
|
|
678
753
|
});
|
|
679
754
|
});
|
|
680
755
|
handle.transport.send({
|
|
@@ -686,6 +761,34 @@ export class RunnerManager {
|
|
|
686
761
|
});
|
|
687
762
|
});
|
|
688
763
|
}
|
|
764
|
+
/** Update a loaded Codex-lineage native thread without spawning a runner. */
|
|
765
|
+
updateCollaborationMode(localThreadId, mode) {
|
|
766
|
+
const handle = this.handles.get(localThreadId);
|
|
767
|
+
if (!handle || handle.dead || !this.liveSessionKeys.has(localThreadId)) {
|
|
768
|
+
return Promise.reject(new AgentRuntimeError("Native Session is not live", 409, "native_session_not_live"));
|
|
769
|
+
}
|
|
770
|
+
handle.lastUsedAt = this.now();
|
|
771
|
+
const reqId = randomUUID();
|
|
772
|
+
return new Promise((resolve, reject) => {
|
|
773
|
+
handle.live.set(reqId, (reply) => {
|
|
774
|
+
if (reply.collaborationUpdated) {
|
|
775
|
+
resolve();
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
reject(fromWireError(reply.error ?? {
|
|
779
|
+
message: "Native collaboration mode update failed",
|
|
780
|
+
code: "collaboration_mode_update_failed",
|
|
781
|
+
statusCode: 503,
|
|
782
|
+
}));
|
|
783
|
+
});
|
|
784
|
+
handle.transport.send({
|
|
785
|
+
t: "collaboration.update",
|
|
786
|
+
reqId,
|
|
787
|
+
localThreadId,
|
|
788
|
+
mode,
|
|
789
|
+
});
|
|
790
|
+
});
|
|
791
|
+
}
|
|
689
792
|
// ── AgentCapabilities ──────────────────────────────────────────────────────
|
|
690
793
|
async listModels(runtime) {
|
|
691
794
|
return listRuntimeModels(this.config, runtime ?? this.defaultRuntime);
|
|
@@ -813,10 +916,7 @@ export class RunnerManager {
|
|
|
813
916
|
if (this.reapTimer) {
|
|
814
917
|
clearInterval(this.reapTimer);
|
|
815
918
|
}
|
|
816
|
-
const children = new Set(
|
|
817
|
-
...this.childHandles,
|
|
818
|
-
...this.terminalInputHandoffs.keys(),
|
|
819
|
-
]);
|
|
919
|
+
const children = new Set(this.childHandles);
|
|
820
920
|
const attempt = (async () => {
|
|
821
921
|
const results = await Promise.allSettled([...children].map((handle) => this.terminateHandle(handle, "runner manager stopped")));
|
|
822
922
|
this.handles.clear();
|
|
@@ -824,6 +924,7 @@ export class RunnerManager {
|
|
|
824
924
|
this.liveErrors.clear();
|
|
825
925
|
this.liveOptions.clear();
|
|
826
926
|
this.mirrorListener = null;
|
|
927
|
+
this.mirrorAbandonListener = null;
|
|
827
928
|
this.rotateListener = null;
|
|
828
929
|
const errors = results
|
|
829
930
|
.filter((result) => result.status === "rejected")
|
|
@@ -843,8 +944,7 @@ export class RunnerManager {
|
|
|
843
944
|
// ── internals ──────────────────────────────────────────────────────────────
|
|
844
945
|
handleForCleanup(localThreadId) {
|
|
845
946
|
return this.handles.get(localThreadId) ??
|
|
846
|
-
[...this.
|
|
847
|
-
handoffs.some((handoff) => handoff.sessionId === localThreadId))?.[0];
|
|
947
|
+
[...this.childHandles].find((handle) => handle.key === localThreadId || handle.activeSessionId === localThreadId);
|
|
848
948
|
}
|
|
849
949
|
/** Forward a capability to a runner child. Session-less caps (listModels/status)
|
|
850
950
|
* use the shared `CAP_KEY` child; per-thread caps pass the session's key so they
|
|
@@ -869,7 +969,6 @@ export class RunnerManager {
|
|
|
869
969
|
!allowReservedForkTarget) {
|
|
870
970
|
throw new AgentRuntimeError("Session fork is still being committed", 409, "session_fork_pending");
|
|
871
971
|
}
|
|
872
|
-
this.reapIdle();
|
|
873
972
|
const existing = this.handles.get(key);
|
|
874
973
|
if (existing && !existing.dead) {
|
|
875
974
|
existing.lastUsedAt = this.now();
|
|
@@ -946,29 +1045,67 @@ export class RunnerManager {
|
|
|
946
1045
|
}
|
|
947
1046
|
async performClaudeManagedFork(currentLocalThreadId, newLocalThreadId, options) {
|
|
948
1047
|
const source = await this.sessionStore.get(currentLocalThreadId);
|
|
1048
|
+
const getIntent = this.sessionStore.getClaudeForkIntent?.bind(this.sessionStore);
|
|
949
1049
|
const setIntent = this.sessionStore.setClaudeForkIntent?.bind(this.sessionStore);
|
|
950
1050
|
const deleteIntent = this.sessionStore.deleteClaudeForkIntent?.bind(this.sessionStore);
|
|
951
|
-
if (!source?.codexSessionId || !setIntent || !deleteIntent) {
|
|
1051
|
+
if (!source?.codexSessionId || !getIntent || !setIntent || !deleteIntent) {
|
|
952
1052
|
return {
|
|
953
1053
|
ok: false,
|
|
954
1054
|
reason: "unsupported",
|
|
955
1055
|
message: "Claude fork persistence is unavailable",
|
|
956
1056
|
};
|
|
957
1057
|
}
|
|
958
|
-
const
|
|
1058
|
+
const persistedIntent = await getIntent(newLocalThreadId);
|
|
1059
|
+
if (persistedIntent &&
|
|
1060
|
+
persistedIntent.sourceSessionId !== currentLocalThreadId) {
|
|
1061
|
+
return {
|
|
1062
|
+
ok: false,
|
|
1063
|
+
reason: "error",
|
|
1064
|
+
message: "target Session already has a different Claude fork intent",
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
// A durable fork operation may be replayed after clone+intent persistence
|
|
1068
|
+
// but before SessionStart commits the Provider binding. Reuse that exact
|
|
1069
|
+
// target id/path/prefix: re-cloning would move the fork boundary if the
|
|
1070
|
+
// source advanced (or silently lose history if it disappeared).
|
|
1071
|
+
const targetClaudeSessionId = persistedIntent?.targetClaudeSessionId ?? randomUUID();
|
|
959
1072
|
try {
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
1073
|
+
if (!persistedIntent) {
|
|
1074
|
+
let clonedTranscript = null;
|
|
1075
|
+
try {
|
|
1076
|
+
clonedTranscript = await cloneClaudeTranscript({
|
|
1077
|
+
sourceClaudeSessionId: source.codexSessionId,
|
|
1078
|
+
targetClaudeSessionId,
|
|
1079
|
+
targetCwd: options.workspace.cwd,
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
catch (error) {
|
|
1083
|
+
console.error(`[claude-fork] could not clone source transcript for ${newLocalThreadId}; target will start without Provider history: ${error instanceof Error ? error.message : String(error)}`);
|
|
1084
|
+
}
|
|
1085
|
+
await setIntent({
|
|
1086
|
+
targetSessionId: newLocalThreadId,
|
|
1087
|
+
sourceSessionId: currentLocalThreadId,
|
|
1088
|
+
sourceClaudeSessionId: source.codexSessionId,
|
|
1089
|
+
targetClaudeSessionId,
|
|
1090
|
+
...(clonedTranscript
|
|
1091
|
+
? {
|
|
1092
|
+
forkTranscriptPath: clonedTranscript.transcriptPath,
|
|
1093
|
+
...(clonedTranscript.prefixBytes === undefined
|
|
1094
|
+
? {}
|
|
1095
|
+
: { forkTranscriptPrefixBytes: clonedTranscript.prefixBytes }),
|
|
1096
|
+
}
|
|
1097
|
+
: {}),
|
|
1098
|
+
updatedAt: new Date().toISOString(),
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
await this.requestLiveSession(newLocalThreadId, {
|
|
968
1102
|
workspace: structuredClone(options.workspace),
|
|
969
1103
|
execution: structuredClone(options.execution),
|
|
970
1104
|
}, true, true);
|
|
971
|
-
|
|
1105
|
+
// SessionStart commits the binding before the child reports live.ready.
|
|
1106
|
+
// If that final ACK is lost (or the child exits in between), the durable
|
|
1107
|
+
// binding is authoritative and must not be rolled back.
|
|
1108
|
+
const target = await this.sessionStore.get(newLocalThreadId);
|
|
972
1109
|
if (!target ||
|
|
973
1110
|
target.parentSessionId !== currentLocalThreadId ||
|
|
974
1111
|
target.codexSessionId !== targetClaudeSessionId) {
|
|
@@ -978,10 +1115,8 @@ export class RunnerManager {
|
|
|
978
1115
|
return { ok: true, data: undefined };
|
|
979
1116
|
}
|
|
980
1117
|
catch (error) {
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
if (target?.parentSessionId === currentLocalThreadId) {
|
|
984
|
-
await this.sessionStore.delete(newLocalThreadId).catch(() => undefined);
|
|
1118
|
+
if (!persistedIntent) {
|
|
1119
|
+
await deleteIntent(newLocalThreadId).catch(() => undefined);
|
|
985
1120
|
}
|
|
986
1121
|
const handle = this.handles.get(newLocalThreadId);
|
|
987
1122
|
if (handle && !handle.dead) {
|
|
@@ -1011,104 +1146,552 @@ export class RunnerManager {
|
|
|
1011
1146
|
if (handle.dead)
|
|
1012
1147
|
return;
|
|
1013
1148
|
if (message.t === "mirror") {
|
|
1014
|
-
this.
|
|
1015
|
-
if (message.event.type === "response.created") {
|
|
1016
|
-
this.settleTerminalInputHandoff(handle, message.sessionId, "turn");
|
|
1017
|
-
}
|
|
1149
|
+
this.deliverMirrorMessage(handle, message);
|
|
1018
1150
|
return;
|
|
1019
1151
|
}
|
|
1020
1152
|
this.deliverRotateMessage(handle, message);
|
|
1021
1153
|
}
|
|
1022
|
-
|
|
1023
|
-
if (
|
|
1024
|
-
|
|
1025
|
-
|
|
1154
|
+
deliverMirrorMessage(handle, message) {
|
|
1155
|
+
if (handle.dead)
|
|
1156
|
+
return;
|
|
1157
|
+
if (this.isStaleMirrorGeneration(handle, message.generation)) {
|
|
1158
|
+
if ("deliveryId" in message) {
|
|
1159
|
+
handle.transport.send({
|
|
1160
|
+
t: "mirror.ack",
|
|
1161
|
+
deliveryId: message.deliveryId,
|
|
1162
|
+
generation: message.generation,
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
else {
|
|
1166
|
+
handle.imageTransfers.delete(message.transferId);
|
|
1167
|
+
handle.transport.send({
|
|
1168
|
+
t: "mirror.image.ack",
|
|
1169
|
+
transferId: message.transferId,
|
|
1170
|
+
seq: message.seq,
|
|
1171
|
+
generation: message.generation,
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1026
1174
|
return;
|
|
1027
1175
|
}
|
|
1176
|
+
if ("deliveryId" in message) {
|
|
1177
|
+
const existing = handle.mirrorDeliveries.get(message.deliveryId);
|
|
1178
|
+
if (existing && existing.sessionId !== message.sessionId) {
|
|
1179
|
+
void this.terminateHandle(handle, "conflicting ordinary mirror delivery replay").catch((error) => logTerminationFailure(handle, error));
|
|
1180
|
+
return;
|
|
1181
|
+
}
|
|
1182
|
+
if (!existing) {
|
|
1183
|
+
handle.mirrorDeliveries.set(message.deliveryId, {
|
|
1184
|
+
sessionId: message.sessionId,
|
|
1185
|
+
abandonRequested: false,
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
let observationCommitted = false;
|
|
1190
|
+
const commitObservation = () => {
|
|
1191
|
+
if (observationCommitted)
|
|
1192
|
+
return;
|
|
1193
|
+
observationCommitted = true;
|
|
1194
|
+
if (this.isStaleMirrorGeneration(handle, message.generation))
|
|
1195
|
+
return;
|
|
1196
|
+
if (message.event.type === "session.collaboration_mode") {
|
|
1197
|
+
this.collaborationModeListener?.(message.sessionId, message.event.mode);
|
|
1198
|
+
}
|
|
1199
|
+
this.observeHandleRuntimeEvent(handle, message.event);
|
|
1200
|
+
};
|
|
1201
|
+
const commitDeliveredObservation = () => {
|
|
1202
|
+
commitObservation();
|
|
1203
|
+
if (handle.dead) {
|
|
1204
|
+
// The listener may have persisted/observed this event before its
|
|
1205
|
+
// Promise settled, while failHandle still saw no committed active id.
|
|
1206
|
+
// Close that newly visible response instead of leaving server runtime
|
|
1207
|
+
// state permanently running.
|
|
1208
|
+
this.failActiveResponses(handle, "runner_crashed", handle.failureReason ?? "runner exited during mirror publication");
|
|
1209
|
+
}
|
|
1210
|
+
};
|
|
1211
|
+
let delivered;
|
|
1028
1212
|
try {
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1213
|
+
const result = this.mirrorListener?.(message.sessionId, message.event);
|
|
1214
|
+
if (result && typeof result.then === "function") {
|
|
1215
|
+
delivered = Promise.resolve(result);
|
|
1216
|
+
}
|
|
1217
|
+
else {
|
|
1218
|
+
commitObservation();
|
|
1219
|
+
delivered = Promise.resolve();
|
|
1220
|
+
}
|
|
1033
1221
|
}
|
|
1034
1222
|
catch (error) {
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1223
|
+
delivered = Promise.reject(error);
|
|
1224
|
+
}
|
|
1225
|
+
if ("transferId" in message) {
|
|
1226
|
+
const transfer = handle.imageTransfers.get(message.transferId);
|
|
1227
|
+
if (transfer)
|
|
1228
|
+
transfer.publication = delivered;
|
|
1229
|
+
void delivered.then(() => {
|
|
1230
|
+
commitDeliveredObservation();
|
|
1231
|
+
if (!handle.dead) {
|
|
1232
|
+
if (handle.imageTransfers.get(message.transferId) === transfer) {
|
|
1233
|
+
handle.imageTransfers.delete(message.transferId);
|
|
1234
|
+
}
|
|
1235
|
+
handle.transport.send({
|
|
1236
|
+
t: "mirror.image.ack",
|
|
1237
|
+
transferId: message.transferId,
|
|
1238
|
+
seq: message.seq,
|
|
1239
|
+
generation: message.generation,
|
|
1240
|
+
});
|
|
1241
|
+
}
|
|
1242
|
+
}, (error) => {
|
|
1243
|
+
if (handle.dead)
|
|
1244
|
+
return;
|
|
1245
|
+
if (handle.imageTransfers.get(message.transferId) === transfer && transfer) {
|
|
1246
|
+
delete transfer.publication;
|
|
1247
|
+
delete transfer.commitSeq;
|
|
1248
|
+
}
|
|
1249
|
+
handle.transport.send({
|
|
1250
|
+
t: "mirror.image.nack",
|
|
1251
|
+
transferId: message.transferId,
|
|
1252
|
+
seq: message.seq,
|
|
1253
|
+
generation: message.generation,
|
|
1254
|
+
classification: mirrorNackClassification(error),
|
|
1255
|
+
message: errorMessage(error),
|
|
1256
|
+
});
|
|
1038
1257
|
});
|
|
1039
|
-
return;
|
|
1040
1258
|
}
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1259
|
+
else {
|
|
1260
|
+
void delivered.then(() => {
|
|
1261
|
+
commitDeliveredObservation();
|
|
1262
|
+
this.releaseMirrorDelivery(handle, message.deliveryId);
|
|
1263
|
+
if (!handle.dead) {
|
|
1264
|
+
handle.transport.send({
|
|
1265
|
+
t: "mirror.ack",
|
|
1266
|
+
deliveryId: message.deliveryId,
|
|
1267
|
+
generation: message.generation,
|
|
1268
|
+
});
|
|
1269
|
+
}
|
|
1270
|
+
}, (error) => {
|
|
1271
|
+
const abandonToken = mirrorAbandonToken(error);
|
|
1272
|
+
if (handle.dead) {
|
|
1273
|
+
// The child can disappear while SQLite publication is still in
|
|
1274
|
+
// flight. A later exact pending-input failure still owns cleanup;
|
|
1275
|
+
// only the wire NACK is impossible after process death.
|
|
1276
|
+
if (abandonToken) {
|
|
1277
|
+
this.mirrorAbandonListener?.(message.sessionId, abandonToken);
|
|
1278
|
+
}
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
{
|
|
1282
|
+
const classification = mirrorNackClassification(error);
|
|
1283
|
+
const delivery = handle.mirrorDeliveries.get(message.deliveryId);
|
|
1284
|
+
if (delivery && abandonToken)
|
|
1285
|
+
delivery.abandonToken = abandonToken;
|
|
1286
|
+
if (classification === "permanent" &&
|
|
1287
|
+
message.attempt >= 3 &&
|
|
1288
|
+
abandonToken) {
|
|
1289
|
+
this.mirrorAbandonListener?.(message.sessionId, abandonToken);
|
|
1290
|
+
this.releaseMirrorDelivery(handle, message.deliveryId);
|
|
1291
|
+
}
|
|
1292
|
+
else if (delivery?.abandonRequested && abandonToken) {
|
|
1293
|
+
this.mirrorAbandonListener?.(delivery.sessionId, abandonToken);
|
|
1294
|
+
this.releaseMirrorDelivery(handle, message.deliveryId);
|
|
1295
|
+
}
|
|
1296
|
+
else if (!abandonToken) {
|
|
1297
|
+
this.releaseMirrorDelivery(handle, message.deliveryId);
|
|
1298
|
+
}
|
|
1299
|
+
else if (delivery && !delivery.cleanupTimer) {
|
|
1300
|
+
// A timely NACK may be retried with a fresh delivery id. Retain
|
|
1301
|
+
// the old association briefly for the timeout/NACK race, then
|
|
1302
|
+
// release it if the child never reports an ambiguous outcome.
|
|
1303
|
+
const timer = setTimeout(() => {
|
|
1304
|
+
this.releaseMirrorDelivery(handle, message.deliveryId);
|
|
1305
|
+
}, 60_000);
|
|
1306
|
+
timer.unref?.();
|
|
1307
|
+
delivery.cleanupTimer = timer;
|
|
1308
|
+
}
|
|
1309
|
+
handle.transport.send({
|
|
1310
|
+
t: "mirror.nack",
|
|
1311
|
+
deliveryId: message.deliveryId,
|
|
1312
|
+
generation: message.generation,
|
|
1313
|
+
classification,
|
|
1314
|
+
message: errorMessage(error),
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
releaseMirrorDelivery(handle, deliveryId) {
|
|
1321
|
+
const delivery = handle.mirrorDeliveries.get(deliveryId);
|
|
1322
|
+
if (!delivery)
|
|
1323
|
+
return;
|
|
1324
|
+
if (delivery.cleanupTimer)
|
|
1325
|
+
clearTimeout(delivery.cleanupTimer);
|
|
1326
|
+
handle.mirrorDeliveries.delete(deliveryId);
|
|
1327
|
+
}
|
|
1328
|
+
failMirrorImageTransfer(handle, reason) {
|
|
1329
|
+
handle.imageTransfers.clear();
|
|
1330
|
+
void this.terminateHandle(handle, reason).catch((error) => {
|
|
1331
|
+
logTerminationFailure(handle, error);
|
|
1048
1332
|
});
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
};
|
|
1072
|
-
void Promise.resolve(this.rotateListener?.(rotation))
|
|
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);
|
|
1079
|
-
if (handle.dead)
|
|
1333
|
+
}
|
|
1334
|
+
deliverRotateMessage(handle, message) {
|
|
1335
|
+
const generation = message.generation;
|
|
1336
|
+
if (handle.completedRotation?.rotationId === message.rotationId &&
|
|
1337
|
+
handle.completedRotation.generation === generation &&
|
|
1338
|
+
handle.completedRotation.from === message.from &&
|
|
1339
|
+
handle.completedRotation.to === message.to) {
|
|
1340
|
+
handle.transport.send({
|
|
1341
|
+
t: "rotate.ack",
|
|
1342
|
+
rotationId: message.rotationId,
|
|
1343
|
+
generation,
|
|
1344
|
+
});
|
|
1345
|
+
return;
|
|
1346
|
+
}
|
|
1347
|
+
let pending = handle.pendingRotation;
|
|
1348
|
+
if (pending) {
|
|
1349
|
+
if (pending.rotationId !== message.rotationId ||
|
|
1350
|
+
pending.generation !== generation ||
|
|
1351
|
+
pending.from !== message.from ||
|
|
1352
|
+
pending.to !== message.to ||
|
|
1353
|
+
pending.kind !== message.kind) {
|
|
1354
|
+
void this.terminateHandle(handle, "conflicting Session rotation replay").catch((error) => logTerminationFailure(handle, error));
|
|
1080
1355
|
return;
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1356
|
+
}
|
|
1357
|
+
if (pending.publicationComplete) {
|
|
1358
|
+
handle.transport.send({
|
|
1359
|
+
t: "rotate.ack",
|
|
1360
|
+
rotationId: pending.rotationId,
|
|
1361
|
+
generation: pending.generation,
|
|
1362
|
+
});
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
if (pending.inFlight)
|
|
1366
|
+
return;
|
|
1367
|
+
}
|
|
1368
|
+
else {
|
|
1369
|
+
if (this.forkReservations.has(message.to) ||
|
|
1370
|
+
this.sourceForkReservations.has(message.from)) {
|
|
1371
|
+
void this.terminateHandle(handle, "conflicting Session rotation").catch((error) => logTerminationFailure(handle, error));
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
try {
|
|
1375
|
+
// Rotate the runtime-local context before publishing the new Session
|
|
1376
|
+
// alias to routing or observers. This prevents the new identity from
|
|
1377
|
+
// briefly inheriting stale context after `/clear` or `/fork`.
|
|
1378
|
+
handle.sessionContext?.rotate(message.to);
|
|
1379
|
+
}
|
|
1380
|
+
catch (error) {
|
|
1381
|
+
const reason = `runner Session context rotation failed: ${errorMessage(error)}`;
|
|
1382
|
+
void this.terminateHandle(handle, reason).catch((terminationError) => {
|
|
1383
|
+
logTerminationFailure(handle, terminationError);
|
|
1384
|
+
});
|
|
1385
|
+
return;
|
|
1386
|
+
}
|
|
1387
|
+
let releaseTarget;
|
|
1388
|
+
const targetReservation = new Promise((resolve) => {
|
|
1389
|
+
releaseTarget = resolve;
|
|
1086
1390
|
});
|
|
1087
|
-
|
|
1088
|
-
|
|
1391
|
+
let releaseSource;
|
|
1392
|
+
const sourceReservation = new Promise((resolve) => {
|
|
1393
|
+
releaseSource = resolve;
|
|
1394
|
+
});
|
|
1395
|
+
pending = {
|
|
1396
|
+
rotationId: message.rotationId,
|
|
1397
|
+
generation,
|
|
1398
|
+
from: message.from,
|
|
1399
|
+
to: message.to,
|
|
1400
|
+
kind: message.kind,
|
|
1401
|
+
rotation: {
|
|
1402
|
+
from: message.from,
|
|
1403
|
+
to: message.to,
|
|
1404
|
+
kind: message.kind,
|
|
1405
|
+
workspace: structuredClone(message.workspace),
|
|
1406
|
+
execution: structuredClone(message.execution),
|
|
1407
|
+
...(message.parentSessionId ? { parentSessionId: message.parentSessionId } : {}),
|
|
1408
|
+
},
|
|
1409
|
+
targetReservation,
|
|
1410
|
+
sourceReservation,
|
|
1411
|
+
releaseTarget,
|
|
1412
|
+
releaseSource,
|
|
1413
|
+
inFlight: false,
|
|
1414
|
+
logicalTargetPublished: false,
|
|
1415
|
+
publicationComplete: false,
|
|
1416
|
+
admission: this.admissionReserve?.(),
|
|
1417
|
+
};
|
|
1418
|
+
handle.pendingRotation = pending;
|
|
1419
|
+
this.forkReservations.set(pending.to, targetReservation);
|
|
1420
|
+
this.sourceForkReservations.set(pending.from, sourceReservation);
|
|
1421
|
+
this.forkBufferedMessages.set(pending.to, []);
|
|
1422
|
+
this.forkBufferedTerminalInputs.set(pending.to, []);
|
|
1423
|
+
}
|
|
1424
|
+
pending.inFlight = true;
|
|
1425
|
+
void Promise.resolve().then(async () => {
|
|
1426
|
+
if (pending.logicalTargetPublished)
|
|
1427
|
+
return;
|
|
1428
|
+
await this.rotateListener?.(pending.rotation);
|
|
1429
|
+
if (handle.dead || handle.pendingRotation !== pending)
|
|
1430
|
+
return false;
|
|
1431
|
+
pending.logicalTargetPublished = true;
|
|
1432
|
+
return true;
|
|
1433
|
+
})
|
|
1434
|
+
.then((current) => {
|
|
1435
|
+
if (current === false || handle.dead || handle.pendingRotation !== pending)
|
|
1436
|
+
return false;
|
|
1437
|
+
return this.publishNativeRotationBinding(pending).then(() => true);
|
|
1438
|
+
})
|
|
1439
|
+
.then((current) => {
|
|
1440
|
+
if (current === false || handle.dead || handle.pendingRotation !== pending) {
|
|
1441
|
+
if (handle.pendingRotation === pending) {
|
|
1442
|
+
this.releasePendingNativeRotation(handle, pending);
|
|
1443
|
+
}
|
|
1444
|
+
return;
|
|
1089
1445
|
}
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1446
|
+
pending.inFlight = false;
|
|
1447
|
+
pending.publicationComplete = true;
|
|
1448
|
+
// Durable publication grants the child permission to move ownership.
|
|
1449
|
+
// Keep both logical ids fenced until rotate.applied proves that the
|
|
1450
|
+
// child has actually transferred its terminal/runtime registries.
|
|
1451
|
+
try {
|
|
1452
|
+
handle.transport.send({
|
|
1453
|
+
t: "rotate.ack",
|
|
1454
|
+
rotationId: pending.rotationId,
|
|
1455
|
+
generation: pending.generation,
|
|
1456
|
+
});
|
|
1457
|
+
}
|
|
1458
|
+
catch (error) {
|
|
1459
|
+
this.releasePendingNativeRotation(handle, pending);
|
|
1460
|
+
void this.terminateHandle(handle, `runner Session rotation commit failed: ${errorMessage(error)}`).catch((terminationError) => {
|
|
1461
|
+
logTerminationFailure(handle, terminationError);
|
|
1462
|
+
});
|
|
1093
1463
|
}
|
|
1094
1464
|
})
|
|
1095
1465
|
.catch((error) => {
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
.
|
|
1099
|
-
if (
|
|
1100
|
-
this.
|
|
1466
|
+
if (handle.pendingRotation !== pending)
|
|
1467
|
+
return;
|
|
1468
|
+
pending.inFlight = false;
|
|
1469
|
+
if (handle.dead) {
|
|
1470
|
+
this.releasePendingNativeRotation(handle, pending);
|
|
1471
|
+
return;
|
|
1101
1472
|
}
|
|
1102
|
-
|
|
1103
|
-
|
|
1473
|
+
try {
|
|
1474
|
+
handle.transport.send({
|
|
1475
|
+
t: "rotate.nack",
|
|
1476
|
+
rotationId: pending.rotationId,
|
|
1477
|
+
generation: pending.generation,
|
|
1478
|
+
classification: mirrorNackClassification(error),
|
|
1479
|
+
message: errorMessage(error),
|
|
1480
|
+
});
|
|
1104
1481
|
}
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1482
|
+
catch (sendError) {
|
|
1483
|
+
this.releasePendingNativeRotation(handle, pending);
|
|
1484
|
+
void this.terminateHandle(handle, `runner Session rotation NACK failed: ${errorMessage(sendError)}`).catch((terminationError) => {
|
|
1485
|
+
logTerminationFailure(handle, terminationError);
|
|
1486
|
+
});
|
|
1487
|
+
}
|
|
1488
|
+
});
|
|
1489
|
+
}
|
|
1490
|
+
/** Bridge ownership is a child-local binding, but the parent is the only
|
|
1491
|
+
* process that knows logical target publication committed. Mark that fact
|
|
1492
|
+
* durably before allowing the child to apply and expose the alias. */
|
|
1493
|
+
async publishNativeRotationBinding(pending) {
|
|
1494
|
+
await this.publishNativeRotationRecords(pending.from, pending.to, pending.kind);
|
|
1495
|
+
}
|
|
1496
|
+
async publishNativeRotationRecords(from, to, kind) {
|
|
1497
|
+
const target = await this.sessionStore.get(to);
|
|
1498
|
+
const source = await this.sessionStore.get(from);
|
|
1499
|
+
if (!target) {
|
|
1500
|
+
throw new AgentRuntimeError("native rotation target binding is missing", 422, "native_rotation_target_missing");
|
|
1501
|
+
}
|
|
1502
|
+
const publishedTarget = {
|
|
1503
|
+
...target,
|
|
1504
|
+
nativeRotationSourceSessionId: from,
|
|
1505
|
+
nativeRotationKind: kind,
|
|
1506
|
+
nativeRotationPublished: true,
|
|
1507
|
+
updatedAt: new Date().toISOString(),
|
|
1508
|
+
};
|
|
1509
|
+
await this.sessionStore.set(publishedTarget);
|
|
1510
|
+
if (!source)
|
|
1511
|
+
return;
|
|
1512
|
+
await this.sessionStore.set({
|
|
1513
|
+
...source,
|
|
1514
|
+
bridgeOwnerSessionId: `${from}-${kind}-retired-${to}`,
|
|
1515
|
+
nativeRotationTargetSessionId: to,
|
|
1516
|
+
updatedAt: new Date().toISOString(),
|
|
1109
1517
|
});
|
|
1110
1518
|
}
|
|
1519
|
+
deliverRotateAppliedMessage(handle, message) {
|
|
1520
|
+
const pending = handle.pendingRotation;
|
|
1521
|
+
if (pending &&
|
|
1522
|
+
pending.publicationComplete &&
|
|
1523
|
+
pending.rotationId === message.rotationId &&
|
|
1524
|
+
pending.generation === message.generation &&
|
|
1525
|
+
pending.from === message.from &&
|
|
1526
|
+
pending.to === message.to) {
|
|
1527
|
+
void this.finalizeNativeRotation(handle, pending);
|
|
1528
|
+
return;
|
|
1529
|
+
}
|
|
1530
|
+
if (handle.completedRotation?.rotationId === message.rotationId &&
|
|
1531
|
+
handle.completedRotation.generation === message.generation &&
|
|
1532
|
+
handle.completedRotation.from === message.from &&
|
|
1533
|
+
handle.completedRotation.to === message.to) {
|
|
1534
|
+
this.acknowledgeRotateApplied(handle, message.rotationId, message.generation);
|
|
1535
|
+
return;
|
|
1536
|
+
}
|
|
1537
|
+
void this.terminateHandle(handle, "invalid Session rotation applied confirmation").catch((error) => logTerminationFailure(handle, error));
|
|
1538
|
+
}
|
|
1539
|
+
finalizeNativeRotation(handle, pending) {
|
|
1540
|
+
if (pending.finalization)
|
|
1541
|
+
return pending.finalization;
|
|
1542
|
+
const operation = (async () => {
|
|
1543
|
+
if (handle.pendingRotation !== pending)
|
|
1544
|
+
return false;
|
|
1545
|
+
pending.inFlight = true;
|
|
1546
|
+
try {
|
|
1547
|
+
// Change the active Session only after ownership transfer. In
|
|
1548
|
+
// this IPC topology rotate.applied is the child-owned equivalent proof.
|
|
1549
|
+
handle.activeResponseIds.clear();
|
|
1550
|
+
for (const [key, candidate] of this.handles) {
|
|
1551
|
+
if (candidate !== handle)
|
|
1552
|
+
continue;
|
|
1553
|
+
this.handles.delete(key);
|
|
1554
|
+
this.liveSessionKeys.delete(key);
|
|
1555
|
+
this.liveOptions.delete(key);
|
|
1556
|
+
}
|
|
1557
|
+
handle.activeSessionId = pending.to;
|
|
1558
|
+
this.handles.set(pending.to, handle);
|
|
1559
|
+
this.liveSessionKeys.add(pending.to);
|
|
1560
|
+
this.liveOptions.set(pending.to, {
|
|
1561
|
+
workspace: structuredClone(pending.rotation.workspace),
|
|
1562
|
+
execution: structuredClone(pending.rotation.execution),
|
|
1563
|
+
});
|
|
1564
|
+
handle.completedRotation = {
|
|
1565
|
+
rotationId: pending.rotationId,
|
|
1566
|
+
generation: pending.generation,
|
|
1567
|
+
from: pending.from,
|
|
1568
|
+
to: pending.to,
|
|
1569
|
+
};
|
|
1570
|
+
// Old-Session presentation is ordered internally but must not hold the
|
|
1571
|
+
// ownership-transfer ACK. Each notice still receives the server queue's
|
|
1572
|
+
// bounded result window; the rotated target can proceed immediately.
|
|
1573
|
+
const noticePublication = this.publishNativeRotationNotice(pending);
|
|
1574
|
+
if (handle.dead)
|
|
1575
|
+
return false;
|
|
1576
|
+
this.acknowledgeRotateApplied(handle, pending.rotationId, pending.generation);
|
|
1577
|
+
for (const entry of this.forkBufferedMessages.get(pending.to) ?? []) {
|
|
1578
|
+
this.deliverForkBufferedMessage(entry.handle, entry.message);
|
|
1579
|
+
}
|
|
1580
|
+
for (const entry of this.forkBufferedTerminalInputs.get(pending.to) ?? []) {
|
|
1581
|
+
if (!entry.handle.dead)
|
|
1582
|
+
entry.handle.transport.send(entry.message);
|
|
1583
|
+
}
|
|
1584
|
+
void noticePublication.catch((error) => {
|
|
1585
|
+
console.error(`Session rotation notice sequence failed for ${pending.from} -> ${pending.to}: ${errorMessage(error)}`);
|
|
1586
|
+
});
|
|
1587
|
+
return true;
|
|
1588
|
+
}
|
|
1589
|
+
catch (error) {
|
|
1590
|
+
void this.terminateHandle(handle, `runner Session rotation finalization failed: ${errorMessage(error)}`).catch((terminationError) => {
|
|
1591
|
+
logTerminationFailure(handle, terminationError);
|
|
1592
|
+
});
|
|
1593
|
+
return false;
|
|
1594
|
+
}
|
|
1595
|
+
finally {
|
|
1596
|
+
pending.inFlight = false;
|
|
1597
|
+
this.releasePendingNativeRotation(handle, pending);
|
|
1598
|
+
}
|
|
1599
|
+
})();
|
|
1600
|
+
pending.finalization = operation;
|
|
1601
|
+
return operation;
|
|
1602
|
+
}
|
|
1603
|
+
acknowledgeRotateApplied(handle, rotationId, generation) {
|
|
1604
|
+
try {
|
|
1605
|
+
handle.transport.send({ t: "rotate.applied.ack", rotationId, generation });
|
|
1606
|
+
}
|
|
1607
|
+
catch (error) {
|
|
1608
|
+
void this.terminateHandle(handle, `runner Session rotation applied ACK failed: ${errorMessage(error)}`).catch((terminationError) => {
|
|
1609
|
+
logTerminationFailure(handle, terminationError);
|
|
1610
|
+
});
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
releasePendingNativeRotation(handle, pending) {
|
|
1614
|
+
if (handle.pendingRotation !== pending)
|
|
1615
|
+
return;
|
|
1616
|
+
delete handle.pendingRotation;
|
|
1617
|
+
if (this.forkReservations.get(pending.to) === pending.targetReservation) {
|
|
1618
|
+
this.forkReservations.delete(pending.to);
|
|
1619
|
+
}
|
|
1620
|
+
if (this.sourceForkReservations.get(pending.from) === pending.sourceReservation) {
|
|
1621
|
+
this.sourceForkReservations.delete(pending.from);
|
|
1622
|
+
}
|
|
1623
|
+
this.forkBufferedMessages.delete(pending.to);
|
|
1624
|
+
this.forkBufferedTerminalInputs.delete(pending.to);
|
|
1625
|
+
pending.releaseTarget();
|
|
1626
|
+
pending.releaseSource();
|
|
1627
|
+
pending.admission?.release();
|
|
1628
|
+
}
|
|
1629
|
+
async publishNativeRotationNotice(pending) {
|
|
1630
|
+
// Only /clear supersedes the old Session. A /fork keeps
|
|
1631
|
+
// both conversations live and therefore emits no old-session redirect.
|
|
1632
|
+
if (pending.kind !== "clear" || pending.from === pending.to)
|
|
1633
|
+
return;
|
|
1634
|
+
this.mirrorSupersedeListener?.(pending.from);
|
|
1635
|
+
const noticeId = `msg_clear_${randomUUID().replaceAll("-", "")}`;
|
|
1636
|
+
const notice = {
|
|
1637
|
+
type: "response.output_item.done",
|
|
1638
|
+
responseId: noticeId,
|
|
1639
|
+
item: {
|
|
1640
|
+
id: noticeId,
|
|
1641
|
+
sessionId: pending.from,
|
|
1642
|
+
position: 0,
|
|
1643
|
+
responseId: noticeId,
|
|
1644
|
+
status: "completed",
|
|
1645
|
+
createdAt: Date.now(),
|
|
1646
|
+
type: "message",
|
|
1647
|
+
data: {
|
|
1648
|
+
role: "assistant",
|
|
1649
|
+
content: [{
|
|
1650
|
+
type: "output_text",
|
|
1651
|
+
text: "This session was ended by `/clear`. " +
|
|
1652
|
+
`Continue in [the new session](../${encodeURIComponent(pending.to)}). ` +
|
|
1653
|
+
"You can also send a message here to resume this session.",
|
|
1654
|
+
}],
|
|
1655
|
+
},
|
|
1656
|
+
},
|
|
1657
|
+
};
|
|
1658
|
+
const transient = {
|
|
1659
|
+
type: "session.rotated",
|
|
1660
|
+
sessionId: pending.from,
|
|
1661
|
+
newSessionId: pending.to,
|
|
1662
|
+
kind: "clear",
|
|
1663
|
+
};
|
|
1664
|
+
// Preserve the post-clear order: stop the old spinner, append a
|
|
1665
|
+
// durable assistant notice, then tell a live viewer to follow the target.
|
|
1666
|
+
const publications = [];
|
|
1667
|
+
for (const event of [
|
|
1668
|
+
{
|
|
1669
|
+
type: "session.status",
|
|
1670
|
+
sessionId: pending.from,
|
|
1671
|
+
status: "idle",
|
|
1672
|
+
backgroundTaskCount: 0,
|
|
1673
|
+
},
|
|
1674
|
+
notice,
|
|
1675
|
+
transient,
|
|
1676
|
+
]) {
|
|
1677
|
+
try {
|
|
1678
|
+
// The server-side SessionMirrorQueue owns the per-record
|
|
1679
|
+
// per-record result deadline. A second enqueue-time timer here would
|
|
1680
|
+
// expire later records before they reach the head of that queue.
|
|
1681
|
+
publications.push(Promise.resolve(this.mirrorListener?.(pending.from, event)).catch((error) => {
|
|
1682
|
+
console.error(`Session rotation notice failed for ${pending.from} -> ${pending.to}: ${errorMessage(error)}`);
|
|
1683
|
+
}));
|
|
1684
|
+
}
|
|
1685
|
+
catch (error) {
|
|
1686
|
+
console.error(`Session rotation notice failed for ${pending.from} -> ${pending.to}: ${errorMessage(error)}`);
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
await Promise.all(publications);
|
|
1690
|
+
}
|
|
1111
1691
|
spawnHandle(key) {
|
|
1692
|
+
const logDir = join(sessionDiagnosticDir("runtime", key), "instances", randomUUID());
|
|
1693
|
+
const event = diagnosticEvents(join(logDir, "process-events.jsonl"), { sessionId: key });
|
|
1694
|
+
event("runner.spawn");
|
|
1112
1695
|
const args = this.runnerEntry.endsWith(".ts")
|
|
1113
1696
|
? ["--import", "tsx", this.runnerEntry]
|
|
1114
1697
|
: [this.runnerEntry];
|
|
@@ -1129,6 +1712,7 @@ export class RunnerManager {
|
|
|
1129
1712
|
...this.childEnv,
|
|
1130
1713
|
...(sessionContext?.childEnv ?? {}),
|
|
1131
1714
|
RYNX_RUNNER_SESSION: key,
|
|
1715
|
+
RYNX_RUNTIME_LOG_DIR: logDir,
|
|
1132
1716
|
},
|
|
1133
1717
|
});
|
|
1134
1718
|
}
|
|
@@ -1137,24 +1721,36 @@ export class RunnerManager {
|
|
|
1137
1721
|
throw error;
|
|
1138
1722
|
}
|
|
1139
1723
|
const completion = observeChildCompletion(child);
|
|
1724
|
+
teeDiagnosticStream(child.stderr, join(logDir, "runner.stderr.log"));
|
|
1725
|
+
child.on("spawn", () => event("runner.started", { pid: child.pid }));
|
|
1726
|
+
child.on("error", (error) => event("runner.error", { message: error.message }));
|
|
1727
|
+
child.on("exit", (code, signal) => event("runner.exit", { code, signal }));
|
|
1140
1728
|
const transport = new StdioRunnerTransport(child.stdout, child.stdin);
|
|
1141
1729
|
const handle = {
|
|
1142
1730
|
key,
|
|
1731
|
+
activeSessionId: key,
|
|
1143
1732
|
child,
|
|
1144
1733
|
transport,
|
|
1145
1734
|
stderr: [],
|
|
1146
1735
|
lastUsedAt: this.now(),
|
|
1736
|
+
activeResponseIds: new Set(),
|
|
1737
|
+
terminalResponseIds: new Set(),
|
|
1738
|
+
terminalWrites: new Map(),
|
|
1147
1739
|
dead: false,
|
|
1148
1740
|
completion,
|
|
1149
|
-
terminalCleanupRetryFailures: 0,
|
|
1150
1741
|
processGroup,
|
|
1151
1742
|
...(sessionContext ? { sessionContext } : {}),
|
|
1152
1743
|
caps: new Map(),
|
|
1153
1744
|
terminals: new Map(),
|
|
1154
1745
|
live: new Map(),
|
|
1746
|
+
imageTransfers: new Map(),
|
|
1747
|
+
mirrorDeliveries: new Map(),
|
|
1155
1748
|
};
|
|
1156
1749
|
this.childHandles.add(handle);
|
|
1157
|
-
void completion.then(() =>
|
|
1750
|
+
void completion.then(() => {
|
|
1751
|
+
this.finishTerminalWrites(handle);
|
|
1752
|
+
this.childHandles.delete(handle);
|
|
1753
|
+
});
|
|
1158
1754
|
transport.onMessage((msg) => this.onChildMessage(handle, msg));
|
|
1159
1755
|
if (child.stderr) {
|
|
1160
1756
|
const rl = readline.createInterface({ input: child.stderr, crlfDelay: Infinity });
|
|
@@ -1165,8 +1761,14 @@ export class RunnerManager {
|
|
|
1165
1761
|
}
|
|
1166
1762
|
});
|
|
1167
1763
|
}
|
|
1168
|
-
|
|
1169
|
-
|
|
1764
|
+
const failAndReap = (reason) => {
|
|
1765
|
+
this.failHandle(handle, reason);
|
|
1766
|
+
void this.terminateHandle(handle, reason).catch((error) => {
|
|
1767
|
+
logTerminationFailure(handle, error);
|
|
1768
|
+
});
|
|
1769
|
+
};
|
|
1770
|
+
child.on("error", (error) => failAndReap(error.message));
|
|
1771
|
+
child.on("exit", (code, signal) => failAndReap(`runner exited (code=${code ?? "null"}${signal ? `, signal=${signal}` : ""})`));
|
|
1170
1772
|
return handle;
|
|
1171
1773
|
}
|
|
1172
1774
|
onChildMessage(handle, msg) {
|
|
@@ -1187,55 +1789,293 @@ export class RunnerManager {
|
|
|
1187
1789
|
}
|
|
1188
1790
|
return;
|
|
1189
1791
|
}
|
|
1190
|
-
case "term.
|
|
1191
|
-
handle.terminals.get(msg.attachId)?.
|
|
1792
|
+
case "term.prepared":
|
|
1793
|
+
handle.terminals.get(msg.attachId)?._prepared(msg.role, msg.seedBytes, msg.cols, msg.rows);
|
|
1192
1794
|
return;
|
|
1193
|
-
case "term.
|
|
1194
|
-
handle.terminals.get(msg.attachId)?.
|
|
1795
|
+
case "term.seed.chunk":
|
|
1796
|
+
handle.terminals.get(msg.attachId)?._chunk(msg.reqId, "seed", msg.dataB64, msg.nextOffset, msg.done, msg.finalOffset);
|
|
1195
1797
|
return;
|
|
1196
|
-
case "term.
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1798
|
+
case "term.started":
|
|
1799
|
+
handle.terminals.get(msg.attachId)?._started(msg.reqId);
|
|
1800
|
+
return;
|
|
1801
|
+
case "term.chunk":
|
|
1802
|
+
handle.terminals.get(msg.attachId)?._chunk(msg.reqId, "read", msg.dataB64, msg.nextOffset, msg.done, msg.finalOffset);
|
|
1803
|
+
return;
|
|
1804
|
+
case "term.dimensions":
|
|
1805
|
+
handle.terminals.get(msg.attachId)?._dimensions(msg.cols, msg.rows);
|
|
1806
|
+
return;
|
|
1807
|
+
case "term.reader.done":
|
|
1808
|
+
handle.terminals.get(msg.attachId)?._readerDone({
|
|
1809
|
+
finalOffset: msg.finalOffset,
|
|
1810
|
+
reason: msg.reason,
|
|
1811
|
+
exitCode: msg.exitCode,
|
|
1812
|
+
});
|
|
1813
|
+
return;
|
|
1814
|
+
case "term.ack":
|
|
1815
|
+
if (msg.operation === "input")
|
|
1816
|
+
this.finishTerminalWrite(handle, msg.reqId);
|
|
1817
|
+
handle.terminals.get(msg.attachId)?._ack(msg.reqId, msg.operation);
|
|
1200
1818
|
return;
|
|
1201
|
-
}
|
|
1202
1819
|
case "term.error": {
|
|
1820
|
+
if (msg.reqId)
|
|
1821
|
+
this.finishTerminalWrite(handle, msg.reqId);
|
|
1822
|
+
else
|
|
1823
|
+
this.finishTerminalWrites(handle, msg.attachId);
|
|
1203
1824
|
const terminal = handle.terminals.get(msg.attachId);
|
|
1204
|
-
|
|
1205
|
-
|
|
1825
|
+
if (!msg.reqId)
|
|
1826
|
+
handle.terminals.delete(msg.attachId);
|
|
1827
|
+
terminal?._fail(msg.message, msg.code, msg.reqId);
|
|
1206
1828
|
return;
|
|
1207
1829
|
}
|
|
1208
1830
|
case "mirror":
|
|
1209
1831
|
if (handle.dead)
|
|
1210
1832
|
return;
|
|
1833
|
+
if (!msg.deliveryId ||
|
|
1834
|
+
!Number.isSafeInteger(msg.generation) ||
|
|
1835
|
+
!Number.isSafeInteger(msg.attempt) ||
|
|
1836
|
+
msg.attempt < 1) {
|
|
1837
|
+
void this.terminateHandle(handle, "invalid durable mirror delivery frame").catch((error) => logTerminationFailure(handle, error));
|
|
1838
|
+
return;
|
|
1839
|
+
}
|
|
1211
1840
|
if (this.bufferForkTargetMessage(handle, msg))
|
|
1212
1841
|
return;
|
|
1213
|
-
this.
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1842
|
+
this.deliverMirrorMessage(handle, msg);
|
|
1843
|
+
return;
|
|
1844
|
+
case "mirror.abandon": {
|
|
1845
|
+
if (!msg.deliveryId || !Number.isSafeInteger(msg.generation)) {
|
|
1846
|
+
void this.terminateHandle(handle, "invalid mirror abandon frame").catch((error) => logTerminationFailure(handle, error));
|
|
1847
|
+
return;
|
|
1848
|
+
}
|
|
1849
|
+
const delivery = handle.mirrorDeliveries.get(msg.deliveryId);
|
|
1850
|
+
if (!delivery)
|
|
1851
|
+
return;
|
|
1852
|
+
delivery.abandonRequested = true;
|
|
1853
|
+
if (delivery.abandonToken) {
|
|
1854
|
+
this.mirrorAbandonListener?.(delivery.sessionId, delivery.abandonToken);
|
|
1855
|
+
this.releaseMirrorDelivery(handle, msg.deliveryId);
|
|
1219
1856
|
}
|
|
1220
1857
|
return;
|
|
1858
|
+
}
|
|
1859
|
+
case "mirror.image.begin": {
|
|
1860
|
+
if (!Number.isSafeInteger(msg.generation)) {
|
|
1861
|
+
this.failMirrorImageTransfer(handle, "invalid generated image transfer generation");
|
|
1862
|
+
return;
|
|
1863
|
+
}
|
|
1864
|
+
if (this.isStaleMirrorGeneration(handle, msg.generation)) {
|
|
1865
|
+
handle.transport.send({
|
|
1866
|
+
t: "mirror.image.ack",
|
|
1867
|
+
transferId: msg.transferId,
|
|
1868
|
+
seq: 0,
|
|
1869
|
+
generation: msg.generation,
|
|
1870
|
+
});
|
|
1871
|
+
return;
|
|
1872
|
+
}
|
|
1873
|
+
const existing = handle.imageTransfers.get(msg.transferId);
|
|
1874
|
+
if (existing) {
|
|
1875
|
+
const exactReplay = existing.sessionId === msg.sessionId &&
|
|
1876
|
+
existing.generation === msg.generation &&
|
|
1877
|
+
existing.totalChars === msg.totalChars &&
|
|
1878
|
+
JSON.stringify(existing.event) === JSON.stringify(msg.event);
|
|
1879
|
+
if (!exactReplay) {
|
|
1880
|
+
this.failMirrorImageTransfer(handle, "conflicting generated image transfer replay");
|
|
1881
|
+
return;
|
|
1882
|
+
}
|
|
1883
|
+
handle.transport.send({
|
|
1884
|
+
t: "mirror.image.ack",
|
|
1885
|
+
transferId: msg.transferId,
|
|
1886
|
+
seq: 0,
|
|
1887
|
+
generation: msg.generation,
|
|
1888
|
+
});
|
|
1889
|
+
return;
|
|
1890
|
+
}
|
|
1891
|
+
if (handle.dead ||
|
|
1892
|
+
!Number.isSafeInteger(msg.totalChars) ||
|
|
1893
|
+
msg.totalChars <= 0 ||
|
|
1894
|
+
msg.totalChars > RUNNER_IMAGE_MAX_RESULT_CHARS ||
|
|
1895
|
+
!isDetachedGeneratedImageEvent(msg.event)) {
|
|
1896
|
+
this.failMirrorImageTransfer(handle, "invalid generated image transfer start");
|
|
1897
|
+
return;
|
|
1898
|
+
}
|
|
1899
|
+
handle.imageTransfers.set(msg.transferId, {
|
|
1900
|
+
sessionId: msg.sessionId,
|
|
1901
|
+
generation: msg.generation,
|
|
1902
|
+
event: msg.event,
|
|
1903
|
+
totalChars: msg.totalChars,
|
|
1904
|
+
receivedChars: 0,
|
|
1905
|
+
nextSeq: 1,
|
|
1906
|
+
chunks: [],
|
|
1907
|
+
});
|
|
1908
|
+
handle.transport.send({
|
|
1909
|
+
t: "mirror.image.ack",
|
|
1910
|
+
transferId: msg.transferId,
|
|
1911
|
+
seq: 0,
|
|
1912
|
+
generation: msg.generation,
|
|
1913
|
+
});
|
|
1914
|
+
return;
|
|
1915
|
+
}
|
|
1916
|
+
case "mirror.image.chunk": {
|
|
1917
|
+
if (!Number.isSafeInteger(msg.generation)) {
|
|
1918
|
+
this.failMirrorImageTransfer(handle, "invalid generated image transfer generation");
|
|
1919
|
+
return;
|
|
1920
|
+
}
|
|
1921
|
+
if (this.isStaleMirrorGeneration(handle, msg.generation)) {
|
|
1922
|
+
handle.transport.send({
|
|
1923
|
+
t: "mirror.image.ack",
|
|
1924
|
+
transferId: msg.transferId,
|
|
1925
|
+
seq: msg.seq,
|
|
1926
|
+
generation: msg.generation,
|
|
1927
|
+
});
|
|
1928
|
+
return;
|
|
1929
|
+
}
|
|
1930
|
+
const transfer = handle.imageTransfers.get(msg.transferId);
|
|
1931
|
+
if (transfer &&
|
|
1932
|
+
msg.seq > 0 &&
|
|
1933
|
+
msg.seq < transfer.nextSeq &&
|
|
1934
|
+
transfer.chunks[msg.seq - 1] === msg.data) {
|
|
1935
|
+
handle.transport.send({
|
|
1936
|
+
t: "mirror.image.ack",
|
|
1937
|
+
transferId: msg.transferId,
|
|
1938
|
+
seq: msg.seq,
|
|
1939
|
+
generation: transfer.generation,
|
|
1940
|
+
});
|
|
1941
|
+
return;
|
|
1942
|
+
}
|
|
1943
|
+
if (handle.dead ||
|
|
1944
|
+
!transfer ||
|
|
1945
|
+
msg.seq !== transfer.nextSeq ||
|
|
1946
|
+
!msg.data ||
|
|
1947
|
+
msg.data.length > RUNNER_IMAGE_CHUNK_CHARS ||
|
|
1948
|
+
transfer.receivedChars + msg.data.length > transfer.totalChars) {
|
|
1949
|
+
this.failMirrorImageTransfer(handle, "invalid generated image transfer chunk");
|
|
1950
|
+
return;
|
|
1951
|
+
}
|
|
1952
|
+
transfer.chunks.push(msg.data);
|
|
1953
|
+
transfer.receivedChars += msg.data.length;
|
|
1954
|
+
transfer.nextSeq += 1;
|
|
1955
|
+
handle.transport.send({
|
|
1956
|
+
t: "mirror.image.ack",
|
|
1957
|
+
transferId: msg.transferId,
|
|
1958
|
+
seq: msg.seq,
|
|
1959
|
+
generation: transfer.generation,
|
|
1960
|
+
});
|
|
1961
|
+
return;
|
|
1962
|
+
}
|
|
1963
|
+
case "mirror.image.commit": {
|
|
1964
|
+
if (!Number.isSafeInteger(msg.generation)) {
|
|
1965
|
+
this.failMirrorImageTransfer(handle, "invalid generated image transfer generation");
|
|
1966
|
+
return;
|
|
1967
|
+
}
|
|
1968
|
+
if (this.isStaleMirrorGeneration(handle, msg.generation)) {
|
|
1969
|
+
handle.imageTransfers.delete(msg.transferId);
|
|
1970
|
+
handle.transport.send({
|
|
1971
|
+
t: "mirror.image.ack",
|
|
1972
|
+
transferId: msg.transferId,
|
|
1973
|
+
seq: msg.seq,
|
|
1974
|
+
generation: msg.generation,
|
|
1975
|
+
});
|
|
1976
|
+
return;
|
|
1977
|
+
}
|
|
1978
|
+
const transfer = handle.imageTransfers.get(msg.transferId);
|
|
1979
|
+
if (handle.dead ||
|
|
1980
|
+
!transfer ||
|
|
1981
|
+
msg.seq !== transfer.nextSeq ||
|
|
1982
|
+
transfer.receivedChars !== transfer.totalChars) {
|
|
1983
|
+
this.failMirrorImageTransfer(handle, "invalid generated image transfer commit");
|
|
1984
|
+
return;
|
|
1985
|
+
}
|
|
1986
|
+
if (transfer.commitSeq !== undefined) {
|
|
1987
|
+
if (transfer.commitSeq !== msg.seq) {
|
|
1988
|
+
this.failMirrorImageTransfer(handle, "conflicting generated image transfer commit");
|
|
1989
|
+
}
|
|
1990
|
+
// The original publication owns the eventual ACK/NACK. A replay can
|
|
1991
|
+
// arrive after its ACK timer elapsed; that eventual result resolves
|
|
1992
|
+
// the replacement waiter because transferId+seq are stable.
|
|
1993
|
+
return;
|
|
1994
|
+
}
|
|
1995
|
+
transfer.commitSeq = msg.seq;
|
|
1996
|
+
const event = attachGeneratedImageResult(transfer.event, transfer.chunks.join(""));
|
|
1997
|
+
if (!event) {
|
|
1998
|
+
this.failMirrorImageTransfer(handle, "generated image transfer target changed");
|
|
1999
|
+
return;
|
|
2000
|
+
}
|
|
2001
|
+
const mirrored = {
|
|
2002
|
+
t: "mirror",
|
|
2003
|
+
sessionId: transfer.sessionId,
|
|
2004
|
+
event,
|
|
2005
|
+
transferId: msg.transferId,
|
|
2006
|
+
seq: msg.seq,
|
|
2007
|
+
generation: transfer.generation,
|
|
2008
|
+
};
|
|
2009
|
+
if (this.bufferForkTargetMessage(handle, mirrored)) {
|
|
2010
|
+
handle.transport.send({
|
|
2011
|
+
t: "mirror.image.hold",
|
|
2012
|
+
transferId: msg.transferId,
|
|
2013
|
+
seq: msg.seq,
|
|
2014
|
+
generation: transfer.generation,
|
|
2015
|
+
});
|
|
2016
|
+
return;
|
|
2017
|
+
}
|
|
2018
|
+
this.deliverMirrorMessage(handle, mirrored);
|
|
2019
|
+
return;
|
|
2020
|
+
}
|
|
2021
|
+
case "mirror.image.abandon": {
|
|
2022
|
+
if (!Number.isSafeInteger(msg.generation))
|
|
2023
|
+
return;
|
|
2024
|
+
const transfer = handle.imageTransfers.get(msg.transferId);
|
|
2025
|
+
if (transfer && transfer.generation === msg.generation) {
|
|
2026
|
+
handle.imageTransfers.delete(msg.transferId);
|
|
2027
|
+
}
|
|
2028
|
+
return;
|
|
2029
|
+
}
|
|
1221
2030
|
case "rotate": {
|
|
1222
2031
|
if (handle.dead)
|
|
1223
2032
|
return;
|
|
2033
|
+
if (!msg.rotationId || !Number.isSafeInteger(msg.generation)) {
|
|
2034
|
+
void this.terminateHandle(handle, "invalid Session rotation frame").catch((error) => logTerminationFailure(handle, error));
|
|
2035
|
+
return;
|
|
2036
|
+
}
|
|
1224
2037
|
if (this.bufferForkTargetMessage(handle, msg))
|
|
1225
2038
|
return;
|
|
1226
2039
|
this.deliverRotateMessage(handle, msg);
|
|
1227
2040
|
return;
|
|
1228
2041
|
}
|
|
2042
|
+
case "rotate.applied": {
|
|
2043
|
+
if (handle.dead)
|
|
2044
|
+
return;
|
|
2045
|
+
this.deliverRotateAppliedMessage(handle, msg);
|
|
2046
|
+
return;
|
|
2047
|
+
}
|
|
2048
|
+
case "terminal.lifecycle.ended": {
|
|
2049
|
+
if (msg.lifecycle === "required") {
|
|
2050
|
+
void this.terminateHandle(handle, `required Terminal exited with status ${msg.status}`).catch((error) => logTerminationFailure(handle, error));
|
|
2051
|
+
}
|
|
2052
|
+
return;
|
|
2053
|
+
}
|
|
2054
|
+
case "terminal.reaped": {
|
|
2055
|
+
const resolve = handle.live.get(msg.reqId);
|
|
2056
|
+
handle.live.delete(msg.reqId);
|
|
2057
|
+
resolve?.({ ok: msg.ok, error: msg.error });
|
|
2058
|
+
return;
|
|
2059
|
+
}
|
|
1229
2060
|
case "live.ready":
|
|
1230
2061
|
case "interrupted":
|
|
1231
2062
|
case "injected": {
|
|
1232
2063
|
const resolve = handle.live.get(msg.reqId);
|
|
1233
2064
|
handle.live.delete(msg.reqId);
|
|
1234
2065
|
resolve?.(msg.t === "injected"
|
|
1235
|
-
? {
|
|
2066
|
+
? { result: msg.result, error: msg.error }
|
|
1236
2067
|
: { ok: msg.ok, error: msg.error });
|
|
1237
2068
|
return;
|
|
1238
2069
|
}
|
|
2070
|
+
case "collaboration.updated": {
|
|
2071
|
+
const resolve = handle.live.get(msg.reqId);
|
|
2072
|
+
handle.live.delete(msg.reqId);
|
|
2073
|
+
resolve?.({
|
|
2074
|
+
collaborationUpdated: msg.ok,
|
|
2075
|
+
...(msg.error ? { error: msg.error } : {}),
|
|
2076
|
+
});
|
|
2077
|
+
return;
|
|
2078
|
+
}
|
|
1239
2079
|
case "interaction.resolved": {
|
|
1240
2080
|
const resolve = handle.live.get(msg.reqId);
|
|
1241
2081
|
handle.live.delete(msg.reqId);
|
|
@@ -1245,11 +2085,12 @@ export class RunnerManager {
|
|
|
1245
2085
|
}
|
|
1246
2086
|
}
|
|
1247
2087
|
/** Mark a handle dead and reject every pending run/cap with the exit reason. */
|
|
1248
|
-
failHandle(handle, reason) {
|
|
2088
|
+
failHandle(handle, reason, opts = {}) {
|
|
1249
2089
|
if (handle.dead) {
|
|
1250
2090
|
return;
|
|
1251
2091
|
}
|
|
1252
2092
|
handle.dead = true;
|
|
2093
|
+
handle.failureReason = reason;
|
|
1253
2094
|
closeOwnedSessionContext(handle);
|
|
1254
2095
|
// Drop every key mapping to this handle — its launch key AND any rotation
|
|
1255
2096
|
// aliases (claude `/clear`·`/fork` terminal transfer).
|
|
@@ -1262,8 +2103,20 @@ export class RunnerManager {
|
|
|
1262
2103
|
}
|
|
1263
2104
|
}
|
|
1264
2105
|
}
|
|
2106
|
+
if (handle.pendingRotation) {
|
|
2107
|
+
// A publication already inside its durable binding write owns both
|
|
2108
|
+
// reservations until that Promise settles. Releasing here would let a
|
|
2109
|
+
// replacement source child open against the old binding while the stale
|
|
2110
|
+
// chain can still commit target/source ownership records.
|
|
2111
|
+
if (!handle.pendingRotation.inFlight) {
|
|
2112
|
+
this.releasePendingNativeRotation(handle, handle.pendingRotation);
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
1265
2115
|
const tail = handle.stderr.join("\n");
|
|
1266
2116
|
const message = tail ? `${reason}\n--- runner log tail ---\n${tail}` : reason;
|
|
2117
|
+
if (opts.failActiveResponses !== false) {
|
|
2118
|
+
this.failActiveResponses(handle, "runner_crashed", message);
|
|
2119
|
+
}
|
|
1267
2120
|
const error = new AgentRuntimeError(message, 500, "runner_crashed");
|
|
1268
2121
|
for (const pending of handle.caps.values()) {
|
|
1269
2122
|
pending.reject(error);
|
|
@@ -1272,20 +2125,23 @@ export class RunnerManager {
|
|
|
1272
2125
|
terminal._fail(message);
|
|
1273
2126
|
}
|
|
1274
2127
|
for (const resolve of handle.live.values()) {
|
|
1275
|
-
resolve({
|
|
2128
|
+
resolve({
|
|
2129
|
+
ok: false,
|
|
2130
|
+
error: { message, code: "runner_crashed", statusCode: 500 },
|
|
2131
|
+
});
|
|
1276
2132
|
}
|
|
1277
2133
|
handle.caps.clear();
|
|
1278
2134
|
handle.terminals.clear();
|
|
1279
2135
|
handle.live.clear();
|
|
2136
|
+
handle.imageTransfers.clear();
|
|
2137
|
+
for (const deliveryId of handle.mirrorDeliveries.keys()) {
|
|
2138
|
+
this.releaseMirrorDelivery(handle, deliveryId);
|
|
2139
|
+
}
|
|
1280
2140
|
handle.transport.close();
|
|
1281
2141
|
}
|
|
1282
2142
|
terminateHandle(handle, reason) {
|
|
1283
2143
|
if (handle.termination)
|
|
1284
2144
|
return handle.termination;
|
|
1285
|
-
if (handle.terminalCleanupRetryTimer) {
|
|
1286
|
-
clearTimeout(handle.terminalCleanupRetryTimer);
|
|
1287
|
-
delete handle.terminalCleanupRetryTimer;
|
|
1288
|
-
}
|
|
1289
2145
|
const attempt = (async () => {
|
|
1290
2146
|
if (!handle.dead) {
|
|
1291
2147
|
try {
|
|
@@ -1294,7 +2150,8 @@ export class RunnerManager {
|
|
|
1294
2150
|
catch {
|
|
1295
2151
|
// The process signal below remains the authoritative shutdown path.
|
|
1296
2152
|
}
|
|
1297
|
-
this.
|
|
2153
|
+
this.interruptActiveResponses(handle);
|
|
2154
|
+
this.failHandle(handle, reason, { failActiveResponses: false });
|
|
1298
2155
|
}
|
|
1299
2156
|
this.signalChild(handle.child, "SIGTERM", handle.processGroup);
|
|
1300
2157
|
let childExited = await waitForChildExit(handle.completion, this.shutdownGraceMs);
|
|
@@ -1302,51 +2159,261 @@ export class RunnerManager {
|
|
|
1302
2159
|
this.signalChild(handle.child, "SIGKILL", handle.processGroup);
|
|
1303
2160
|
childExited = await waitForChildExit(handle.completion, this.shutdownKillGraceMs);
|
|
1304
2161
|
}
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
2162
|
+
if (handle.key !== CAP_KEY) {
|
|
2163
|
+
await Promise.resolve(this.terminateTerminalServer(`${handle.key}-main`, handle.child.pid)).catch((error) => {
|
|
2164
|
+
console.warn(`[runner-manager] best-effort terminal close failed for ${handle.key}-main: ${error instanceof Error ? error.message : String(error)}`);
|
|
2165
|
+
});
|
|
2166
|
+
}
|
|
1308
2167
|
if (!childExited) {
|
|
1309
2168
|
throw new Error(`runner child ${handle.child.pid ?? handle.key} did not exit after SIGKILL`);
|
|
1310
2169
|
}
|
|
1311
|
-
if (!terminalStopped) {
|
|
1312
|
-
throw new Error(`runner terminal ${handle.key}-main remained live after child exit`);
|
|
1313
|
-
}
|
|
1314
2170
|
this.childHandles.delete(handle);
|
|
1315
2171
|
})();
|
|
1316
2172
|
handle.termination = attempt;
|
|
1317
2173
|
void attempt.then(() => {
|
|
1318
|
-
this.
|
|
2174
|
+
this.finishTerminalWrites(handle);
|
|
1319
2175
|
}, () => {
|
|
1320
2176
|
if (handle.termination === attempt)
|
|
1321
2177
|
delete handle.termination;
|
|
1322
|
-
handle
|
|
1323
|
-
|
|
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);
|
|
2178
|
+
// An unexited dead handle remains visible in pendingNativeOperationCount.
|
|
2179
|
+
this.finishTerminalWrites(handle);
|
|
1328
2180
|
});
|
|
1329
2181
|
return attempt;
|
|
1330
2182
|
}
|
|
1331
2183
|
reapIdle() {
|
|
2184
|
+
if (this.reapPromise)
|
|
2185
|
+
return this.reapPromise;
|
|
2186
|
+
const attempt = this.reapIdleOnce();
|
|
2187
|
+
this.reapPromise = attempt;
|
|
2188
|
+
const clear = () => {
|
|
2189
|
+
if (this.reapPromise === attempt)
|
|
2190
|
+
this.reapPromise = null;
|
|
2191
|
+
};
|
|
2192
|
+
void attempt.then(clear, clear);
|
|
2193
|
+
return attempt;
|
|
2194
|
+
}
|
|
2195
|
+
async reapIdleOnce() {
|
|
1332
2196
|
const now = this.now();
|
|
1333
2197
|
for (const [key, handle] of this.handles) {
|
|
1334
|
-
if (
|
|
2198
|
+
if (this.isManagedNativeHandle(handle)) {
|
|
2199
|
+
await this.reapNativePane(handle, now);
|
|
1335
2200
|
continue;
|
|
1336
2201
|
}
|
|
1337
|
-
|
|
1338
|
-
// the session's single event writer, so never reap a session that has one.
|
|
1339
|
-
if (this.liveSessionKeys.has(key)) {
|
|
2202
|
+
if (handle.caps.size > 0 || handle.terminals.size > 0 || handle.live.size > 0) {
|
|
1340
2203
|
continue;
|
|
1341
2204
|
}
|
|
1342
|
-
|
|
2205
|
+
const idleForMs = now - handle.lastUsedAt;
|
|
2206
|
+
if (idleForMs < this.idleTtlMs) {
|
|
1343
2207
|
continue;
|
|
1344
2208
|
}
|
|
1345
|
-
|
|
2209
|
+
const hasActiveResponse = handle.activeResponseIds.size > 0;
|
|
2210
|
+
if (hasActiveResponse)
|
|
2211
|
+
continue;
|
|
2212
|
+
const reason = this.liveSessionKeys.has(key)
|
|
2213
|
+
? "idle live runner reaped"
|
|
2214
|
+
: "idle runner reaped";
|
|
2215
|
+
void this.terminateHandle(handle, reason).catch((error) => {
|
|
1346
2216
|
logTerminationFailure(handle, error);
|
|
1347
2217
|
});
|
|
1348
2218
|
}
|
|
1349
2219
|
}
|
|
2220
|
+
/** Provider-authoritative turn state is the lifecycle truth; attached clients
|
|
2221
|
+
* and tmux's own activity clock are independent busy evidence. There is no
|
|
2222
|
+
* "user was inactive for an hour" override for an active turn. */
|
|
2223
|
+
async reapNativePane(handle, now) {
|
|
2224
|
+
if (this.nativePaneIdleTtlMs <= 0 || handle.dead)
|
|
2225
|
+
return;
|
|
2226
|
+
if (await this.isNativePaneBusy(handle)) {
|
|
2227
|
+
handle.nativePaneLastBusyAt = now;
|
|
2228
|
+
return;
|
|
2229
|
+
}
|
|
2230
|
+
const lastBusyAt = handle.nativePaneLastBusyAt;
|
|
2231
|
+
if (lastBusyAt === undefined) {
|
|
2232
|
+
// First idle observation starts a full grace window.
|
|
2233
|
+
handle.nativePaneLastBusyAt = now;
|
|
2234
|
+
return;
|
|
2235
|
+
}
|
|
2236
|
+
if (now - lastBusyAt < this.nativePaneIdleTtlMs)
|
|
2237
|
+
return;
|
|
2238
|
+
// Close the select→reap race: activity may begin after classification but
|
|
2239
|
+
// before teardown.
|
|
2240
|
+
if (await this.isNativePaneBusy(handle)) {
|
|
2241
|
+
handle.nativePaneLastBusyAt = this.now();
|
|
2242
|
+
return;
|
|
2243
|
+
}
|
|
2244
|
+
// A failed teardown must re-arm on the next scan instead of retrying on
|
|
2245
|
+
// every sweep forever.
|
|
2246
|
+
delete handle.nativePaneLastBusyAt;
|
|
2247
|
+
await this.reapNativeTerminal(handle).catch((error) => {
|
|
2248
|
+
logTerminationFailure(handle, error);
|
|
2249
|
+
});
|
|
2250
|
+
}
|
|
2251
|
+
reapNativeTerminal(handle) {
|
|
2252
|
+
const localThreadId = this.currentTerminalSessionId(handle, handle.key);
|
|
2253
|
+
const terminalId = `${localThreadId}-main`;
|
|
2254
|
+
const reqId = randomUUID();
|
|
2255
|
+
return new Promise((resolve, reject) => {
|
|
2256
|
+
const timeout = setTimeout(() => {
|
|
2257
|
+
if (!handle.live.delete(reqId))
|
|
2258
|
+
return;
|
|
2259
|
+
reject(new Error(`runner did not acknowledge idle terminal reap within ${this.liveReadyTimeoutMs}ms`));
|
|
2260
|
+
}, this.liveReadyTimeoutMs);
|
|
2261
|
+
timeout.unref?.();
|
|
2262
|
+
handle.live.set(reqId, (reply) => {
|
|
2263
|
+
clearTimeout(timeout);
|
|
2264
|
+
if (reply.ok) {
|
|
2265
|
+
resolve();
|
|
2266
|
+
return;
|
|
2267
|
+
}
|
|
2268
|
+
reject(fromWireError(reply.error ?? {
|
|
2269
|
+
message: "idle terminal reap failed",
|
|
2270
|
+
code: "terminal_reap_failed",
|
|
2271
|
+
statusCode: 500,
|
|
2272
|
+
}));
|
|
2273
|
+
});
|
|
2274
|
+
handle.transport.send({
|
|
2275
|
+
t: "terminal.reap",
|
|
2276
|
+
reqId,
|
|
2277
|
+
localThreadId,
|
|
2278
|
+
terminalId,
|
|
2279
|
+
});
|
|
2280
|
+
});
|
|
2281
|
+
}
|
|
2282
|
+
async isNativePaneBusy(handle) {
|
|
2283
|
+
if (handle.activeResponseIds.size > 0 ||
|
|
2284
|
+
handle.caps.size > 0 ||
|
|
2285
|
+
handle.live.size > 0 ||
|
|
2286
|
+
handle.terminals.size > 0) {
|
|
2287
|
+
return true;
|
|
2288
|
+
}
|
|
2289
|
+
const terminalName = `${handle.key}-main`;
|
|
2290
|
+
try {
|
|
2291
|
+
if (await this.terminalHasAttachedClient(terminalName, handle.child.pid))
|
|
2292
|
+
return true;
|
|
2293
|
+
}
|
|
2294
|
+
catch {
|
|
2295
|
+
// A failed probe contributes no busy evidence; check activity next.
|
|
2296
|
+
}
|
|
2297
|
+
let activityAt = null;
|
|
2298
|
+
try {
|
|
2299
|
+
activityAt = await this.terminalWindowActivityAt(terminalName, handle.child.pid);
|
|
2300
|
+
}
|
|
2301
|
+
catch {
|
|
2302
|
+
// A failed probe contributes no busy evidence.
|
|
2303
|
+
}
|
|
2304
|
+
return activityAt !== null &&
|
|
2305
|
+
this.wallNow() - activityAt * 1_000 < this.nativePaneOutputBusyWindowMs;
|
|
2306
|
+
}
|
|
2307
|
+
isManagedNativeHandle(handle) {
|
|
2308
|
+
const sessionId = this.currentTerminalSessionId(handle, handle.key);
|
|
2309
|
+
const provider = this.liveOptions.get(sessionId)?.execution.provider;
|
|
2310
|
+
return isManagedNativeProvider(provider);
|
|
2311
|
+
}
|
|
2312
|
+
failActiveResponses(handle, code, message) {
|
|
2313
|
+
const responseIds = [...handle.activeResponseIds];
|
|
2314
|
+
handle.activeResponseIds.clear();
|
|
2315
|
+
const sessionId = this.currentTerminalSessionId(handle, handle.key);
|
|
2316
|
+
for (const responseId of responseIds) {
|
|
2317
|
+
rememberTerminalResponse(handle, responseId);
|
|
2318
|
+
this.emitCompensatingMirror(handle, sessionId, {
|
|
2319
|
+
type: "response.failed",
|
|
2320
|
+
responseId,
|
|
2321
|
+
error: {
|
|
2322
|
+
source: "execution",
|
|
2323
|
+
code,
|
|
2324
|
+
message,
|
|
2325
|
+
},
|
|
2326
|
+
});
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
2329
|
+
interruptActiveResponses(handle) {
|
|
2330
|
+
const responseIds = [...handle.activeResponseIds];
|
|
2331
|
+
handle.activeResponseIds.clear();
|
|
2332
|
+
const sessionId = this.currentTerminalSessionId(handle, handle.key);
|
|
2333
|
+
for (const responseId of responseIds) {
|
|
2334
|
+
this.emitCompensatingMirror(handle, sessionId, {
|
|
2335
|
+
type: "session.interrupted",
|
|
2336
|
+
sessionId,
|
|
2337
|
+
responseId,
|
|
2338
|
+
});
|
|
2339
|
+
}
|
|
2340
|
+
}
|
|
2341
|
+
/** Crash/termination compensation is intentionally best-effort because it is
|
|
2342
|
+
* a terminal/status edge rather than an ordinary transcript delivery.
|
|
2343
|
+
* Still consume an asynchronous persistence rejection so a dying child
|
|
2344
|
+
* cannot take the parent daemon down with an unhandled rejection. */
|
|
2345
|
+
emitCompensatingMirror(handle, sessionId, event) {
|
|
2346
|
+
if (!this.mirrorListener)
|
|
2347
|
+
return;
|
|
2348
|
+
try {
|
|
2349
|
+
const result = this.mirrorListener(sessionId, event);
|
|
2350
|
+
if (result && typeof result.then === "function") {
|
|
2351
|
+
void Promise.resolve(result).catch((error) => {
|
|
2352
|
+
logCompensatingMirrorFailure(handle, sessionId, event.type, error);
|
|
2353
|
+
});
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
2356
|
+
catch (error) {
|
|
2357
|
+
logCompensatingMirrorFailure(handle, sessionId, event.type, error);
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
/** Track the provider-authoritative response lifecycle. Native-pane busy
|
|
2361
|
+
* classification consumes this level directly; output volume is separately
|
|
2362
|
+
* grounded in tmux's own activity clock. */
|
|
2363
|
+
observeHandleRuntimeEvent(handle, event) {
|
|
2364
|
+
switch (event.type) {
|
|
2365
|
+
case "response.created":
|
|
2366
|
+
case "response.output_text.delta":
|
|
2367
|
+
case "response.reasoning_summary_text.delta":
|
|
2368
|
+
case "response.function_call_output.delta":
|
|
2369
|
+
case "response.output_item.done":
|
|
2370
|
+
if (handle.terminalResponseIds.has(event.responseId))
|
|
2371
|
+
return;
|
|
2372
|
+
handle.activeResponseIds.add(event.responseId);
|
|
2373
|
+
return;
|
|
2374
|
+
case "session.interaction.requested":
|
|
2375
|
+
if (handle.terminalResponseIds.has(event.responseId))
|
|
2376
|
+
return;
|
|
2377
|
+
handle.activeResponseIds.add(event.responseId);
|
|
2378
|
+
return;
|
|
2379
|
+
case "response.completed":
|
|
2380
|
+
case "response.failed":
|
|
2381
|
+
case "session.interrupted":
|
|
2382
|
+
handle.activeResponseIds.delete(event.responseId);
|
|
2383
|
+
rememberTerminalResponse(handle, event.responseId);
|
|
2384
|
+
return;
|
|
2385
|
+
case "session.status":
|
|
2386
|
+
if (event.status === "running" && event.responseId) {
|
|
2387
|
+
if (handle.terminalResponseIds.has(event.responseId))
|
|
2388
|
+
return;
|
|
2389
|
+
handle.activeResponseIds.add(event.responseId);
|
|
2390
|
+
}
|
|
2391
|
+
else if (event.status !== "running") {
|
|
2392
|
+
if (event.responseId) {
|
|
2393
|
+
handle.activeResponseIds.delete(event.responseId);
|
|
2394
|
+
rememberTerminalResponse(handle, event.responseId);
|
|
2395
|
+
}
|
|
2396
|
+
else {
|
|
2397
|
+
for (const responseId of handle.activeResponseIds) {
|
|
2398
|
+
rememberTerminalResponse(handle, responseId);
|
|
2399
|
+
}
|
|
2400
|
+
handle.activeResponseIds.clear();
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
return;
|
|
2404
|
+
case "session.rotated":
|
|
2405
|
+
handle.activeResponseIds.clear();
|
|
2406
|
+
return;
|
|
2407
|
+
default:
|
|
2408
|
+
return;
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
2411
|
+
isStaleMirrorGeneration(handle, generation) {
|
|
2412
|
+
const currentGeneration = handle.pendingRotation?.generation ??
|
|
2413
|
+
handle.completedRotation?.generation ??
|
|
2414
|
+
0;
|
|
2415
|
+
return generation < currentGeneration;
|
|
2416
|
+
}
|
|
1350
2417
|
openSessionContext(sessionId) {
|
|
1351
2418
|
if (!this.sessionContextProvider)
|
|
1352
2419
|
return undefined;
|
|
@@ -1365,69 +2432,15 @@ export class RunnerManager {
|
|
|
1365
2432
|
}
|
|
1366
2433
|
}
|
|
1367
2434
|
}
|
|
1368
|
-
function
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
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
|
-
}
|
|
2435
|
+
function rememberTerminalResponse(handle, responseId) {
|
|
2436
|
+
handle.terminalResponseIds.delete(responseId);
|
|
2437
|
+
handle.terminalResponseIds.add(responseId);
|
|
2438
|
+
while (handle.terminalResponseIds.size > MAX_TERMINAL_RESPONSE_IDS) {
|
|
2439
|
+
const oldest = handle.terminalResponseIds.values().next().value;
|
|
2440
|
+
if (!oldest)
|
|
2441
|
+
break;
|
|
2442
|
+
handle.terminalResponseIds.delete(oldest);
|
|
1429
2443
|
}
|
|
1430
|
-
return submissions;
|
|
1431
2444
|
}
|
|
1432
2445
|
function validateSessionContext(context) {
|
|
1433
2446
|
if (!context || typeof context !== "object") {
|
|
@@ -1503,9 +2516,61 @@ function closeSessionContext(context) {
|
|
|
1503
2516
|
}));
|
|
1504
2517
|
}
|
|
1505
2518
|
}
|
|
2519
|
+
function isDetachedGeneratedImageEvent(event) {
|
|
2520
|
+
if (event.type !== "response.output_item.done" ||
|
|
2521
|
+
event.item.type !== "function_call_output") {
|
|
2522
|
+
return false;
|
|
2523
|
+
}
|
|
2524
|
+
const data = event.item.data;
|
|
2525
|
+
return Boolean(data.__rynxGeneratedImage && data.__rynxGeneratedImage.result === undefined);
|
|
2526
|
+
}
|
|
2527
|
+
function attachGeneratedImageResult(event, result) {
|
|
2528
|
+
if (!isDetachedGeneratedImageEvent(event))
|
|
2529
|
+
return undefined;
|
|
2530
|
+
const outputEvent = event;
|
|
2531
|
+
const item = outputEvent.item;
|
|
2532
|
+
if (item.type !== "function_call_output")
|
|
2533
|
+
return undefined;
|
|
2534
|
+
const data = item.data;
|
|
2535
|
+
return {
|
|
2536
|
+
...outputEvent,
|
|
2537
|
+
item: {
|
|
2538
|
+
...item,
|
|
2539
|
+
data: {
|
|
2540
|
+
...data,
|
|
2541
|
+
__rynxGeneratedImage: {
|
|
2542
|
+
...data.__rynxGeneratedImage,
|
|
2543
|
+
result,
|
|
2544
|
+
},
|
|
2545
|
+
},
|
|
2546
|
+
},
|
|
2547
|
+
};
|
|
2548
|
+
}
|
|
1506
2549
|
function errorMessage(error) {
|
|
1507
2550
|
return error instanceof Error ? error.message : String(error);
|
|
1508
2551
|
}
|
|
2552
|
+
function mirrorNackClassification(error) {
|
|
2553
|
+
if (typeof error === "object" && error !== null &&
|
|
2554
|
+
error.mirrorClassification === "ambiguous")
|
|
2555
|
+
return "ambiguous";
|
|
2556
|
+
const statusCode = error instanceof AgentRuntimeError
|
|
2557
|
+
? error.statusCode
|
|
2558
|
+
: typeof error === "object" && error !== null &&
|
|
2559
|
+
typeof error.statusCode === "number"
|
|
2560
|
+
? error.statusCode
|
|
2561
|
+
: undefined;
|
|
2562
|
+
return statusCode !== undefined &&
|
|
2563
|
+
statusCode >= 400 && statusCode < 500 &&
|
|
2564
|
+
![408, 409, 425, 429].includes(statusCode)
|
|
2565
|
+
? "permanent"
|
|
2566
|
+
: "transient";
|
|
2567
|
+
}
|
|
2568
|
+
function mirrorAbandonToken(error) {
|
|
2569
|
+
if (typeof error !== "object" || error === null)
|
|
2570
|
+
return undefined;
|
|
2571
|
+
const token = error.abandonToken;
|
|
2572
|
+
return typeof token === "string" && token.length > 0 ? token : undefined;
|
|
2573
|
+
}
|
|
1509
2574
|
function defaultRunnerEntry() {
|
|
1510
2575
|
if (process.env.RYNX_RUNNER_ENTRY?.trim()) {
|
|
1511
2576
|
return process.env.RYNX_RUNNER_ENTRY.trim();
|
|
@@ -1580,6 +2645,18 @@ function logTerminationFailure(handle, error) {
|
|
|
1580
2645
|
error: error instanceof Error ? error.message : String(error),
|
|
1581
2646
|
}));
|
|
1582
2647
|
}
|
|
2648
|
+
function logCompensatingMirrorFailure(handle, sessionId, eventType, error) {
|
|
2649
|
+
console.error(JSON.stringify({
|
|
2650
|
+
level: "error",
|
|
2651
|
+
type: "runner",
|
|
2652
|
+
event: "compensating_mirror_failed",
|
|
2653
|
+
key: handle.key,
|
|
2654
|
+
sessionId,
|
|
2655
|
+
eventType,
|
|
2656
|
+
pid: handle.child.pid,
|
|
2657
|
+
error: errorMessage(error),
|
|
2658
|
+
}));
|
|
2659
|
+
}
|
|
1583
2660
|
function capabilityForkResult(result) {
|
|
1584
2661
|
return result.ok
|
|
1585
2662
|
? { ok: true }
|