@rynx-ai/runtime 0.1.11-beta.20 → 0.1.11-beta.21
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-integration.d.ts +23 -1
- package/dist/claude/native-integration.js +69 -6
- package/dist/claude/session-status.d.ts +39 -0
- package/dist/claude/session-status.js +163 -0
- package/dist/codex-app-server/forwarder.d.ts +33 -2
- package/dist/codex-app-server/forwarder.js +175 -16
- package/dist/codex-app-server/mapping.d.ts +2 -0
- package/dist/codex-app-server/mapping.js +97 -23
- package/dist/codex-app-server/protocol.d.ts +1 -1
- package/dist/host.d.ts +3 -2
- package/dist/host.js +111 -90
- package/dist/runner/child.js +1 -1
- package/dist/runner/manager.d.ts +26 -11
- package/dist/runner/manager.js +99 -30
- package/dist/runner/protocol.d.ts +5 -11
- package/dist/terminal/tmux.d.ts +15 -0
- package/dist/terminal/tmux.js +50 -0
- package/package.json +2 -2
package/dist/host.js
CHANGED
|
@@ -955,8 +955,59 @@ export class LocalAgentHost {
|
|
|
955
955
|
}
|
|
956
956
|
live.canonicalInteractions.clear();
|
|
957
957
|
};
|
|
958
|
+
const completeCurrentTurn = (usage, reason) => {
|
|
959
|
+
closeCanonicalInteractions();
|
|
960
|
+
if (!normalizer)
|
|
961
|
+
return;
|
|
962
|
+
const completedResponseId = currentResponseId;
|
|
963
|
+
if (usage) {
|
|
964
|
+
for (const se of normalizer.next({ type: "turn_completed", usage }))
|
|
965
|
+
emitCurrent(se);
|
|
966
|
+
}
|
|
967
|
+
for (const se of normalizer.next({ type: "done" })) {
|
|
968
|
+
if (reason === "superseded" && se.type === "session.status")
|
|
969
|
+
continue;
|
|
970
|
+
emitCurrent(se);
|
|
971
|
+
}
|
|
972
|
+
clearPendingInputsForResponse(completedResponseId);
|
|
973
|
+
normalizer = null;
|
|
974
|
+
currentResponseId = null;
|
|
975
|
+
};
|
|
976
|
+
const failCurrentTurn = (error) => {
|
|
977
|
+
closeCanonicalInteractions();
|
|
978
|
+
if (!normalizer)
|
|
979
|
+
return;
|
|
980
|
+
const failedResponseId = currentResponseId;
|
|
981
|
+
const responseStopped = error.message === "Codex turn was interrupted";
|
|
982
|
+
const providerName = runtime === "traex" ? "Traex" : "Codex";
|
|
983
|
+
const message = responseStopped
|
|
984
|
+
? "Response stopped"
|
|
985
|
+
: runtime === "traex"
|
|
986
|
+
? error.message.replace(/^Codex\b/, providerName)
|
|
987
|
+
: error.message;
|
|
988
|
+
for (const se of normalizer.fail({
|
|
989
|
+
code: responseStopped
|
|
990
|
+
? "response_stopped"
|
|
991
|
+
: runtime === "traex" ? "traex_error" : "codex_error",
|
|
992
|
+
message,
|
|
993
|
+
source: "execution",
|
|
994
|
+
})) {
|
|
995
|
+
emitCurrent(se.type === "session.status"
|
|
996
|
+
? { ...se, status: "failed", note: message }
|
|
997
|
+
: se);
|
|
998
|
+
}
|
|
999
|
+
clearPendingInputsForResponse(failedResponseId);
|
|
1000
|
+
normalizer = null;
|
|
1001
|
+
currentResponseId = null;
|
|
1002
|
+
};
|
|
958
1003
|
const sink = {
|
|
959
1004
|
onTurnStart: (turnId) => startNormalizer(turnId),
|
|
1005
|
+
onTurnObserved: (turnId) => {
|
|
1006
|
+
const n = startNormalizer(turnId);
|
|
1007
|
+
for (const event of n.next({ type: "turn_started", ...(turnId ? { turnId } : {}) })) {
|
|
1008
|
+
emitCurrent(event);
|
|
1009
|
+
}
|
|
1010
|
+
},
|
|
960
1011
|
onUserMessage: (content) => {
|
|
961
1012
|
const normalizedContent = typeof content === "string"
|
|
962
1013
|
? [{ type: "input_text", text: content }]
|
|
@@ -990,45 +1041,28 @@ export class LocalAgentHost {
|
|
|
990
1041
|
...(statusKind ? { statusKind } : {}),
|
|
991
1042
|
});
|
|
992
1043
|
},
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
1044
|
+
// A newer authoritative turn/started completes the old canonical
|
|
1045
|
+
// response without publishing a false idle edge in a running→running
|
|
1046
|
+
// transition.
|
|
1047
|
+
onTurnEnd: completeCurrentTurn,
|
|
1048
|
+
onRecoveredTurnStatus: (status, turnId, error) => {
|
|
1049
|
+
const responseId = turnId ? `resp_codex_${turnId}` : undefined;
|
|
1050
|
+
if (normalizer && (!responseId || currentResponseId === responseId)) {
|
|
1051
|
+
if (status === "failed")
|
|
1052
|
+
failCurrentTurn(error ?? new Error("Codex turn failed"));
|
|
1053
|
+
else
|
|
1054
|
+
completeCurrentTurn();
|
|
996
1055
|
return;
|
|
997
|
-
const completedResponseId = currentResponseId;
|
|
998
|
-
if (usage) {
|
|
999
|
-
for (const se of normalizer.next({ type: "turn_completed", usage }))
|
|
1000
|
-
emitCurrent(se);
|
|
1001
1056
|
}
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
closeCanonicalInteractions();
|
|
1010
|
-
if (!normalizer)
|
|
1011
|
-
return;
|
|
1012
|
-
const failedResponseId = currentResponseId;
|
|
1013
|
-
const responseStopped = error.message === "Codex turn was interrupted";
|
|
1014
|
-
const providerName = runtime === "traex" ? "Traex" : "Codex";
|
|
1015
|
-
const message = responseStopped
|
|
1016
|
-
? "Response stopped"
|
|
1017
|
-
: runtime === "traex"
|
|
1018
|
-
? error.message.replace(/^Codex\b/, providerName)
|
|
1019
|
-
: error.message;
|
|
1020
|
-
for (const se of normalizer.fail({
|
|
1021
|
-
code: responseStopped
|
|
1022
|
-
? "response_stopped"
|
|
1023
|
-
: runtime === "traex" ? "traex_error" : "codex_error",
|
|
1024
|
-
message,
|
|
1025
|
-
source: "execution",
|
|
1026
|
-
}))
|
|
1027
|
-
emitCurrent(se);
|
|
1028
|
-
clearPendingInputsForResponse(failedResponseId);
|
|
1029
|
-
normalizer = null;
|
|
1030
|
-
currentResponseId = null;
|
|
1057
|
+
emitCurrent({
|
|
1058
|
+
type: "session.status",
|
|
1059
|
+
sessionId: currentSessionId,
|
|
1060
|
+
...(responseId ? { responseId } : {}),
|
|
1061
|
+
status,
|
|
1062
|
+
...(error ? { note: error.message } : {}),
|
|
1063
|
+
});
|
|
1031
1064
|
},
|
|
1065
|
+
onTurnError: failCurrentTurn,
|
|
1032
1066
|
// A resumed TUI may rebroadcast `thread/started`; binding is idempotent.
|
|
1033
1067
|
shouldIgnoreThreadStarted: (threadId, forkedFromId) => this.shouldIgnoreManagedForkThreadStarted(live, threadId, forkedFromId),
|
|
1034
1068
|
onThreadStarted: (threadId, forkedFromId) => this.onLiveThreadStarted(live, localThreadId, threadId, forkedFromId),
|
|
@@ -1140,6 +1174,7 @@ export class LocalAgentHost {
|
|
|
1140
1174
|
}
|
|
1141
1175
|
}
|
|
1142
1176
|
if (resumedThreadId) {
|
|
1177
|
+
forwarder.noteThreadBound(resumedThreadId);
|
|
1143
1178
|
this.onLiveThreadStarted(live, localThreadId, resumedThreadId);
|
|
1144
1179
|
}
|
|
1145
1180
|
else if (resumeError &&
|
|
@@ -1238,8 +1273,9 @@ export class LocalAgentHost {
|
|
|
1238
1273
|
* `_subscribe_until_ready`). A fresh TUI thread has no rollout until its first
|
|
1239
1274
|
* turn, so `thread/resume` is retried: park until the forwarder observes the
|
|
1240
1275
|
* thread active, then retry. Resume only fetches the newest summarized Turn:
|
|
1241
|
-
* recovery reconciles the exact active Turn
|
|
1242
|
-
* historical items. Once resume succeeds,
|
|
1276
|
+
* recovery reconciles the exact active Turn or publishes the newest explicit
|
|
1277
|
+
* terminal status without replaying historical items. Once resume succeeds,
|
|
1278
|
+
* subsequent turns arrive live.
|
|
1243
1279
|
*/
|
|
1244
1280
|
async subscribeUntilReady(live, threadId) {
|
|
1245
1281
|
while (!live.stopped) {
|
|
@@ -1255,13 +1291,12 @@ export class LocalAgentHost {
|
|
|
1255
1291
|
},
|
|
1256
1292
|
});
|
|
1257
1293
|
// Codex 0.146.1 accepts initialTurnsPage but can still return the entire
|
|
1258
|
-
// rollout in chronological order.
|
|
1259
|
-
//
|
|
1260
|
-
const
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
live.forwarder.reconcileActiveTurn(activeTurn);
|
|
1294
|
+
// rollout in chronological order. An identified active turn is matched
|
|
1295
|
+
// by id; the no-active fallback reads only the final explicit status.
|
|
1296
|
+
const turns = Array.isArray(resp.thread.turns)
|
|
1297
|
+
? resp.thread.turns
|
|
1298
|
+
: [];
|
|
1299
|
+
live.forwarder.reconcileResumeTurns(turns);
|
|
1265
1300
|
return true; // subscribed — live item/turn notifications now flow to the forwarder
|
|
1266
1301
|
}
|
|
1267
1302
|
catch (error) {
|
|
@@ -1476,30 +1511,11 @@ export class LocalAgentHost {
|
|
|
1476
1511
|
const turnId = live.forwarder.currentTurnId();
|
|
1477
1512
|
if (turnId) {
|
|
1478
1513
|
injectionMethod = "turn/steer";
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
threadId,
|
|
1485
|
-
expectedTurnId: turnId,
|
|
1486
|
-
input: nativeInput,
|
|
1487
|
-
});
|
|
1488
|
-
}
|
|
1489
|
-
catch (error) {
|
|
1490
|
-
const delayMs = TURN_STEER_ACTIVATION_RETRY_DELAYS_MS[retryIndex];
|
|
1491
|
-
if (delayMs === undefined ||
|
|
1492
|
-
!isTransientTurnSteerActivationRace(error) ||
|
|
1493
|
-
live.stopped ||
|
|
1494
|
-
live.rotationPending ||
|
|
1495
|
-
!live.forwarder.isTurnOpen() ||
|
|
1496
|
-
live.forwarder.currentTurnId() !== turnId) {
|
|
1497
|
-
throw error;
|
|
1498
|
-
}
|
|
1499
|
-
retryIndex += 1;
|
|
1500
|
-
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
1501
|
-
}
|
|
1502
|
-
}
|
|
1514
|
+
const steered = await live.injectClient.turnSteer({
|
|
1515
|
+
threadId,
|
|
1516
|
+
expectedTurnId: turnId,
|
|
1517
|
+
input: nativeInput,
|
|
1518
|
+
});
|
|
1503
1519
|
if (pendingInput.state === "prepublished") {
|
|
1504
1520
|
// The caller already persisted and published this user input
|
|
1505
1521
|
// before waiting for the native Terminal to become ready.
|
|
@@ -1515,7 +1531,7 @@ export class LocalAgentHost {
|
|
|
1515
1531
|
if (!live.observerAvailable) {
|
|
1516
1532
|
this.armObserverReconnectDeadline(live);
|
|
1517
1533
|
}
|
|
1518
|
-
return "
|
|
1534
|
+
return "steered";
|
|
1519
1535
|
}
|
|
1520
1536
|
}
|
|
1521
1537
|
// Carry the agent-spec model on the turn so a web-injected turn runs the
|
|
@@ -1988,6 +2004,17 @@ export class LocalAgentHost {
|
|
|
1988
2004
|
emitCurrent(se);
|
|
1989
2005
|
},
|
|
1990
2006
|
onInteraction: forwardInteraction,
|
|
2007
|
+
onStatus: (status, blockedOn) => {
|
|
2008
|
+
const responseId = currentResponseId ??
|
|
2009
|
+
live.pendingInjectedInputs.find((entry) => entry.responseId)?.responseId;
|
|
2010
|
+
emitCurrent({
|
|
2011
|
+
type: "session.status",
|
|
2012
|
+
sessionId: currentSessionId,
|
|
2013
|
+
...(responseId ? { responseId } : {}),
|
|
2014
|
+
status,
|
|
2015
|
+
...(blockedOn ? { note: blockedOn } : {}),
|
|
2016
|
+
});
|
|
2017
|
+
},
|
|
1991
2018
|
onTurnEnd: (usage) => {
|
|
1992
2019
|
if (!normalizer)
|
|
1993
2020
|
return;
|
|
@@ -2028,16 +2055,20 @@ export class LocalAgentHost {
|
|
|
2028
2055
|
});
|
|
2029
2056
|
}
|
|
2030
2057
|
},
|
|
2031
|
-
onTurnError: () => {
|
|
2058
|
+
onTurnError: (error) => {
|
|
2032
2059
|
if (!normalizer)
|
|
2033
2060
|
return;
|
|
2034
2061
|
const rid = currentResponseId;
|
|
2062
|
+
const message = error.message || "Agent turn failed";
|
|
2035
2063
|
for (const se of normalizer.fail({
|
|
2036
2064
|
code: "agent_error",
|
|
2037
|
-
message
|
|
2065
|
+
message,
|
|
2038
2066
|
source: "execution",
|
|
2039
|
-
}))
|
|
2040
|
-
emitCurrent(se
|
|
2067
|
+
})) {
|
|
2068
|
+
emitCurrent(se.type === "session.status"
|
|
2069
|
+
? { ...se, status: "failed", note: message }
|
|
2070
|
+
: se);
|
|
2071
|
+
}
|
|
2041
2072
|
live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== rid);
|
|
2042
2073
|
normalizer = null;
|
|
2043
2074
|
currentResponseId = undefined;
|
|
@@ -2196,14 +2227,18 @@ export class LocalAgentHost {
|
|
|
2196
2227
|
* (they inject via the app-server). Idempotent. */
|
|
2197
2228
|
attachTerminalInjector(localThreadId, injector) {
|
|
2198
2229
|
const live = this.liveClaudeSessions.get(localThreadId);
|
|
2199
|
-
if (live)
|
|
2230
|
+
if (live) {
|
|
2231
|
+
if (live.injector === injector)
|
|
2232
|
+
return;
|
|
2200
2233
|
live.injector = injector;
|
|
2234
|
+
live.forwarder.attachStatusSource({ panePid: () => injector.panePid?.() });
|
|
2235
|
+
}
|
|
2201
2236
|
}
|
|
2202
2237
|
/** Close an active native response when its terminal or runner disappears. */
|
|
2203
2238
|
failLiveSession(localThreadId, error) {
|
|
2204
2239
|
const claude = this.liveClaudeSessions.get(localThreadId);
|
|
2205
2240
|
if (claude)
|
|
2206
|
-
return claude.forwarder.
|
|
2241
|
+
return claude.forwarder.noteTerminalExit(error);
|
|
2207
2242
|
const codex = this.liveSessions.get(localThreadId);
|
|
2208
2243
|
if (!codex)
|
|
2209
2244
|
return false;
|
|
@@ -2387,20 +2422,6 @@ function codexRpcError(error, method) {
|
|
|
2387
2422
|
}
|
|
2388
2423
|
return `Codex ${method} failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
2389
2424
|
}
|
|
2390
|
-
/**
|
|
2391
|
-
* Codex 0.146 can acknowledge `turn/start` a few milliseconds before its
|
|
2392
|
-
* internal active-turn pointer becomes visible to `turn/steer`. The injection
|
|
2393
|
-
* lock still prevents double-starts, but the immediately following steer must
|
|
2394
|
-
* briefly retry the same expected turn id. Keep this narrow: all other RPC
|
|
2395
|
-
* errors remain terminal and visible to the caller.
|
|
2396
|
-
*/
|
|
2397
|
-
function isTransientTurnSteerActivationRace(error) {
|
|
2398
|
-
return error instanceof CodexTransportError &&
|
|
2399
|
-
error.code === -32600 &&
|
|
2400
|
-
(/no active turn to steer/i.test(error.message) ||
|
|
2401
|
-
/expected active turn id\b.+\bfound\b/i.test(error.message));
|
|
2402
|
-
}
|
|
2403
|
-
const TURN_STEER_ACTIVATION_RETRY_DELAYS_MS = [10, 20, 40, 80, 160, 250, 440];
|
|
2404
2425
|
export function parseCodexLoginStatus(exitCode, output) {
|
|
2405
2426
|
const normalized = output.trim();
|
|
2406
2427
|
const match = normalized.match(/Logged in using (.+)$/im);
|
package/dist/runner/child.js
CHANGED
|
@@ -304,7 +304,7 @@ export class RunnerSession {
|
|
|
304
304
|
const outcome = (await provider.injectMessage?.(msg.localThreadId, input)) ?? "notLive";
|
|
305
305
|
// App-server injection is independent of the Terminal TUI startup, so it
|
|
306
306
|
// must not cancel prompt handling for the pane that is still starting.
|
|
307
|
-
const error = outcome === "injected"
|
|
307
|
+
const error = outcome === "injected" || outcome === "steered"
|
|
308
308
|
? undefined
|
|
309
309
|
: provider.liveSessionError?.(msg.localThreadId);
|
|
310
310
|
this.transport.send({
|
package/dist/runner/manager.d.ts
CHANGED
|
@@ -43,9 +43,10 @@ export interface RunnerManagerOptions {
|
|
|
43
43
|
runnerEntry?: string;
|
|
44
44
|
/** Idle TTL (ms) after which an unused runner is reaped. */
|
|
45
45
|
idleTtlMs?: number;
|
|
46
|
-
/**
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
/** Native pane idle window. `<= 0` disables native-pane idle reaping. */
|
|
47
|
+
nativePaneIdleTtlMs?: number;
|
|
48
|
+
/** Recent tmux-output window used as independent native-pane busy evidence. */
|
|
49
|
+
nativePaneOutputBusyWindowMs?: number;
|
|
49
50
|
/** Background reap sweep interval (ms); `0` disables the timer (tests). */
|
|
50
51
|
reapIntervalMs?: number;
|
|
51
52
|
/** Grace period between SIGTERM and SIGKILL during runner shutdown. */
|
|
@@ -75,7 +76,13 @@ export interface RunnerManagerOptions {
|
|
|
75
76
|
/** Injected for tests. Kills and verifies the deterministic private tmux
|
|
76
77
|
* server owned by a Session runner. */
|
|
77
78
|
terminateTerminalServer?: (terminalName: string) => boolean | Promise<boolean>;
|
|
79
|
+
/** Injected tmux `#{window_activity}` reader. Returns epoch seconds. */
|
|
80
|
+
terminalWindowActivityAt?: (terminalName: string) => number | null | Promise<number | null>;
|
|
81
|
+
/** Injected tmux attached-client probe. */
|
|
82
|
+
terminalHasAttachedClient?: (terminalName: string) => boolean | Promise<boolean>;
|
|
78
83
|
now?: () => number;
|
|
84
|
+
/** Wall clock used to compare tmux's epoch timestamp. */
|
|
85
|
+
wallNow?: () => number;
|
|
79
86
|
/** Extra env merged into every spawned runner child — e.g. the control-plane
|
|
80
87
|
* URL + token so a claude-native PermissionRequest hook can POST back. */
|
|
81
88
|
childEnv?: Record<string, string>;
|
|
@@ -142,7 +149,8 @@ export declare class RunnerManager implements AgentCapabilities {
|
|
|
142
149
|
private readonly sessionStore;
|
|
143
150
|
private readonly runnerEntry;
|
|
144
151
|
private readonly idleTtlMs;
|
|
145
|
-
private readonly
|
|
152
|
+
private readonly nativePaneIdleTtlMs;
|
|
153
|
+
private readonly nativePaneOutputBusyWindowMs;
|
|
146
154
|
private readonly spawn;
|
|
147
155
|
private readonly childEnv;
|
|
148
156
|
private readonly sessionContextProvider;
|
|
@@ -159,12 +167,16 @@ export declare class RunnerManager implements AgentCapabilities {
|
|
|
159
167
|
private readonly shutdownKillGraceMs;
|
|
160
168
|
private readonly signalChild;
|
|
161
169
|
private readonly terminateTerminalServer;
|
|
170
|
+
private readonly terminalWindowActivityAt;
|
|
171
|
+
private readonly terminalHasAttachedClient;
|
|
172
|
+
private readonly wallNow;
|
|
162
173
|
private readonly liveStartTimeoutMs;
|
|
163
174
|
private readonly liveReadyTimeoutMs;
|
|
164
175
|
private readonly nativeLiveStartTimeoutMs;
|
|
165
176
|
private readonly liveInterruptTimeoutMs;
|
|
166
177
|
private readonly terminalInputHandoffTimeoutMs;
|
|
167
178
|
private readonly terminalInputCleanupRetryMs;
|
|
179
|
+
private reapPromise;
|
|
168
180
|
private stopping;
|
|
169
181
|
private stopPromise;
|
|
170
182
|
/** Sink for mirrored {@link SessionEvent}s from every session's forwarder. */
|
|
@@ -321,14 +333,17 @@ export declare class RunnerManager implements AgentCapabilities {
|
|
|
321
333
|
private failHandle;
|
|
322
334
|
private terminateHandle;
|
|
323
335
|
private reapIdle;
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
private
|
|
336
|
+
private reapIdleOnce;
|
|
337
|
+
/** Provider-authoritative turn state is the lifecycle truth; attached clients
|
|
338
|
+
* and tmux's own activity clock are independent busy evidence. There is no
|
|
339
|
+
* "user was inactive for an hour" override for an active turn. */
|
|
340
|
+
private reapNativePane;
|
|
341
|
+
private isNativePaneBusy;
|
|
342
|
+
private isManagedNativeHandle;
|
|
329
343
|
private failActiveResponses;
|
|
330
|
-
/** Track
|
|
331
|
-
*
|
|
344
|
+
/** Track the provider-authoritative response lifecycle. Native-pane busy
|
|
345
|
+
* classification consumes this level directly; output volume is separately
|
|
346
|
+
* grounded in tmux's own activity clock. */
|
|
332
347
|
private observeHandleRuntimeEvent;
|
|
333
348
|
private openSessionContext;
|
|
334
349
|
}
|
package/dist/runner/manager.js
CHANGED
|
@@ -22,7 +22,7 @@ import { fileURLToPath } from "node:url";
|
|
|
22
22
|
import { AgentRuntimeError, } from "@rynx-ai/core";
|
|
23
23
|
import { listRuntimeModels } from "../models-catalog.js";
|
|
24
24
|
import { probeRuntimeStatus } from "../runtime-status.js";
|
|
25
|
-
import { terminateTmuxServer } from "../terminal/tmux.js";
|
|
25
|
+
import { terminateTmuxServer, tmuxHasAttachedClient, tmuxWindowActivityAt, } from "../terminal/tmux.js";
|
|
26
26
|
import { fromWireError, } from "./protocol.js";
|
|
27
27
|
import { isManagedNativeProvider } from "./startup-policy.js";
|
|
28
28
|
import { StdioRunnerTransport } from "./transport.js";
|
|
@@ -51,12 +51,10 @@ const DEFAULT_TERMINAL_INPUT_HANDOFF_TIMEOUT_MS = 30_000;
|
|
|
51
51
|
/** Retry transient terminal-server cleanup without spinning forever. */
|
|
52
52
|
const DEFAULT_TERMINAL_INPUT_CLEANUP_RETRY_MS = 1_000;
|
|
53
53
|
const MAX_TERMINAL_INPUT_CLEANUP_RETRY_MS = 30_000;
|
|
54
|
-
/**
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
* does not, because a runaway redraw loop is the failure mode this bounds. */
|
|
59
|
-
const DEFAULT_STALE_ACTIVE_TTL_MS = 60 * 60_000;
|
|
54
|
+
/** Full idle window before an inactive native pane becomes reapable. */
|
|
55
|
+
const DEFAULT_NATIVE_PANE_IDLE_TTL_MS = 60 * 60_000;
|
|
56
|
+
/** tmux output this recent independently proves that a native pane is busy. */
|
|
57
|
+
const DEFAULT_NATIVE_PANE_OUTPUT_BUSY_WINDOW_MS = 120_000;
|
|
60
58
|
/** Keep per-Session spawn context small enough to remain an environment handoff,
|
|
61
59
|
* not an unbounded transport. */
|
|
62
60
|
const MAX_SESSION_CONTEXT_ENV_ENTRIES = 32;
|
|
@@ -157,7 +155,8 @@ export class RunnerManager {
|
|
|
157
155
|
sessionStore;
|
|
158
156
|
runnerEntry;
|
|
159
157
|
idleTtlMs;
|
|
160
|
-
|
|
158
|
+
nativePaneIdleTtlMs;
|
|
159
|
+
nativePaneOutputBusyWindowMs;
|
|
161
160
|
spawn;
|
|
162
161
|
childEnv;
|
|
163
162
|
sessionContextProvider;
|
|
@@ -174,12 +173,16 @@ export class RunnerManager {
|
|
|
174
173
|
shutdownKillGraceMs;
|
|
175
174
|
signalChild;
|
|
176
175
|
terminateTerminalServer;
|
|
176
|
+
terminalWindowActivityAt;
|
|
177
|
+
terminalHasAttachedClient;
|
|
178
|
+
wallNow;
|
|
177
179
|
liveStartTimeoutMs;
|
|
178
180
|
liveReadyTimeoutMs;
|
|
179
181
|
nativeLiveStartTimeoutMs;
|
|
180
182
|
liveInterruptTimeoutMs;
|
|
181
183
|
terminalInputHandoffTimeoutMs;
|
|
182
184
|
terminalInputCleanupRetryMs;
|
|
185
|
+
reapPromise = null;
|
|
183
186
|
stopping = false;
|
|
184
187
|
stopPromise;
|
|
185
188
|
/** Sink for mirrored {@link SessionEvent}s from every session's forwarder. */
|
|
@@ -213,18 +216,22 @@ export class RunnerManager {
|
|
|
213
216
|
this.sessionStore = opts.sessionStore;
|
|
214
217
|
this.runnerEntry = opts.runnerEntry ?? defaultRunnerEntry();
|
|
215
218
|
this.idleTtlMs = opts.idleTtlMs ?? 300_000;
|
|
216
|
-
this.
|
|
219
|
+
this.nativePaneIdleTtlMs = opts.nativePaneIdleTtlMs ?? DEFAULT_NATIVE_PANE_IDLE_TTL_MS;
|
|
220
|
+
this.nativePaneOutputBusyWindowMs = Math.max(0, opts.nativePaneOutputBusyWindowMs ?? DEFAULT_NATIVE_PANE_OUTPUT_BUSY_WINDOW_MS);
|
|
217
221
|
this.spawn = opts.spawn ?? nodeSpawn;
|
|
218
222
|
this.childEnv = opts.childEnv ?? {};
|
|
219
223
|
this.sessionContextProvider = opts.sessionContextProvider;
|
|
220
224
|
this.admissionOpen = opts.admissionOpen ?? (() => true);
|
|
221
225
|
this.admissionReserve = opts.admissionReserve;
|
|
222
226
|
this.now = opts.now ?? (() => Date.now());
|
|
227
|
+
this.wallNow = opts.wallNow ?? (() => Date.now());
|
|
223
228
|
this.defaultRuntime = opts.config.DEFAULT_RUNTIME ?? "codex";
|
|
224
229
|
this.shutdownGraceMs = Math.max(0, opts.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS);
|
|
225
230
|
this.shutdownKillGraceMs = Math.max(0, opts.shutdownKillGraceMs ?? DEFAULT_SHUTDOWN_KILL_GRACE_MS);
|
|
226
231
|
this.signalChild = opts.signalChild ?? signalRunnerChild;
|
|
227
232
|
this.terminateTerminalServer = opts.terminateTerminalServer ?? terminateTmuxServer;
|
|
233
|
+
this.terminalWindowActivityAt = opts.terminalWindowActivityAt ?? tmuxWindowActivityAt;
|
|
234
|
+
this.terminalHasAttachedClient = opts.terminalHasAttachedClient ?? tmuxHasAttachedClient;
|
|
228
235
|
this.liveStartTimeoutMs = Math.max(1, opts.liveStartTimeoutMs ?? DEFAULT_LIVE_START_TIMEOUT_MS);
|
|
229
236
|
this.liveReadyTimeoutMs = Math.max(1, opts.liveReadyTimeoutMs ?? DEFAULT_LIVE_READY_TIMEOUT_MS);
|
|
230
237
|
this.nativeLiveStartTimeoutMs = Math.max(1, opts.nativeLiveStartTimeoutMs ?? DEFAULT_NATIVE_LIVE_START_TIMEOUT_MS);
|
|
@@ -233,7 +240,7 @@ export class RunnerManager {
|
|
|
233
240
|
this.terminalInputCleanupRetryMs = Math.max(1, opts.terminalInputCleanupRetryMs ?? DEFAULT_TERMINAL_INPUT_CLEANUP_RETRY_MS);
|
|
234
241
|
const reapIntervalMs = opts.reapIntervalMs ?? 60_000;
|
|
235
242
|
if (reapIntervalMs > 0) {
|
|
236
|
-
this.reapTimer = setInterval(() => this.reapIdle(), reapIntervalMs);
|
|
243
|
+
this.reapTimer = setInterval(() => void this.reapIdle(), reapIntervalMs);
|
|
237
244
|
this.reapTimer.unref?.();
|
|
238
245
|
}
|
|
239
246
|
else {
|
|
@@ -648,7 +655,7 @@ export class RunnerManager {
|
|
|
648
655
|
handle.live.set(reqId, (res) => {
|
|
649
656
|
const outcome = res.outcome ?? "failed";
|
|
650
657
|
const finish = () => {
|
|
651
|
-
if (outcome === "injected") {
|
|
658
|
+
if (outcome === "injected" || outcome === "steered") {
|
|
652
659
|
this.liveErrors.delete(localThreadId);
|
|
653
660
|
}
|
|
654
661
|
else {
|
|
@@ -902,7 +909,6 @@ export class RunnerManager {
|
|
|
902
909
|
!allowReservedForkTarget) {
|
|
903
910
|
throw new AgentRuntimeError("Session fork is still being committed", 409, "session_fork_pending");
|
|
904
911
|
}
|
|
905
|
-
this.reapIdle();
|
|
906
912
|
const existing = this.handles.get(key);
|
|
907
913
|
if (existing && !existing.dead) {
|
|
908
914
|
existing.lastUsedAt = this.now();
|
|
@@ -1369,8 +1375,24 @@ export class RunnerManager {
|
|
|
1369
1375
|
return attempt;
|
|
1370
1376
|
}
|
|
1371
1377
|
reapIdle() {
|
|
1378
|
+
if (this.reapPromise)
|
|
1379
|
+
return this.reapPromise;
|
|
1380
|
+
const attempt = this.reapIdleOnce();
|
|
1381
|
+
this.reapPromise = attempt;
|
|
1382
|
+
const clear = () => {
|
|
1383
|
+
if (this.reapPromise === attempt)
|
|
1384
|
+
this.reapPromise = null;
|
|
1385
|
+
};
|
|
1386
|
+
void attempt.then(clear, clear);
|
|
1387
|
+
return attempt;
|
|
1388
|
+
}
|
|
1389
|
+
async reapIdleOnce() {
|
|
1372
1390
|
const now = this.now();
|
|
1373
1391
|
for (const [key, handle] of this.handles) {
|
|
1392
|
+
if (this.isManagedNativeHandle(handle)) {
|
|
1393
|
+
await this.reapNativePane(handle, now);
|
|
1394
|
+
continue;
|
|
1395
|
+
}
|
|
1374
1396
|
if (handle.caps.size > 0 || handle.terminals.size > 0 || handle.live.size > 0) {
|
|
1375
1397
|
continue;
|
|
1376
1398
|
}
|
|
@@ -1379,28 +1401,74 @@ export class RunnerManager {
|
|
|
1379
1401
|
continue;
|
|
1380
1402
|
}
|
|
1381
1403
|
const hasActiveResponse = handle.activeResponseIds.size > 0;
|
|
1382
|
-
if (hasActiveResponse
|
|
1404
|
+
if (hasActiveResponse)
|
|
1383
1405
|
continue;
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
: this.liveSessionKeys.has(key)
|
|
1388
|
-
? "idle live runner reaped"
|
|
1389
|
-
: "idle runner reaped";
|
|
1390
|
-
if (hasActiveResponse) {
|
|
1391
|
-
this.failStaleActiveResponses(handle, idleForMs);
|
|
1392
|
-
}
|
|
1406
|
+
const reason = this.liveSessionKeys.has(key)
|
|
1407
|
+
? "idle live runner reaped"
|
|
1408
|
+
: "idle runner reaped";
|
|
1393
1409
|
void this.terminateHandle(handle, reason).catch((error) => {
|
|
1394
1410
|
logTerminationFailure(handle, error);
|
|
1395
1411
|
});
|
|
1396
1412
|
}
|
|
1397
1413
|
}
|
|
1398
|
-
/**
|
|
1399
|
-
*
|
|
1400
|
-
*
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1414
|
+
/** Provider-authoritative turn state is the lifecycle truth; attached clients
|
|
1415
|
+
* and tmux's own activity clock are independent busy evidence. There is no
|
|
1416
|
+
* "user was inactive for an hour" override for an active turn. */
|
|
1417
|
+
async reapNativePane(handle, now) {
|
|
1418
|
+
if (this.nativePaneIdleTtlMs <= 0 || handle.dead)
|
|
1419
|
+
return;
|
|
1420
|
+
if (await this.isNativePaneBusy(handle)) {
|
|
1421
|
+
handle.nativePaneLastBusyAt = now;
|
|
1422
|
+
return;
|
|
1423
|
+
}
|
|
1424
|
+
const lastBusyAt = handle.nativePaneLastBusyAt;
|
|
1425
|
+
if (lastBusyAt === undefined) {
|
|
1426
|
+
// First idle observation starts a full grace window.
|
|
1427
|
+
handle.nativePaneLastBusyAt = now;
|
|
1428
|
+
return;
|
|
1429
|
+
}
|
|
1430
|
+
if (now - lastBusyAt < this.nativePaneIdleTtlMs)
|
|
1431
|
+
return;
|
|
1432
|
+
// Close the select→reap race: activity may begin after classification but
|
|
1433
|
+
// before teardown.
|
|
1434
|
+
if (await this.isNativePaneBusy(handle)) {
|
|
1435
|
+
handle.nativePaneLastBusyAt = this.now();
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
// A failed teardown must re-arm on the next scan instead of retrying on
|
|
1439
|
+
// every sweep forever.
|
|
1440
|
+
delete handle.nativePaneLastBusyAt;
|
|
1441
|
+
await this.terminateHandle(handle, "idle native pane reaped").catch((error) => logTerminationFailure(handle, error));
|
|
1442
|
+
}
|
|
1443
|
+
async isNativePaneBusy(handle) {
|
|
1444
|
+
if (handle.activeResponseIds.size > 0 ||
|
|
1445
|
+
handle.caps.size > 0 ||
|
|
1446
|
+
handle.live.size > 0 ||
|
|
1447
|
+
handle.terminals.size > 0) {
|
|
1448
|
+
return true;
|
|
1449
|
+
}
|
|
1450
|
+
const terminalName = `${handle.key}-main`;
|
|
1451
|
+
try {
|
|
1452
|
+
if (await this.terminalHasAttachedClient(terminalName))
|
|
1453
|
+
return true;
|
|
1454
|
+
}
|
|
1455
|
+
catch {
|
|
1456
|
+
// A failed probe contributes no busy evidence; check activity next.
|
|
1457
|
+
}
|
|
1458
|
+
let activityAt = null;
|
|
1459
|
+
try {
|
|
1460
|
+
activityAt = await this.terminalWindowActivityAt(terminalName);
|
|
1461
|
+
}
|
|
1462
|
+
catch {
|
|
1463
|
+
// A failed probe contributes no busy evidence.
|
|
1464
|
+
}
|
|
1465
|
+
return activityAt !== null &&
|
|
1466
|
+
this.wallNow() - activityAt * 1_000 < this.nativePaneOutputBusyWindowMs;
|
|
1467
|
+
}
|
|
1468
|
+
isManagedNativeHandle(handle) {
|
|
1469
|
+
const sessionId = this.currentTerminalSessionId(handle, handle.key);
|
|
1470
|
+
const provider = this.liveOptions.get(sessionId)?.execution.provider;
|
|
1471
|
+
return isManagedNativeProvider(provider);
|
|
1404
1472
|
}
|
|
1405
1473
|
failActiveResponses(handle, code, message) {
|
|
1406
1474
|
const responseIds = [...handle.activeResponseIds];
|
|
@@ -1418,8 +1486,9 @@ export class RunnerManager {
|
|
|
1418
1486
|
});
|
|
1419
1487
|
}
|
|
1420
1488
|
}
|
|
1421
|
-
/** Track
|
|
1422
|
-
*
|
|
1489
|
+
/** Track the provider-authoritative response lifecycle. Native-pane busy
|
|
1490
|
+
* classification consumes this level directly; output volume is separately
|
|
1491
|
+
* grounded in tmux's own activity clock. */
|
|
1423
1492
|
observeHandleRuntimeEvent(handle, event) {
|
|
1424
1493
|
switch (event.type) {
|
|
1425
1494
|
case "response.created":
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* (`live.ensure` / `inject` / `live.interrupt`) plus per-thread capabilities and
|
|
7
7
|
* terminal attachment; the reply channels mirror each request's `reqId`.
|
|
8
8
|
*/
|
|
9
|
-
import { AgentRuntimeError, type ResolvedExecutionSnapshot, type RuntimeUserInput, type SessionInteractionResolution, type SessionEvent, type SessionWorkspaceSnapshot } from "@rynx-ai/core";
|
|
9
|
+
import { AgentRuntimeError, type InjectOutcome, type ResolvedExecutionSnapshot, type RuntimeUserInput, type SessionInteractionResolution, type SessionEvent, type SessionWorkspaceSnapshot } from "@rynx-ai/core";
|
|
10
|
+
export type { InjectOutcome } from "@rynx-ai/core";
|
|
10
11
|
import type { ResolveInteractionResult } from "../interactions.js";
|
|
11
12
|
/** A runtime error flattened for the wire; rebuilt parent-side as `AgentRuntimeError`. */
|
|
12
13
|
export interface WireError {
|
|
@@ -22,13 +23,6 @@ export type TerminalRole = "owner" | "read-only";
|
|
|
22
23
|
/** Stable reason for a terminal attach failure. `terminal_not_live` is an
|
|
23
24
|
* expected, retryable absence; `terminal_open_failed` is an infrastructure error. */
|
|
24
25
|
export type TerminalOpenErrorCode = "terminal_not_live" | "terminal_open_failed";
|
|
25
|
-
/** Outcome of an `inject`. `injected` = the app-server / tmux accepted the turn.
|
|
26
|
-
* `notLive` = no live forwarder for this session (caller may use the run path).
|
|
27
|
-
* `notReady` = live but not ready within the park window; `failed` = injection
|
|
28
|
-
* threw. For BOTH notReady and failed the caller reports an error and MUST NOT
|
|
29
|
-
* re-run the turn (re-running would double-write alongside the forwarder).
|
|
30
|
-
* Mirrors reference implementation's "inject failure ⇒ response.failed, never re-run locally". */
|
|
31
|
-
export type InjectOutcome = "injected" | "notLive" | "notReady" | "failed";
|
|
32
26
|
/** Parent → child. Terminal PTY bytes ride `term.input` base64-encoded so the
|
|
33
27
|
* NDJSON line framing stays intact (raw bytes contain newlines). */
|
|
34
28
|
export type ToChild = {
|
|
@@ -171,9 +165,9 @@ export type FromChild = {
|
|
|
171
165
|
ok: boolean;
|
|
172
166
|
error?: string;
|
|
173
167
|
}
|
|
174
|
-
/** Result of an `inject`: `outcome` distinguishes
|
|
175
|
-
*
|
|
176
|
-
*
|
|
168
|
+
/** Result of an `inject`: `outcome` distinguishes a new turn, an active-turn
|
|
169
|
+
* steer, and failures so the caller never falls back to a second output path.
|
|
170
|
+
* Echoes the request `reqId`. */
|
|
177
171
|
| {
|
|
178
172
|
t: "injected";
|
|
179
173
|
reqId: string;
|