agent-relay-runner 0.129.2 → 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
- "version": "0.129.2",
3
+ "version": "0.129.3",
4
4
  "description": "Unified provider lifecycle runner for Agent Relay",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
3
  "description": "Thin Agent Relay runner bridge for Claude Code",
4
- "version": "0.129.2",
4
+ "version": "0.129.3",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -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 hasResumeId = providerExitResumeId(diagnostics) !== undefined;
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
- logger.warn("lifecycle", `provider session exited unexpectedly after ${Math.round(runtimeMs / 1000)}s; restarting in ${delayMs}ms`);
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: `runner will start a fresh ${this.options.provider} provider in ${delayMs}ms`,
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;
@@ -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(text); match; match = re.exec(text)) {
194
+ for (let match = re.exec(cleaned); match; match = re.exec(cleaned)) {
183
195
  latest = match[1];
184
196
  }
185
197
  return latest;