agent-relay-runner 0.129.1 → 0.129.3
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/package.json
CHANGED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Codex launch/config-arg construction: the `-c` override builders, CLI command
|
|
2
|
+
// resolution (shim detection), and the tool-output token limit. Extracted from
|
|
3
|
+
// codex.ts (file-size ratchet, epic #291); codex.ts re-exports these so existing
|
|
4
|
+
// consumers + tests keep their import path.
|
|
5
|
+
import { accessSync, constants, realpathSync } from "node:fs";
|
|
6
|
+
import { basename, join } from "node:path";
|
|
7
|
+
import { isRecord } from "agent-relay-sdk";
|
|
8
|
+
import type { RunnerSpawnConfig } from "../adapter";
|
|
9
|
+
import { tomlString } from "../relay-mcp";
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_CODEX_TOOL_OUTPUT_TOKEN_LIMIT = 12_000;
|
|
12
|
+
|
|
13
|
+
// #1248 — default background-terminal poll window (300000ms/5min) is too short for
|
|
14
|
+
// our long-running commands (tests/releases run 5+ min), forcing agents to poll
|
|
15
|
+
// instead of just waiting. Verified against installed codex-cli's source (rust-v0.144.0):
|
|
16
|
+
// `background_terminal_max_timeout` is a top-level Config field — it replaced the older
|
|
17
|
+
// `background_terminal_timeout` key — and IS correctly threaded through: ConfigManager
|
|
18
|
+
// replays these launch `-c` overrides for every thread (config/mod.rs,
|
|
19
|
+
// app-server/src/config_manager.rs), so `UnifiedExecProcessManager` (unified_exec/mod.rs)
|
|
20
|
+
// is built with this raised ceiling and the `write_stdin` handler clamps empty-poll
|
|
21
|
+
// yield_time_ms against it (unified_exec/process_manager.rs). The config value alone does
|
|
22
|
+
// NOT make agents wait longer, though: codex's `write_stdin` tool schema
|
|
23
|
+
// (tools/handlers/shell_spec.rs::create_write_stdin_tool) ships a hardcoded description —
|
|
24
|
+
// "empty polls wait 5000-300000 ms by default" — that never reflects this override, so a
|
|
25
|
+
// model sees no signal it may request more and keeps polling at ~300000ms. That's why a
|
|
26
|
+
// live probe still saw ~300.001s polls after this override landed. codexRelayContextBlock()
|
|
27
|
+
// (codex.ts) tells Codex agents explicitly that they may request yield_time_ms up to this value.
|
|
28
|
+
const CODEX_BACKGROUND_TERMINAL_MAX_TIMEOUT_MS = 900000;
|
|
29
|
+
|
|
30
|
+
export const CODEX_BACKGROUND_TERMINAL_HINT =
|
|
31
|
+
`This Codex session's background-terminal poll window is configured to ${CODEX_BACKGROUND_TERMINAL_MAX_TIMEOUT_MS}ms ` +
|
|
32
|
+
"(15 min), well above the 300000ms ceiling the write_stdin tool's own description advertises. " +
|
|
33
|
+
"When polling a long-running background command with write_stdin, pass a larger " +
|
|
34
|
+
`yield_time_ms (up to ${CODEX_BACKGROUND_TERMINAL_MAX_TIMEOUT_MS}) instead of repeatedly ` +
|
|
35
|
+
"polling at the tool's documented default — it will be honored.";
|
|
36
|
+
|
|
37
|
+
export function codexModelConfigArgs(model?: string, effort?: string): string[] {
|
|
38
|
+
const args: string[] = [];
|
|
39
|
+
if (model) args.push("-c", `model=${tomlString(model)}`);
|
|
40
|
+
if (effort) args.push("-c", `model_reasoning_effort=${tomlString(effort)}`);
|
|
41
|
+
return args;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function codexApprovalConfigArgs(approvalMode?: string): string[] {
|
|
45
|
+
const sandboxMode = approvalMode === "open"
|
|
46
|
+
? "danger-full-access"
|
|
47
|
+
: approvalMode === "read-only"
|
|
48
|
+
? "read-only"
|
|
49
|
+
: "workspace-write";
|
|
50
|
+
return [
|
|
51
|
+
"-c",
|
|
52
|
+
'approval_policy="never"',
|
|
53
|
+
"-c",
|
|
54
|
+
`sandbox_mode=${tomlString(sandboxMode)}`,
|
|
55
|
+
];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// #1248 — see CODEX_BACKGROUND_TERMINAL_MAX_TIMEOUT_MS above for why this override
|
|
59
|
+
// is correct but insufficient on its own.
|
|
60
|
+
export function codexManagedConfigArgs(): string[] {
|
|
61
|
+
return [
|
|
62
|
+
"-c",
|
|
63
|
+
"check_for_update_on_startup=false",
|
|
64
|
+
"-c",
|
|
65
|
+
`background_terminal_max_timeout=${CODEX_BACKGROUND_TERMINAL_MAX_TIMEOUT_MS}`,
|
|
66
|
+
];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function isCodexCliCommand(command: string): boolean {
|
|
70
|
+
return basename(command) === "codex";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function resolveRealCodexCommand(command: string, pathValue = process.env.PATH || ""): string {
|
|
74
|
+
const candidates: string[] = [];
|
|
75
|
+
if (command.includes("/")) candidates.push(command);
|
|
76
|
+
for (const dir of pathValue.split(":").filter(Boolean)) candidates.push(join(dir, "codex"));
|
|
77
|
+
|
|
78
|
+
const seen = new Set<string>();
|
|
79
|
+
for (const candidate of candidates) {
|
|
80
|
+
if (seen.has(candidate)) continue;
|
|
81
|
+
seen.add(candidate);
|
|
82
|
+
if (!isExecutableFile(candidate)) continue;
|
|
83
|
+
const real = realPath(candidate);
|
|
84
|
+
if (isAgentRelayCodexShim(candidate) || isAgentRelayCodexShim(real)) continue;
|
|
85
|
+
return candidate;
|
|
86
|
+
}
|
|
87
|
+
return command;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isExecutableFile(path: string): boolean {
|
|
91
|
+
try {
|
|
92
|
+
accessSync(path, constants.X_OK);
|
|
93
|
+
return true;
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function realPath(path: string): string {
|
|
100
|
+
try {
|
|
101
|
+
return realpathSync(path);
|
|
102
|
+
} catch {
|
|
103
|
+
return path;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isAgentRelayCodexShim(path: string): boolean {
|
|
108
|
+
return path.includes("/.agent-relay/codex/bin/codex");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function codexAppServerConfigArgs(...argLists: string[][]): string[] {
|
|
112
|
+
const result: string[] = [];
|
|
113
|
+
for (const args of argLists) {
|
|
114
|
+
for (let i = 0; i < args.length; i++) {
|
|
115
|
+
const arg = args[i];
|
|
116
|
+
if (arg === "-c" || arg === "--config" || arg === "--enable" || arg === "--disable") {
|
|
117
|
+
const value = args[i + 1];
|
|
118
|
+
if (value !== undefined) {
|
|
119
|
+
result.push(arg, value);
|
|
120
|
+
i++;
|
|
121
|
+
}
|
|
122
|
+
} else if (arg?.startsWith("--config=") || arg?.startsWith("--enable=") || arg?.startsWith("--disable=")) {
|
|
123
|
+
result.push(arg);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function codexToolOutputTokenLimitConfigArgs(config: Pick<RunnerSpawnConfig, "agentProfile">): string[] {
|
|
131
|
+
const configured = codexToolOutputTokenLimit(config.agentProfile?.providerOptions);
|
|
132
|
+
if (configured === null) return [];
|
|
133
|
+
const limit = configured ?? DEFAULT_CODEX_TOOL_OUTPUT_TOKEN_LIMIT;
|
|
134
|
+
return ["-c", `tool_output_token_limit=${limit}`];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function codexToolOutputTokenLimit(providerOptions: unknown): number | null | undefined {
|
|
138
|
+
if (!isRecord(providerOptions)) return undefined;
|
|
139
|
+
const codex = providerOptions.codex;
|
|
140
|
+
if (!isRecord(codex)) return undefined;
|
|
141
|
+
const limit = codex.toolOutputTokenLimit;
|
|
142
|
+
return typeof limit === "number" || limit === null ? limit : undefined;
|
|
143
|
+
}
|
package/src/adapters/codex.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { accessSync, constants, existsSync, realpathSync } from "node:fs";
|
|
2
|
-
import { basename, join, resolve } from "node:path";
|
|
3
1
|
import type { ContextState, InteractivePrompt, LivenessSignal, Message, MessageTurnEndReason, ProviderState } from "agent-relay-sdk";
|
|
4
2
|
import { isRecord, stringValue } from "agent-relay-sdk";
|
|
5
3
|
import { isPidAlive, killPid, processTreePids, processTreePidsFromTable, waitForPidsExit } from "agent-relay-sdk/process-utils";
|
|
@@ -14,15 +12,28 @@ import { codexTokenUsageContext, isContextState, latestCodexContext } from "./co
|
|
|
14
12
|
import { computeLivenessSignal, type LivenessInputs } from "../liveness";
|
|
15
13
|
|
|
16
14
|
/** Relay context prepended to a Codex agent's first turn: the standard relay
|
|
17
|
-
* blurb plus, when running in an isolated workspace, the deps caveat (#159)
|
|
15
|
+
* blurb plus, when running in an isolated workspace, the deps caveat (#159),
|
|
16
|
+
* plus the #1248 background-terminal poll-ceiling hint. */
|
|
18
17
|
function codexRelayContextBlock(): string {
|
|
19
|
-
return [relayContext(), workspaceDepsNoteFromEnv()].filter(Boolean).join("\n\n");
|
|
18
|
+
return [relayContext(), workspaceDepsNoteFromEnv(), CODEX_BACKGROUND_TERMINAL_HINT].filter(Boolean).join("\n\n");
|
|
20
19
|
}
|
|
21
20
|
import { prepareCodexProfileHome, profileUsesHostProviderGlobals } from "../profile-home";
|
|
22
21
|
import { CodexAppClient, type ClientEvent } from "./codex-client";
|
|
22
|
+
import { CODEX_BACKGROUND_TERMINAL_HINT, codexApprovalConfigArgs, codexAppServerConfigArgs, codexManagedConfigArgs, codexModelConfigArgs, codexToolOutputTokenLimitConfigArgs, isCodexCliCommand, resolveRealCodexCommand } from "./codex-launch-config";
|
|
23
23
|
|
|
24
24
|
export { processTreePidsFromTable };
|
|
25
|
-
|
|
25
|
+
// Launch/config-arg construction lives in ./codex-launch-config; re-exported so
|
|
26
|
+
// codex.ts consumers + tests keep their import path.
|
|
27
|
+
export {
|
|
28
|
+
codexApprovalConfigArgs,
|
|
29
|
+
codexAppServerConfigArgs,
|
|
30
|
+
codexManagedConfigArgs,
|
|
31
|
+
codexModelConfigArgs,
|
|
32
|
+
codexToolOutputTokenLimitConfigArgs,
|
|
33
|
+
DEFAULT_CODEX_TOOL_OUTPUT_TOKEN_LIMIT,
|
|
34
|
+
isCodexCliCommand,
|
|
35
|
+
resolveRealCodexCommand,
|
|
36
|
+
} from "./codex-launch-config";
|
|
26
37
|
|
|
27
38
|
type PendingCodexApproval = {
|
|
28
39
|
id: string;
|
|
@@ -1073,85 +1084,6 @@ function activeFlags(value: unknown): string[] {
|
|
|
1073
1084
|
return value.activeFlags.filter((flag): flag is string => typeof flag === "string" && flag.length > 0);
|
|
1074
1085
|
}
|
|
1075
1086
|
|
|
1076
|
-
export function codexModelConfigArgs(model?: string, effort?: string): string[] {
|
|
1077
|
-
const args: string[] = [];
|
|
1078
|
-
if (model) args.push("-c", `model=${tomlString(model)}`);
|
|
1079
|
-
if (effort) args.push("-c", `model_reasoning_effort=${tomlString(effort)}`);
|
|
1080
|
-
return args;
|
|
1081
|
-
}
|
|
1082
|
-
|
|
1083
|
-
export function codexApprovalConfigArgs(approvalMode?: string): string[] {
|
|
1084
|
-
const sandboxMode = approvalMode === "open"
|
|
1085
|
-
? "danger-full-access"
|
|
1086
|
-
: approvalMode === "read-only"
|
|
1087
|
-
? "read-only"
|
|
1088
|
-
: "workspace-write";
|
|
1089
|
-
return [
|
|
1090
|
-
"-c",
|
|
1091
|
-
'approval_policy="never"',
|
|
1092
|
-
"-c",
|
|
1093
|
-
`sandbox_mode=${tomlString(sandboxMode)}`,
|
|
1094
|
-
];
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
|
-
// #1248 — default background-terminal poll window (300000ms/5min) is too short for
|
|
1098
|
-
// our long-running commands (tests/releases run 5+ min), forcing agents to poll
|
|
1099
|
-
// instead of just waiting. Verified against the installed codex-cli binary (0.144.0):
|
|
1100
|
-
// `background_terminal_max_timeout` is a top-level (global) Config field — it replaced
|
|
1101
|
-
// the older `background_terminal_timeout` key, which no longer appears in that build.
|
|
1102
|
-
const CODEX_BACKGROUND_TERMINAL_MAX_TIMEOUT_MS = 900000;
|
|
1103
|
-
|
|
1104
|
-
export function codexManagedConfigArgs(): string[] {
|
|
1105
|
-
return [
|
|
1106
|
-
"-c",
|
|
1107
|
-
"check_for_update_on_startup=false",
|
|
1108
|
-
"-c",
|
|
1109
|
-
`background_terminal_max_timeout=${CODEX_BACKGROUND_TERMINAL_MAX_TIMEOUT_MS}`,
|
|
1110
|
-
];
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
|
-
export function isCodexCliCommand(command: string): boolean {
|
|
1114
|
-
return basename(command) === "codex";
|
|
1115
|
-
}
|
|
1116
|
-
|
|
1117
|
-
export function resolveRealCodexCommand(command: string, pathValue = process.env.PATH || ""): string {
|
|
1118
|
-
const candidates: string[] = [];
|
|
1119
|
-
if (command.includes("/")) candidates.push(command);
|
|
1120
|
-
for (const dir of pathValue.split(":").filter(Boolean)) candidates.push(join(dir, "codex"));
|
|
1121
|
-
|
|
1122
|
-
const seen = new Set<string>();
|
|
1123
|
-
for (const candidate of candidates) {
|
|
1124
|
-
if (seen.has(candidate)) continue;
|
|
1125
|
-
seen.add(candidate);
|
|
1126
|
-
if (!isExecutableFile(candidate)) continue;
|
|
1127
|
-
const real = realPath(candidate);
|
|
1128
|
-
if (isAgentRelayCodexShim(candidate) || isAgentRelayCodexShim(real)) continue;
|
|
1129
|
-
return candidate;
|
|
1130
|
-
}
|
|
1131
|
-
return command;
|
|
1132
|
-
}
|
|
1133
|
-
|
|
1134
|
-
function isExecutableFile(path: string): boolean {
|
|
1135
|
-
try {
|
|
1136
|
-
accessSync(path, constants.X_OK);
|
|
1137
|
-
return true;
|
|
1138
|
-
} catch {
|
|
1139
|
-
return false;
|
|
1140
|
-
}
|
|
1141
|
-
}
|
|
1142
|
-
|
|
1143
|
-
function realPath(path: string): string {
|
|
1144
|
-
try {
|
|
1145
|
-
return realpathSync(path);
|
|
1146
|
-
} catch {
|
|
1147
|
-
return path;
|
|
1148
|
-
}
|
|
1149
|
-
}
|
|
1150
|
-
|
|
1151
|
-
function isAgentRelayCodexShim(path: string): boolean {
|
|
1152
|
-
return path.includes("/.agent-relay/codex/bin/codex");
|
|
1153
|
-
}
|
|
1154
|
-
|
|
1155
1087
|
// #557 — bundledCodexSkillDirs + bundledSkillConfigArgs now live in ../launch-assembly
|
|
1156
1088
|
// (the single launch-arg home, shared with the assembler); re-exported so existing
|
|
1157
1089
|
// codex.ts consumers + tests keep their import path. tomlString likewise from ../relay-mcp.
|
|
@@ -1161,41 +1093,6 @@ export { tomlString };
|
|
|
1161
1093
|
// so codex.ts consumers + tests keep their import path.
|
|
1162
1094
|
export { codexTokenUsageContext, codexRolloutContextFromText } from "./codex-rollout-context";
|
|
1163
1095
|
|
|
1164
|
-
export function codexAppServerConfigArgs(...argLists: string[][]): string[] {
|
|
1165
|
-
const result: string[] = [];
|
|
1166
|
-
for (const args of argLists) {
|
|
1167
|
-
for (let i = 0; i < args.length; i++) {
|
|
1168
|
-
const arg = args[i];
|
|
1169
|
-
if (arg === "-c" || arg === "--config" || arg === "--enable" || arg === "--disable") {
|
|
1170
|
-
const value = args[i + 1];
|
|
1171
|
-
if (value !== undefined) {
|
|
1172
|
-
result.push(arg, value);
|
|
1173
|
-
i++;
|
|
1174
|
-
}
|
|
1175
|
-
} else if (arg?.startsWith("--config=") || arg?.startsWith("--enable=") || arg?.startsWith("--disable=")) {
|
|
1176
|
-
result.push(arg);
|
|
1177
|
-
}
|
|
1178
|
-
}
|
|
1179
|
-
}
|
|
1180
|
-
return result;
|
|
1181
|
-
}
|
|
1182
|
-
|
|
1183
|
-
export function codexToolOutputTokenLimitConfigArgs(config: Pick<RunnerSpawnConfig, "agentProfile">): string[] {
|
|
1184
|
-
const configured = codexToolOutputTokenLimit(config.agentProfile?.providerOptions);
|
|
1185
|
-
if (configured === null) return [];
|
|
1186
|
-
const limit = configured ?? DEFAULT_CODEX_TOOL_OUTPUT_TOKEN_LIMIT;
|
|
1187
|
-
return ["-c", `tool_output_token_limit=${limit}`];
|
|
1188
|
-
}
|
|
1189
|
-
|
|
1190
|
-
function codexToolOutputTokenLimit(providerOptions: unknown): number | null | undefined {
|
|
1191
|
-
if (!isRecord(providerOptions)) return undefined;
|
|
1192
|
-
const codex = providerOptions.codex;
|
|
1193
|
-
if (!isRecord(codex)) return undefined;
|
|
1194
|
-
const limit = codex.toolOutputTokenLimit;
|
|
1195
|
-
return typeof limit === "number" || limit === null ? limit : undefined;
|
|
1196
|
-
}
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
1096
|
async function connectWithRetry(client: CodexAppClient, attempts = 40): Promise<void> {
|
|
1200
1097
|
// Give the freshly-spawned app-server a moment to bind its socket before the
|
|
1201
1098
|
// first attempt, so we don't spend the retry budget on guaranteed refusals.
|
package/src/runner-core.ts
CHANGED
|
@@ -1053,8 +1053,10 @@ export class AgentRunner {
|
|
|
1053
1053
|
return;
|
|
1054
1054
|
}
|
|
1055
1055
|
|
|
1056
|
+
let restartResumeId: string | undefined;
|
|
1056
1057
|
if (this.shouldStopUnexpectedProviderExit(diagnostics)) {
|
|
1057
|
-
const
|
|
1058
|
+
const resumeId = providerExitResumeId(diagnostics);
|
|
1059
|
+
const hasResumeId = resumeId !== undefined;
|
|
1058
1060
|
if (!hasResumeId) {
|
|
1059
1061
|
const recoveredStatus = await this.recoveredStatusAfterUndeterminedProviderExit();
|
|
1060
1062
|
if (recoveredStatus) {
|
|
@@ -1081,6 +1083,17 @@ export class AgentRunner {
|
|
|
1081
1083
|
}
|
|
1082
1084
|
if (!hasResumeId && this.shouldRestartRecoverableProviderExit(diagnostics)) {
|
|
1083
1085
|
logger.warn("lifecycle", `${this.options.provider} exit signal is early/recoverable; allowing fresh restart`);
|
|
1086
|
+
} else if (resumeId && this.shouldResumeWithContextProviderExit(diagnostics, recent.length)) {
|
|
1087
|
+
// #1258 (L2): a long-lived agent killed via tmux-session-ended (external SIGTERM, reboot,
|
|
1088
|
+
// OOM, admin cleanup) used to be stranded permanently offline even though the provider CLI printed
|
|
1089
|
+
// a resumable session id. Now, when Layer 3 captured a valid resume id, auto-resume WITH
|
|
1090
|
+
// context instead of surfacing manual recovery. Crash-loop protection reuses the existing
|
|
1091
|
+
// unexpected-exit window: `recent` is capped at MAX_RAPID_UNEXPECTED_EXITS within
|
|
1092
|
+
// UNEXPECTED_EXIT_WINDOW_MS, and the fall-through path below applies backoff + a hard
|
|
1093
|
+
// give-up — so a genuinely broken agent stops resurrecting once the window fills, while a
|
|
1094
|
+
// resume that survives past the window resets the counter (no permanent lockout).
|
|
1095
|
+
restartResumeId = resumeId;
|
|
1096
|
+
logger.warn("lifecycle", `${this.options.provider} exited via tmux; auto-resuming with captured context (resume id ${resumeId})`);
|
|
1084
1097
|
} else {
|
|
1085
1098
|
logger.warn("lifecycle", `${this.options.provider} exited; leaving agent offline for manual recovery`);
|
|
1086
1099
|
this.publishRunnerTimelineEvent({
|
|
@@ -1141,18 +1154,21 @@ export class AgentRunner {
|
|
|
1141
1154
|
}
|
|
1142
1155
|
|
|
1143
1156
|
const delayMs = Math.min(10_000, Math.max(500, 500 * recent.length));
|
|
1144
|
-
|
|
1157
|
+
const resuming = restartResumeId !== undefined;
|
|
1158
|
+
logger.warn("lifecycle", `provider session exited unexpectedly after ${Math.round(runtimeMs / 1000)}s; ${resuming ? "resuming with context" : "restarting"} in ${delayMs}ms`);
|
|
1145
1159
|
this.publishRunnerTimelineEvent({
|
|
1146
1160
|
status: "provider.restart_decision",
|
|
1147
1161
|
id: `provider-restart-decision-${this.providerSessionId}-${now}`,
|
|
1148
1162
|
timestamp: Date.now(),
|
|
1149
1163
|
title: "Provider restart scheduled",
|
|
1150
|
-
body:
|
|
1164
|
+
body: resuming
|
|
1165
|
+
? `runner will resume ${this.options.provider} with captured context in ${delayMs}ms`
|
|
1166
|
+
: `runner will start a fresh ${this.options.provider} provider in ${delayMs}ms`,
|
|
1151
1167
|
icon: "ti-refresh",
|
|
1152
1168
|
metadata: {
|
|
1153
1169
|
eventType: "provider.restart_decision",
|
|
1154
|
-
decision: "restart-fresh",
|
|
1155
|
-
reason: "unexpected-headless-terminal-exit",
|
|
1170
|
+
decision: resuming ? "resume-with-context" : "restart-fresh",
|
|
1171
|
+
reason: resuming ? "tmux-exit-auto-resume" : "unexpected-headless-terminal-exit",
|
|
1156
1172
|
delayMs,
|
|
1157
1173
|
rapidExitCount: recent.length,
|
|
1158
1174
|
rapidExitWindowMs: UNEXPECTED_EXIT_WINDOW_MS,
|
|
@@ -1162,7 +1178,7 @@ export class AgentRunner {
|
|
|
1162
1178
|
await Bun.sleep(delayMs);
|
|
1163
1179
|
if (this.stopped || this.exitCommandInProgress) return;
|
|
1164
1180
|
try {
|
|
1165
|
-
await this.restartProvider();
|
|
1181
|
+
await this.restartProvider(restartResumeId ? { resumeId: restartResumeId } : undefined);
|
|
1166
1182
|
this.publishStatus();
|
|
1167
1183
|
this.scheduleDrain();
|
|
1168
1184
|
} catch (error) {
|
|
@@ -1186,6 +1202,15 @@ export class AgentRunner {
|
|
|
1186
1202
|
&& diagnostics.runtimeMs < RAPID_EXIT_MS;
|
|
1187
1203
|
}
|
|
1188
1204
|
|
|
1205
|
+
// #1258 (L2): auto-resume-with-context gate for a tmux-session-ended death at any point in an
|
|
1206
|
+
// agent's life (not just the <30s early-flake window). Requires a captured resume id (the caller
|
|
1207
|
+
// only reaches this when one exists) and bounds crash loops via the shared unexpected-exit window:
|
|
1208
|
+
// once `recent` exceeds MAX_RAPID_UNEXPECTED_EXITS the agent is left offline for manual recovery.
|
|
1209
|
+
private shouldResumeWithContextProviderExit(diagnostics: Record<string, unknown>, recentUnexpectedExits: number): boolean {
|
|
1210
|
+
return diagnostics.exitSource === "tmux-session-ended"
|
|
1211
|
+
&& recentUnexpectedExits <= MAX_RAPID_UNEXPECTED_EXITS;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1189
1214
|
private async recoveredStatusAfterUndeterminedProviderExit(): Promise<"busy" | "idle" | undefined> {
|
|
1190
1215
|
if (!this.process || !this.options.adapter.probeActivity) return undefined;
|
|
1191
1216
|
const deadline = Date.now() + UNDETERMINED_PROVIDER_EXIT_CONFIRM_MS;
|
package/src/runner-helpers.ts
CHANGED
|
@@ -168,6 +168,17 @@ export function runnerShouldRestartUnexpectedProviderExit(
|
|
|
168
168
|
&& input.hasTerminalSession;
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
// #1258 (L3): claude prints its resume banner ANSI-dimmed — the raw log bytes are
|
|
172
|
+
// `\x1b[2mclaude --resume <uuid>\x1b[22m`, so `claude` is immediately preceded by the SGR
|
|
173
|
+
// escape's trailing `m`. A leading `\b` in the extraction regex finds no word boundary
|
|
174
|
+
// (`m`→`c` are both word chars) and yields zero matches, stranding the resume id as null.
|
|
175
|
+
// Strip CSI escape sequences before matching so the banner reads as plain text.
|
|
176
|
+
const ANSI_ESCAPE_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
177
|
+
|
|
178
|
+
function stripAnsi(text: string): string {
|
|
179
|
+
return text.replace(ANSI_ESCAPE_RE, "");
|
|
180
|
+
}
|
|
181
|
+
|
|
171
182
|
function resumeLogExtractionRe(provider: string): RegExp | undefined {
|
|
172
183
|
const manifest = getManifest(provider);
|
|
173
184
|
const resume = manifest?.resume as { supported: boolean; idPattern?: string } | undefined;
|
|
@@ -178,8 +189,9 @@ function resumeLogExtractionRe(provider: string): RegExp | undefined {
|
|
|
178
189
|
export function latestResumeIdFromText(provider: string, text: string): string | undefined {
|
|
179
190
|
const re = resumeLogExtractionRe(provider);
|
|
180
191
|
if (!re) return undefined;
|
|
192
|
+
const cleaned = stripAnsi(text);
|
|
181
193
|
let latest: string | undefined;
|
|
182
|
-
for (let match = re.exec(
|
|
194
|
+
for (let match = re.exec(cleaned); match; match = re.exec(cleaned)) {
|
|
183
195
|
latest = match[1];
|
|
184
196
|
}
|
|
185
197
|
return latest;
|