@rynx-ai/runtime 0.1.11-beta.37 → 0.1.11-beta.39
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/native-bridge.d.ts +85 -0
- package/dist/claude/native-bridge.js +335 -17
- package/dist/claude/native-hook-main.js +18 -1
- package/dist/claude/native-hooks.js +7 -0
- package/dist/claude/native-integration.d.ts +120 -18
- package/dist/claude/native-integration.js +1200 -161
- 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 +131 -30
- package/dist/codex-session-store.d.ts +23 -0
- package/dist/codex-session-store.js +21 -0
- package/dist/host.d.ts +31 -2
- package/dist/host.js +551 -72
- package/dist/runner/child.d.ts +29 -5
- package/dist/runner/child.js +635 -54
- package/dist/runner/manager.d.ts +25 -0
- package/dist/runner/manager.js +805 -115
- package/dist/runner/protocol.d.ts +76 -3
- package/dist/runner/transport.d.ts +9 -0
- package/dist/runner/transport.js +39 -12
- package/package.json +2 -2
package/dist/runner/manager.js
CHANGED
|
@@ -26,6 +26,7 @@ import { terminateTmuxServer, tmuxHasAttachedClient, tmuxWindowActivityAt, } fro
|
|
|
26
26
|
import { RUNNER_IMAGE_CHUNK_CHARS, RUNNER_IMAGE_MAX_RESULT_CHARS, fromWireError, } from "./protocol.js";
|
|
27
27
|
import { isCodexLineageProvider, isManagedNativeProvider } from "./startup-policy.js";
|
|
28
28
|
import { StdioRunnerTransport } from "./transport.js";
|
|
29
|
+
import { cloneClaudeTranscript } from "../claude/transcript-clone.js";
|
|
29
30
|
/** Routing key for the shared capability runner (slash-command RPCs). */
|
|
30
31
|
const CAP_KEY = "__cap__";
|
|
31
32
|
/** Max stderr lines retained per handle for the crash exit-report tail. */
|
|
@@ -57,6 +58,7 @@ const MAX_SESSION_CONTEXT_ENV_ENTRIES = 32;
|
|
|
57
58
|
const MAX_SESSION_CONTEXT_ENV_KEY_BYTES = 128;
|
|
58
59
|
const MAX_SESSION_CONTEXT_ENV_VALUE_BYTES = 8_192;
|
|
59
60
|
const MAX_SESSION_CONTEXT_ENV_TOTAL_BYTES = 32_768;
|
|
61
|
+
const MAX_TERMINAL_RESPONSE_IDS = 2_000;
|
|
60
62
|
/** A typed attach failure so transport adapters can distinguish an expected
|
|
61
63
|
* absent pane from an infrastructure failure without matching error strings. */
|
|
62
64
|
export class TerminalOpenError extends Error {
|
|
@@ -295,6 +297,12 @@ export class RunnerManager {
|
|
|
295
297
|
stopPromise;
|
|
296
298
|
/** Sink for mirrored {@link SessionEvent}s from every session's forwarder. */
|
|
297
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;
|
|
298
306
|
/** Synchronous persistence hook for native collaboration-mode reflection.
|
|
299
307
|
* It runs before the event reaches the Session bus. */
|
|
300
308
|
collaborationModeListener = null;
|
|
@@ -467,6 +475,12 @@ export class RunnerManager {
|
|
|
467
475
|
onMirror(listener) {
|
|
468
476
|
this.mirrorListener = listener;
|
|
469
477
|
}
|
|
478
|
+
onMirrorAbandon(listener) {
|
|
479
|
+
this.mirrorAbandonListener = listener;
|
|
480
|
+
}
|
|
481
|
+
onMirrorSupersede(listener) {
|
|
482
|
+
this.mirrorSupersedeListener = listener;
|
|
483
|
+
}
|
|
470
484
|
onCollaborationMode(listener) {
|
|
471
485
|
this.collaborationModeListener = listener;
|
|
472
486
|
}
|
|
@@ -553,7 +567,7 @@ export class RunnerManager {
|
|
|
553
567
|
if (candidate === handle && this.liveSessionKeys.has(sessionId))
|
|
554
568
|
return sessionId;
|
|
555
569
|
}
|
|
556
|
-
return fallback;
|
|
570
|
+
return handle.activeSessionId || fallback;
|
|
557
571
|
}
|
|
558
572
|
finishTerminalInputHandoff(handle, handoff) {
|
|
559
573
|
if (handoff.timer) {
|
|
@@ -750,6 +764,14 @@ export class RunnerManager {
|
|
|
750
764
|
.finally(() => admission.release());
|
|
751
765
|
}
|
|
752
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
|
+
}
|
|
753
775
|
const sourceReservation = this.sourceForkReservations.get(localThreadId);
|
|
754
776
|
if (sourceReservation) {
|
|
755
777
|
return sourceReservation.then(() => this.injectMessageAdmitted(localThreadId, input, options));
|
|
@@ -1010,6 +1032,7 @@ export class RunnerManager {
|
|
|
1010
1032
|
this.liveErrors.clear();
|
|
1011
1033
|
this.liveOptions.clear();
|
|
1012
1034
|
this.mirrorListener = null;
|
|
1035
|
+
this.mirrorAbandonListener = null;
|
|
1013
1036
|
this.rotateListener = null;
|
|
1014
1037
|
const errors = results
|
|
1015
1038
|
.filter((result) => result.status === "rejected")
|
|
@@ -1131,29 +1154,67 @@ export class RunnerManager {
|
|
|
1131
1154
|
}
|
|
1132
1155
|
async performClaudeManagedFork(currentLocalThreadId, newLocalThreadId, options) {
|
|
1133
1156
|
const source = await this.sessionStore.get(currentLocalThreadId);
|
|
1157
|
+
const getIntent = this.sessionStore.getClaudeForkIntent?.bind(this.sessionStore);
|
|
1134
1158
|
const setIntent = this.sessionStore.setClaudeForkIntent?.bind(this.sessionStore);
|
|
1135
1159
|
const deleteIntent = this.sessionStore.deleteClaudeForkIntent?.bind(this.sessionStore);
|
|
1136
|
-
if (!source?.codexSessionId || !setIntent || !deleteIntent) {
|
|
1160
|
+
if (!source?.codexSessionId || !getIntent || !setIntent || !deleteIntent) {
|
|
1137
1161
|
return {
|
|
1138
1162
|
ok: false,
|
|
1139
1163
|
reason: "unsupported",
|
|
1140
1164
|
message: "Claude fork persistence is unavailable",
|
|
1141
1165
|
};
|
|
1142
1166
|
}
|
|
1143
|
-
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();
|
|
1144
1181
|
try {
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
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, {
|
|
1153
1211
|
workspace: structuredClone(options.workspace),
|
|
1154
1212
|
execution: structuredClone(options.execution),
|
|
1155
1213
|
}, true, true);
|
|
1156
|
-
|
|
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);
|
|
1157
1218
|
if (!target ||
|
|
1158
1219
|
target.parentSessionId !== currentLocalThreadId ||
|
|
1159
1220
|
target.codexSessionId !== targetClaudeSessionId) {
|
|
@@ -1163,10 +1224,8 @@ export class RunnerManager {
|
|
|
1163
1224
|
return { ok: true, data: undefined };
|
|
1164
1225
|
}
|
|
1165
1226
|
catch (error) {
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
if (target?.parentSessionId === currentLocalThreadId) {
|
|
1169
|
-
await this.sessionStore.delete(newLocalThreadId).catch(() => undefined);
|
|
1227
|
+
if (!persistedIntent) {
|
|
1228
|
+
await deleteIntent(newLocalThreadId).catch(() => undefined);
|
|
1170
1229
|
}
|
|
1171
1230
|
const handle = this.handles.get(newLocalThreadId);
|
|
1172
1231
|
if (handle && !handle.dead) {
|
|
@@ -1204,42 +1263,180 @@ export class RunnerManager {
|
|
|
1204
1263
|
deliverMirrorMessage(handle, message) {
|
|
1205
1264
|
if (handle.dead)
|
|
1206
1265
|
return;
|
|
1207
|
-
if (
|
|
1208
|
-
|
|
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;
|
|
1209
1284
|
}
|
|
1210
|
-
|
|
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
|
+
}
|
|
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
|
+
};
|
|
1211
1323
|
let delivered;
|
|
1212
1324
|
try {
|
|
1213
|
-
|
|
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
|
+
}
|
|
1214
1333
|
}
|
|
1215
1334
|
catch (error) {
|
|
1216
1335
|
delivered = Promise.reject(error);
|
|
1217
1336
|
}
|
|
1218
|
-
if (
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
this.settleTerminalInputHandoff(handle, message.sessionId, "turn");
|
|
1223
|
-
}
|
|
1224
|
-
if (message.transferId && message.seq !== undefined) {
|
|
1337
|
+
if ("transferId" in message) {
|
|
1338
|
+
const transfer = handle.imageTransfers.get(message.transferId);
|
|
1339
|
+
if (transfer)
|
|
1340
|
+
transfer.publication = delivered;
|
|
1225
1341
|
void delivered.then(() => {
|
|
1342
|
+
commitDeliveredObservation();
|
|
1226
1343
|
if (!handle.dead) {
|
|
1344
|
+
if (handle.imageTransfers.get(message.transferId) === transfer) {
|
|
1345
|
+
handle.imageTransfers.delete(message.transferId);
|
|
1346
|
+
}
|
|
1227
1347
|
handle.transport.send({
|
|
1228
1348
|
t: "mirror.image.ack",
|
|
1229
1349
|
transferId: message.transferId,
|
|
1230
1350
|
seq: message.seq,
|
|
1351
|
+
generation: message.generation,
|
|
1231
1352
|
});
|
|
1232
1353
|
}
|
|
1233
1354
|
}, (error) => {
|
|
1234
|
-
|
|
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
|
+
});
|
|
1235
1369
|
});
|
|
1236
1370
|
}
|
|
1237
1371
|
else {
|
|
1238
|
-
void delivered.
|
|
1239
|
-
|
|
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
|
+
}
|
|
1240
1429
|
});
|
|
1241
1430
|
}
|
|
1242
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
|
+
}
|
|
1243
1440
|
failMirrorImageTransfer(handle, reason) {
|
|
1244
1441
|
handle.imageTransfers.clear();
|
|
1245
1442
|
void this.terminateHandle(handle, reason).catch((error) => {
|
|
@@ -1247,95 +1444,361 @@ export class RunnerManager {
|
|
|
1247
1444
|
});
|
|
1248
1445
|
}
|
|
1249
1446
|
deliverRotateMessage(handle, message) {
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
handle.sessionContext?.rotate(message.to);
|
|
1260
|
-
}
|
|
1261
|
-
catch (error) {
|
|
1262
|
-
const reason = `runner Session context rotation failed: ${errorMessage(error)}`;
|
|
1263
|
-
void this.terminateHandle(handle, reason).catch((terminationError) => {
|
|
1264
|
-
logTerminationFailure(handle, terminationError);
|
|
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,
|
|
1265
1456
|
});
|
|
1266
1457
|
return;
|
|
1267
1458
|
}
|
|
1268
|
-
let
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
to: message.to,
|
|
1294
|
-
kind: message.kind,
|
|
1295
|
-
workspace: message.workspace,
|
|
1296
|
-
execution: message.execution,
|
|
1297
|
-
...(message.parentSessionId ? { parentSessionId: message.parentSessionId } : {}),
|
|
1298
|
-
};
|
|
1299
|
-
void Promise.resolve(this.rotateListener?.(rotation))
|
|
1300
|
-
.then(() => {
|
|
1301
|
-
// The server's rotation listener resolves only after the target Session
|
|
1302
|
-
// is published. Release the terminal admission handoff afterwards so a
|
|
1303
|
-
// maintenance snapshot cannot observe a gap with neither source work
|
|
1304
|
-
// nor the target Session.
|
|
1305
|
-
this.settleTerminalRotationHandoff(handle, message.from, message.to);
|
|
1306
|
-
if (handle.dead)
|
|
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));
|
|
1307
1484
|
return;
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
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
|
+
});
|
|
1497
|
+
return;
|
|
1498
|
+
}
|
|
1499
|
+
let releaseTarget;
|
|
1500
|
+
const targetReservation = new Promise((resolve) => {
|
|
1501
|
+
releaseTarget = resolve;
|
|
1314
1502
|
});
|
|
1315
|
-
|
|
1316
|
-
|
|
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;
|
|
1317
1556
|
}
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
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
|
+
});
|
|
1321
1574
|
}
|
|
1322
1575
|
})
|
|
1323
1576
|
.catch((error) => {
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
.
|
|
1327
|
-
if (
|
|
1328
|
-
this.
|
|
1577
|
+
if (handle.pendingRotation !== pending)
|
|
1578
|
+
return;
|
|
1579
|
+
pending.inFlight = false;
|
|
1580
|
+
if (handle.dead) {
|
|
1581
|
+
this.releasePendingNativeRotation(handle, pending);
|
|
1582
|
+
return;
|
|
1329
1583
|
}
|
|
1330
|
-
|
|
1331
|
-
|
|
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
|
+
});
|
|
1332
1592
|
}
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
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(),
|
|
1337
1628
|
});
|
|
1338
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
|
+
}
|
|
1339
1802
|
spawnHandle(key) {
|
|
1340
1803
|
const args = this.runnerEntry.endsWith(".ts")
|
|
1341
1804
|
? ["--import", "tsx", this.runnerEntry]
|
|
@@ -1368,11 +1831,13 @@ export class RunnerManager {
|
|
|
1368
1831
|
const transport = new StdioRunnerTransport(child.stdout, child.stdin);
|
|
1369
1832
|
const handle = {
|
|
1370
1833
|
key,
|
|
1834
|
+
activeSessionId: key,
|
|
1371
1835
|
child,
|
|
1372
1836
|
transport,
|
|
1373
1837
|
stderr: [],
|
|
1374
1838
|
lastUsedAt: this.now(),
|
|
1375
1839
|
activeResponseIds: new Set(),
|
|
1840
|
+
terminalResponseIds: new Set(),
|
|
1376
1841
|
dead: false,
|
|
1377
1842
|
completion,
|
|
1378
1843
|
processGroup,
|
|
@@ -1381,6 +1846,7 @@ export class RunnerManager {
|
|
|
1381
1846
|
terminals: new Map(),
|
|
1382
1847
|
live: new Map(),
|
|
1383
1848
|
imageTransfers: new Map(),
|
|
1849
|
+
mirrorDeliveries: new Map(),
|
|
1384
1850
|
};
|
|
1385
1851
|
this.childHandles.add(handle);
|
|
1386
1852
|
void completion.then(() => this.childHandles.delete(handle));
|
|
@@ -1457,13 +1923,65 @@ export class RunnerManager {
|
|
|
1457
1923
|
case "mirror":
|
|
1458
1924
|
if (handle.dead)
|
|
1459
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
|
+
}
|
|
1460
1933
|
if (this.bufferForkTargetMessage(handle, msg))
|
|
1461
1934
|
return;
|
|
1462
1935
|
this.deliverMirrorMessage(handle, msg);
|
|
1463
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
|
+
}
|
|
1464
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
|
+
}
|
|
1465
1984
|
if (handle.dead ||
|
|
1466
|
-
handle.imageTransfers.has(msg.transferId) ||
|
|
1467
1985
|
!Number.isSafeInteger(msg.totalChars) ||
|
|
1468
1986
|
msg.totalChars <= 0 ||
|
|
1469
1987
|
msg.totalChars > RUNNER_IMAGE_MAX_RESULT_CHARS ||
|
|
@@ -1473,17 +1991,48 @@ export class RunnerManager {
|
|
|
1473
1991
|
}
|
|
1474
1992
|
handle.imageTransfers.set(msg.transferId, {
|
|
1475
1993
|
sessionId: msg.sessionId,
|
|
1994
|
+
generation: msg.generation,
|
|
1476
1995
|
event: msg.event,
|
|
1477
1996
|
totalChars: msg.totalChars,
|
|
1478
1997
|
receivedChars: 0,
|
|
1479
1998
|
nextSeq: 1,
|
|
1480
1999
|
chunks: [],
|
|
1481
2000
|
});
|
|
1482
|
-
handle.transport.send({
|
|
2001
|
+
handle.transport.send({
|
|
2002
|
+
t: "mirror.image.ack",
|
|
2003
|
+
transferId: msg.transferId,
|
|
2004
|
+
seq: 0,
|
|
2005
|
+
generation: msg.generation,
|
|
2006
|
+
});
|
|
1483
2007
|
return;
|
|
1484
2008
|
}
|
|
1485
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
|
+
}
|
|
1486
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
|
+
}
|
|
1487
2036
|
if (handle.dead ||
|
|
1488
2037
|
!transfer ||
|
|
1489
2038
|
msg.seq !== transfer.nextSeq ||
|
|
@@ -1500,10 +2049,25 @@ export class RunnerManager {
|
|
|
1500
2049
|
t: "mirror.image.ack",
|
|
1501
2050
|
transferId: msg.transferId,
|
|
1502
2051
|
seq: msg.seq,
|
|
2052
|
+
generation: transfer.generation,
|
|
1503
2053
|
});
|
|
1504
2054
|
return;
|
|
1505
2055
|
}
|
|
1506
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
|
+
}
|
|
1507
2071
|
const transfer = handle.imageTransfers.get(msg.transferId);
|
|
1508
2072
|
if (handle.dead ||
|
|
1509
2073
|
!transfer ||
|
|
@@ -1512,7 +2076,16 @@ export class RunnerManager {
|
|
|
1512
2076
|
this.failMirrorImageTransfer(handle, "invalid generated image transfer commit");
|
|
1513
2077
|
return;
|
|
1514
2078
|
}
|
|
1515
|
-
|
|
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;
|
|
1516
2089
|
const event = attachGeneratedImageResult(transfer.event, transfer.chunks.join(""));
|
|
1517
2090
|
if (!event) {
|
|
1518
2091
|
this.failMirrorImageTransfer(handle, "generated image transfer target changed");
|
|
@@ -1524,26 +2097,47 @@ export class RunnerManager {
|
|
|
1524
2097
|
event,
|
|
1525
2098
|
transferId: msg.transferId,
|
|
1526
2099
|
seq: msg.seq,
|
|
2100
|
+
generation: transfer.generation,
|
|
1527
2101
|
};
|
|
1528
2102
|
if (this.bufferForkTargetMessage(handle, mirrored)) {
|
|
1529
2103
|
handle.transport.send({
|
|
1530
2104
|
t: "mirror.image.hold",
|
|
1531
2105
|
transferId: msg.transferId,
|
|
1532
2106
|
seq: msg.seq,
|
|
2107
|
+
generation: transfer.generation,
|
|
1533
2108
|
});
|
|
1534
2109
|
return;
|
|
1535
2110
|
}
|
|
1536
2111
|
this.deliverMirrorMessage(handle, mirrored);
|
|
1537
2112
|
return;
|
|
1538
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);
|
|
2120
|
+
}
|
|
2121
|
+
return;
|
|
2122
|
+
}
|
|
1539
2123
|
case "rotate": {
|
|
1540
2124
|
if (handle.dead)
|
|
1541
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
|
+
}
|
|
1542
2130
|
if (this.bufferForkTargetMessage(handle, msg))
|
|
1543
2131
|
return;
|
|
1544
2132
|
this.deliverRotateMessage(handle, msg);
|
|
1545
2133
|
return;
|
|
1546
2134
|
}
|
|
2135
|
+
case "rotate.applied": {
|
|
2136
|
+
if (handle.dead)
|
|
2137
|
+
return;
|
|
2138
|
+
this.deliverRotateAppliedMessage(handle, msg);
|
|
2139
|
+
return;
|
|
2140
|
+
}
|
|
1547
2141
|
case "terminal.lifecycle.ended": {
|
|
1548
2142
|
if (msg.lifecycle === "required") {
|
|
1549
2143
|
void this.terminateHandle(handle, `required Terminal exited with status ${msg.status}`).catch((error) => logTerminationFailure(handle, error));
|
|
@@ -1589,6 +2183,7 @@ export class RunnerManager {
|
|
|
1589
2183
|
return;
|
|
1590
2184
|
}
|
|
1591
2185
|
handle.dead = true;
|
|
2186
|
+
handle.failureReason = reason;
|
|
1592
2187
|
closeOwnedSessionContext(handle);
|
|
1593
2188
|
// Drop every key mapping to this handle — its launch key AND any rotation
|
|
1594
2189
|
// aliases (claude `/clear`·`/fork` terminal transfer).
|
|
@@ -1601,6 +2196,15 @@ export class RunnerManager {
|
|
|
1601
2196
|
}
|
|
1602
2197
|
}
|
|
1603
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
|
+
}
|
|
1604
2208
|
const tail = handle.stderr.join("\n");
|
|
1605
2209
|
const message = tail ? `${reason}\n--- runner log tail ---\n${tail}` : reason;
|
|
1606
2210
|
if (opts.failActiveResponses !== false) {
|
|
@@ -1623,6 +2227,9 @@ export class RunnerManager {
|
|
|
1623
2227
|
handle.terminals.clear();
|
|
1624
2228
|
handle.live.clear();
|
|
1625
2229
|
handle.imageTransfers.clear();
|
|
2230
|
+
for (const deliveryId of handle.mirrorDeliveries.keys()) {
|
|
2231
|
+
this.releaseMirrorDelivery(handle, deliveryId);
|
|
2232
|
+
}
|
|
1626
2233
|
handle.transport.close();
|
|
1627
2234
|
}
|
|
1628
2235
|
terminateHandle(handle, reason) {
|
|
@@ -1801,7 +2408,8 @@ export class RunnerManager {
|
|
|
1801
2408
|
handle.activeResponseIds.clear();
|
|
1802
2409
|
const sessionId = this.currentTerminalSessionId(handle, handle.key);
|
|
1803
2410
|
for (const responseId of responseIds) {
|
|
1804
|
-
|
|
2411
|
+
rememberTerminalResponse(handle, responseId);
|
|
2412
|
+
this.emitCompensatingMirror(handle, sessionId, {
|
|
1805
2413
|
type: "response.failed",
|
|
1806
2414
|
responseId,
|
|
1807
2415
|
error: {
|
|
@@ -1817,13 +2425,32 @@ export class RunnerManager {
|
|
|
1817
2425
|
handle.activeResponseIds.clear();
|
|
1818
2426
|
const sessionId = this.currentTerminalSessionId(handle, handle.key);
|
|
1819
2427
|
for (const responseId of responseIds) {
|
|
1820
|
-
this.
|
|
2428
|
+
this.emitCompensatingMirror(handle, sessionId, {
|
|
1821
2429
|
type: "session.interrupted",
|
|
1822
2430
|
sessionId,
|
|
1823
2431
|
responseId,
|
|
1824
2432
|
});
|
|
1825
2433
|
}
|
|
1826
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
|
+
}
|
|
1827
2454
|
/** Track the provider-authoritative response lifecycle. Native-pane busy
|
|
1828
2455
|
* classification consumes this level directly; output volume is separately
|
|
1829
2456
|
* grounded in tmux's own activity clock. */
|
|
@@ -1834,25 +2461,38 @@ export class RunnerManager {
|
|
|
1834
2461
|
case "response.reasoning_summary_text.delta":
|
|
1835
2462
|
case "response.function_call_output.delta":
|
|
1836
2463
|
case "response.output_item.done":
|
|
2464
|
+
if (handle.terminalResponseIds.has(event.responseId))
|
|
2465
|
+
return;
|
|
1837
2466
|
handle.activeResponseIds.add(event.responseId);
|
|
1838
2467
|
return;
|
|
1839
2468
|
case "session.interaction.requested":
|
|
2469
|
+
if (handle.terminalResponseIds.has(event.responseId))
|
|
2470
|
+
return;
|
|
1840
2471
|
handle.activeResponseIds.add(event.responseId);
|
|
1841
2472
|
return;
|
|
1842
2473
|
case "response.completed":
|
|
1843
2474
|
case "response.failed":
|
|
1844
2475
|
case "session.interrupted":
|
|
1845
2476
|
handle.activeResponseIds.delete(event.responseId);
|
|
2477
|
+
rememberTerminalResponse(handle, event.responseId);
|
|
1846
2478
|
return;
|
|
1847
2479
|
case "session.status":
|
|
1848
2480
|
if (event.status === "running" && event.responseId) {
|
|
2481
|
+
if (handle.terminalResponseIds.has(event.responseId))
|
|
2482
|
+
return;
|
|
1849
2483
|
handle.activeResponseIds.add(event.responseId);
|
|
1850
2484
|
}
|
|
1851
2485
|
else if (event.status !== "running") {
|
|
1852
|
-
if (event.responseId)
|
|
2486
|
+
if (event.responseId) {
|
|
1853
2487
|
handle.activeResponseIds.delete(event.responseId);
|
|
1854
|
-
|
|
2488
|
+
rememberTerminalResponse(handle, event.responseId);
|
|
2489
|
+
}
|
|
2490
|
+
else {
|
|
2491
|
+
for (const responseId of handle.activeResponseIds) {
|
|
2492
|
+
rememberTerminalResponse(handle, responseId);
|
|
2493
|
+
}
|
|
1855
2494
|
handle.activeResponseIds.clear();
|
|
2495
|
+
}
|
|
1856
2496
|
}
|
|
1857
2497
|
return;
|
|
1858
2498
|
case "session.rotated":
|
|
@@ -1862,6 +2502,12 @@ export class RunnerManager {
|
|
|
1862
2502
|
return;
|
|
1863
2503
|
}
|
|
1864
2504
|
}
|
|
2505
|
+
isStaleMirrorGeneration(handle, generation) {
|
|
2506
|
+
const currentGeneration = handle.pendingRotation?.generation ??
|
|
2507
|
+
handle.completedRotation?.generation ??
|
|
2508
|
+
0;
|
|
2509
|
+
return generation < currentGeneration;
|
|
2510
|
+
}
|
|
1865
2511
|
openSessionContext(sessionId) {
|
|
1866
2512
|
if (!this.sessionContextProvider)
|
|
1867
2513
|
return undefined;
|
|
@@ -1880,6 +2526,16 @@ export class RunnerManager {
|
|
|
1880
2526
|
}
|
|
1881
2527
|
}
|
|
1882
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
|
+
}
|
|
1883
2539
|
function trackedTerminalSubmissions(tracker, data) {
|
|
1884
2540
|
const pasteStart = "\u001b[200~";
|
|
1885
2541
|
const pasteEnd = "\u001b[201~";
|
|
@@ -1923,8 +2579,8 @@ function trackedTerminalSubmissions(tracker, data) {
|
|
|
1923
2579
|
if (command.length > 0 &&
|
|
1924
2580
|
(command.includes("\u001b") ||
|
|
1925
2581
|
!command.startsWith("/") ||
|
|
1926
|
-
/^\/(?:clear|fork)(?:\s|$)/u.test(command))) {
|
|
1927
|
-
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");
|
|
1928
2584
|
}
|
|
1929
2585
|
continue;
|
|
1930
2586
|
}
|
|
@@ -2051,6 +2707,28 @@ function attachGeneratedImageResult(event, result) {
|
|
|
2051
2707
|
function errorMessage(error) {
|
|
2052
2708
|
return error instanceof Error ? error.message : String(error);
|
|
2053
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
|
+
}
|
|
2054
2732
|
function defaultRunnerEntry() {
|
|
2055
2733
|
if (process.env.RYNX_RUNNER_ENTRY?.trim()) {
|
|
2056
2734
|
return process.env.RYNX_RUNNER_ENTRY.trim();
|
|
@@ -2125,6 +2803,18 @@ function logTerminationFailure(handle, error) {
|
|
|
2125
2803
|
error: error instanceof Error ? error.message : String(error),
|
|
2126
2804
|
}));
|
|
2127
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
|
+
}
|
|
2128
2818
|
function capabilityForkResult(result) {
|
|
2129
2819
|
return result.ok
|
|
2130
2820
|
? { ok: true }
|