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