agent-relay-runner 0.129.14 → 0.129.15

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.14",
3
+ "version": "0.129.15",
4
4
  "description": "Unified provider lifecycle runner for Agent Relay",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,7 +23,7 @@
23
23
  },
24
24
  "dependencies": {
25
25
  "agent-relay-providers": "0.104.6",
26
- "agent-relay-sdk": "0.2.130",
26
+ "agent-relay-sdk": "0.2.131",
27
27
  "callmux": "0.24.2"
28
28
  },
29
29
  "devDependencies": {
@@ -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.14",
4
+ "version": "0.129.15",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
package/src/adapter.ts CHANGED
@@ -260,6 +260,16 @@ export interface ManagedProcess {
260
260
  meta?: Record<string, unknown>;
261
261
  }
262
262
 
263
+ /**
264
+ * #1746 — the terminal verdict of a provider shutdown. `killed` means the provider
265
+ * process (tmux pane / pid tree) is POSITIVELY confirmed gone; `failed-to-kill` means it
266
+ * survived even a SIGKILL of its process group (near-impossible on a same-user Linux host,
267
+ * so it is the operator-visible safety surface, not the happy path). The runner uses this
268
+ * to gate deregistration: a roster removal must imply the process is dead (#1746 ask 2), so
269
+ * a `failed-to-kill` keeps the agent visible rather than turning it into a silent zombie.
270
+ */
271
+ export type ShutdownOutcome = "killed" | "failed-to-kill";
272
+
263
273
  export interface TerminalAttachSpec {
264
274
  mode: "guest";
265
275
  provider: string;
@@ -302,7 +312,10 @@ export interface ProviderAdapter {
302
312
  stopObligationPath?: string;
303
313
  permissionPromptHandler?: ProviderPermissionPromptHandler;
304
314
  spawn(config: RunnerSpawnConfig): Promise<ManagedProcess>;
305
- shutdown(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<void>;
315
+ // #1746 resolves once the provider is terminal. Returns the outcome so the runner can
316
+ // gate deregistration on confirmed death; `void` from a legacy adapter is treated as
317
+ // `killed` (best-effort back-compat), but both first-party adapters now report explicitly.
318
+ shutdown(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<ShutdownOutcome | void>;
306
319
  compact?(process: ManagedProcess, opts?: { instructions?: string }): Promise<Record<string, unknown> | void>;
307
320
  compactSupportsInstructions?: boolean;
308
321
  clearContext?(process: ManagedProcess): Promise<Record<string, unknown> | void>;
@@ -14,7 +14,7 @@
14
14
  // home the runner can't resolve). Schema is reverse-engineered (pbauermeister/
15
15
  // claude-busy-monitor), so we gate on `version`/presence and degrade gracefully if
16
16
  // Anthropic ever drifts it.
17
- import { readFileSync } from "node:fs";
17
+ import { readFileSync, statSync } from "node:fs";
18
18
  import { homedir } from "node:os";
19
19
  import { join } from "node:path";
20
20
  import { isPidAlive } from "agent-relay-sdk/process-utils";
@@ -51,9 +51,18 @@ export function claudeProbeBusyIsStale(
51
51
  reading: ClaudeSessionReading,
52
52
  nowMs: number = Date.now(),
53
53
  thresholdMs: number = claudeProbeBusyStaleMsFromEnv(),
54
+ lastActivityAt: number | null = null,
54
55
  ): boolean {
55
56
  if (!reading.available || reading.updatedAt === null) return false;
56
- return nowMs - reading.updatedAt >= thresholdMs;
57
+ // #1642 age the busy verdict against the most recent OBSERVED activity, not the probe's own
58
+ // last write alone. Claude appends to its transcript on every turn event (tool_use / thinking /
59
+ // text) even when no status TRANSITION rewrites the probe, so a fresher transcript append is
60
+ // direct proof the turn is still producing work. With no activity signal supplied this collapses
61
+ // to the exact #1621 behavior (age against `updatedAt`).
62
+ const lastActivity = lastActivityAt !== null && lastActivityAt > reading.updatedAt
63
+ ? lastActivityAt
64
+ : reading.updatedAt;
65
+ return nowMs - lastActivity >= thresholdMs;
57
66
  }
58
67
 
59
68
  /**
@@ -65,19 +74,27 @@ export function claudeProbeBusyIsStale(
65
74
  export function claudeProbeVerdict(
66
75
  reading: ClaudeSessionReading | undefined,
67
76
  nowMs: number = Date.now(),
68
- opts: { paneInterruptAt?: number } = {},
77
+ opts: { paneInterruptAt?: number; lastActivityAt?: number } = {},
69
78
  ): { activity: ClaudeProbeActivity | undefined; busyStale: boolean } {
70
79
  const activity = reading?.available ? claudeProbeActivity(reading.status) : undefined;
71
80
  if (activity !== "busy" || !reading) return { activity, busyStale: false };
81
+ // #1642 — a transcript append AFTER a candidate cancellation is proof the turn is still live and
82
+ // defeats BOTH staleness paths below. An actively-streaming turn must never be handed to the pane
83
+ // for clearing just because the probe file went quiet between transitions. A genuine out-of-band
84
+ // cancel stops the event stream too, so the transcript also falls silent and the trust decay
85
+ // below still fires — this narrows the decay to exactly the false-idle case, nothing wider.
86
+ const activityAt = opts.lastActivityAt ?? null;
72
87
  // #1621 — the precise form of the same staleness question, for the case relay CAUSED: relay
73
88
  // typed a turn-cancelling keystroke into the pane (the prompt gate's Escape) and the probe has
74
89
  // not been rewritten since. That is not a guess about elapsed time — it is a busy assertion that
75
90
  // demonstrably predates the cancellation, so it stops being authoritative immediately instead of
76
- // after the whole trust window. The pane still has to corroborate the idle read either way.
91
+ // after the whole trust window. The pane still has to corroborate the idle read either way. A
92
+ // transcript append after the interrupt (#1642) means the cancel did not take, so it is honored.
77
93
  const predatesInterrupt = opts.paneInterruptAt !== undefined
78
94
  && reading.updatedAt !== null
79
- && reading.updatedAt <= opts.paneInterruptAt;
80
- return { activity, busyStale: predatesInterrupt || claudeProbeBusyIsStale(reading, nowMs) };
95
+ && reading.updatedAt <= opts.paneInterruptAt
96
+ && !(activityAt !== null && activityAt > opts.paneInterruptAt);
97
+ return { activity, busyStale: predatesInterrupt || claudeProbeBusyIsStale(reading, nowMs, undefined, activityAt) };
81
98
  }
82
99
 
83
100
  export interface ClaudeSessionReading {
@@ -274,6 +291,27 @@ export function claudeTranscriptPath(configHome: string, cwd: string, sessionId:
274
291
  return join(configHome, "projects", cwd.replace(/[^a-zA-Z0-9]/g, "-"), `${sessionId}.jsonl`);
275
292
  }
276
293
 
294
+ /**
295
+ * #1642 — the transcript's last-modified time in epoch-ms, or undefined when it can't be read
296
+ * (absent/old probe with no sessionId+cwd, or the file not yet created). Claude appends to this
297
+ * JSONL on every turn event, so its mtime is the runner's hookless "events still flowing" heartbeat
298
+ * — the signal that keeps an actively-streaming turn's frozen busy probe from being aged out to the
299
+ * pane. A failed stat is silence, never a claim the turn ended: undefined leaves #1621 untouched.
300
+ */
301
+ export function claudeTranscriptMtimeMs(
302
+ reading: Pick<ClaudeSessionReading, "sessionId" | "cwd"> | undefined,
303
+ configHome: string | undefined,
304
+ ): number | undefined {
305
+ const sessionId = reading?.sessionId;
306
+ const cwd = reading?.cwd;
307
+ if (!sessionId || !cwd || !configHome) return undefined;
308
+ try {
309
+ return statSync(claudeTranscriptPath(configHome, cwd, sessionId)).mtimeMs;
310
+ } catch {
311
+ return undefined;
312
+ }
313
+ }
314
+
277
315
  /**
278
316
  * Resolve the live transcript path for the managed claude via its session probe (#1235):
279
317
  * the probe carries `sessionId` + `cwd`, and the runner knows the config home. Returns
@@ -4,8 +4,9 @@
4
4
  //
5
5
  // The policy itself lives in ./claude-exit. This module is the caller that samples
6
6
  // live state and drives the loop.
7
- import { tmuxCommand, tmuxHasSession } from "agent-relay-sdk/tmux-utils";
8
- import type { ManagedProcess } from "../adapter";
7
+ import { tmuxCommand, tmuxHasSession, tmuxPanePid } from "agent-relay-sdk/tmux-utils";
8
+ import { isPidAlive, killProcessGroup } from "agent-relay-sdk/process-utils";
9
+ import type { ManagedProcess, ShutdownOutcome } from "../adapter";
9
10
  import { readManagedClaudeStatus } from "./claude-session-probe";
10
11
  import { claudePaneIsBusy } from "./claude-status-detectors";
11
12
  import { captureTmuxPane } from "./claude-tmux";
@@ -69,6 +70,13 @@ export function observeClaudeExitState(
69
70
  };
70
71
  }
71
72
 
73
+ // #1746 — the graceful window before the force-kill fallback escalates a signal. Two steps
74
+ // (SIGTERM→SIGKILL) of this each; short because by the time we reap, the #1647 graceful
75
+ // `/exit` window has already been given and declined.
76
+ export const CLAUDE_REAP_ESCALATION_STEP_MS = 1_500;
77
+ // Poll cadence while confirming the pane process is gone after a signal.
78
+ const CLAUDE_REAP_CONFIRM_POLL_MS = 100;
79
+
72
80
  // Injectable seams so the shutdown loop (which otherwise only talks to a live tmux
73
81
  // server) is unit-testable — same pattern as ClaudeClearCommitDeps.
74
82
  export interface ClaudeShutdownDeps {
@@ -76,8 +84,19 @@ export interface ClaudeShutdownDeps {
76
84
  observe?: (process: ManagedProcess, sessionName: string, socketName?: string) => ClaudeExitObservation;
77
85
  submitExit?: (grant: ClaudeExitGrant) => Promise<unknown>;
78
86
  confirmExit?: (grant: ClaudeExitGrant) => unknown;
79
- reap?: (sessionName: string, socketName?: string) => void;
87
+ // #1746 — the reap now returns a terminal outcome (it verifies death, it does not merely
88
+ // fire a kill-session). Tests that only care that the reap ran may still return void.
89
+ reap?: (sessionName: string, socketName?: string) => ShutdownOutcome | void | Promise<ShutdownOutcome | void>;
80
90
  sleep?: (ms: number) => Promise<unknown>;
91
+ // #1746 — seams for the force-kill fallback so the escalation is unit-testable without a
92
+ // real tmux server or live pids.
93
+ panePid?: (sessionName: string, socketName?: string) => number | undefined;
94
+ killSession?: (sessionName: string, socketName?: string) => void;
95
+ isAlive?: (pid: number) => boolean;
96
+ killGroup?: (pid: number, signal: "SIGTERM" | "SIGKILL") => void;
97
+ // Per-escalation-step confirm window; defaults to CLAUDE_REAP_ESCALATION_STEP_MS. Tests
98
+ // shrink it so the SIGTERM→SIGKILL escalation runs in milliseconds, not seconds.
99
+ reapStepMs?: number;
81
100
  }
82
101
 
83
102
  export async function shutdownClaudeTmux(
@@ -87,7 +106,7 @@ export async function shutdownClaudeTmux(
87
106
  socketName: string | undefined,
88
107
  fallbackEpoch: number,
89
108
  deps: ClaudeShutdownDeps = {},
90
- ): Promise<void> {
109
+ ): Promise<ShutdownOutcome> {
91
110
  const hasSession = deps.hasSession ?? tmuxHasSession;
92
111
  const observe = deps.observe ?? ((p, s, k) => observeClaudeExitState(p, s, k, fallbackEpoch));
93
112
  const submitExit = deps.submitExit ?? submitClaudeExitCommand;
@@ -110,7 +129,9 @@ export async function shutdownClaudeTmux(
110
129
  let grant: ClaudeExitGrant | undefined;
111
130
  const deadline = Date.now() + claudeShutdownGraceMs(opts.timeoutMs);
112
131
  while (Date.now() < deadline) {
113
- if (!hasSession(sessionName, socketName)) return;
132
+ // The session vanished on its own — the graceful `/exit` (or the agent's own exit)
133
+ // took. That IS a confirmed-terminal shutdown; no force-kill needed. (#1746)
134
+ if (!hasSession(sessionName, socketName)) return "killed";
114
135
  if (!grant) {
115
136
  // #1647 — the guard that would have saved the coordinator. A turn that is live
116
137
  // (or that we cannot positively confirm has ended) authorizes nothing, so we
@@ -138,11 +159,70 @@ export async function shutdownClaudeTmux(
138
159
 
139
160
  // The out-of-band backstop, and the path #1647 prefers: it terminates the process
140
161
  // without typing anything into the pane, so it is always reached when the guard above
141
- // declines to authorize a graceful exit.
142
- (deps.reap ?? reapTmuxSession)(sessionName, socketName);
162
+ // declines to authorize a graceful exit. #1746 — this is now a VERIFIED, escalating
163
+ // force-kill that returns a terminal outcome, not a fire-and-forget kill-session.
164
+ const outcome = await (deps.reap ?? ((s, k) => terminateClaudeTmux(s, k, deps)))(sessionName, socketName);
165
+ return outcome ?? "killed";
166
+ }
167
+
168
+ /**
169
+ * #1746 — the guaranteed-terminal force-kill fallback for a Claude tmux session. The old
170
+ * `reapTmuxSession` fired a single `kill-session` and trusted it; a wedged pane (parked at
171
+ * the "Exit anyway?" dialog) or a hiccuping tmux server would silently no-op and leave the
172
+ * agent fully alive while shutdown reported success — the #1746 zombie. This resolves the
173
+ * pane's OS pid up front, then:
174
+ * 1. asks tmux to kill the session (the clean path) and verifies the pane pid is gone,
175
+ * 2. on survival, SIGTERM the pane's PROCESS GROUP (covers the pane's foreground tree),
176
+ * 3. still alive → SIGKILL the group, then kill-session again to drop the empty shell,
177
+ * confirming death by RE-OBSERVING pid liveness + session presence after each step. Returns
178
+ * `killed` only on positive proof the process is gone; `failed-to-kill` if it somehow
179
+ * survives SIGKILL (so the runner keeps it visible rather than deregistering a live zombie).
180
+ */
181
+ export async function terminateClaudeTmux(
182
+ sessionName: string,
183
+ socketName: string | undefined,
184
+ deps: ClaudeShutdownDeps = {},
185
+ ): Promise<ShutdownOutcome> {
186
+ const hasSession = deps.hasSession ?? tmuxHasSession;
187
+ const killSession = deps.killSession ?? defaultKillSession;
188
+ const isAlive = deps.isAlive ?? isPidAlive;
189
+ const killGroup = deps.killGroup ?? killProcessGroup;
190
+ const sleep = deps.sleep ?? Bun.sleep;
191
+ const stepMs = deps.reapStepMs ?? CLAUDE_REAP_ESCALATION_STEP_MS;
192
+ // Resolve BEFORE the first kill, while the session still exists to report it.
193
+ const panePid = (deps.panePid ?? tmuxPanePid)(sessionName, socketName);
194
+
195
+ // Terminal iff the actual pane PROCESS is gone. When we resolved a pane pid, that pid is the
196
+ // authority — a lingering empty tmux session shell with a dead pane process is harmless and
197
+ // must not read as "failed-to-kill". Only when no pid is resolvable do we fall back to the
198
+ // session's presence as the sole liveness signal.
199
+ const dead = () => panePid !== undefined ? !isAlive(panePid) : !hasSession(sessionName, socketName);
200
+ const settle = async (): Promise<boolean> => {
201
+ const deadline = Date.now() + stepMs;
202
+ while (Date.now() < deadline) {
203
+ if (dead()) return true;
204
+ await sleep(CLAUDE_REAP_CONFIRM_POLL_MS);
205
+ }
206
+ return dead();
207
+ };
208
+
209
+ // 1. The clean tear-down.
210
+ killSession(sessionName, socketName);
211
+ if (await settle()) return "killed";
212
+
213
+ // 2/3. kill-session did not take it down. Escalate on the pane's process group.
214
+ if (panePid !== undefined) {
215
+ killGroup(panePid, "SIGTERM");
216
+ if (await settle()) return "killed";
217
+ killGroup(panePid, "SIGKILL");
218
+ killSession(sessionName, socketName); // drop the now-empty session shell
219
+ if (await settle()) return "killed";
220
+ }
221
+
222
+ return dead() ? "killed" : "failed-to-kill";
143
223
  }
144
224
 
145
- function reapTmuxSession(sessionName: string, socketName?: string): void {
225
+ function defaultKillSession(sessionName: string, socketName?: string): void {
146
226
  Bun.spawnSync(tmuxCommand(socketName, "kill-session", "-t", sessionName), {
147
227
  stdin: "ignore", stdout: "ignore", stderr: "ignore",
148
228
  });
@@ -4,14 +4,15 @@ import { delimiter, join, resolve } from "node:path";
4
4
  import { type Message } from "agent-relay-sdk";
5
5
  import { shellEscape as shellQuote } from "agent-relay-sdk/shell-utils";
6
6
  import { tmuxCommand, tmuxHasSession } from "agent-relay-sdk/tmux-utils";
7
+ import { isPidAlive } from "agent-relay-sdk/process-utils";
7
8
  import { runtimePath } from "agent-relay-sdk/runtime-prefix";
8
- import { profileAllowsRelayFeature, type ManagedProcess, type PaneBusyReading, type PaneObservation, type ProviderAdapter, type ProviderConfig, type ProviderStatusUpdate, type RunnerSpawnConfig, type SemanticStatus, type SpawnArgs } from "../adapter";
9
+ import { profileAllowsRelayFeature, type ManagedProcess, type PaneBusyReading, type PaneObservation, type ProviderAdapter, type ProviderConfig, type ProviderStatusUpdate, type RunnerSpawnConfig, type SemanticStatus, type ShutdownOutcome, type SpawnArgs } from "../adapter";
9
10
  import { computeLivenessSignal, type LivenessInputs } from "../liveness";
10
11
  import type { LivenessSignal } from "agent-relay-sdk";
11
12
  import { prepareClaudeProfileHome, profileUsesClaudeHostProviderGlobals } from "../profile-home";
12
13
  import { assembleLaunch, materializeLaunchAssembly, sessionStatusLineSettingsArgs, stripSettingsArgs } from "../launch-assembly";
13
14
  import { claudeProviderMessageText } from "./claude-delivery";
14
- import { claudeProbeVerdict, readManagedClaudeStatus, resolveClaudePid, resolveManagedClaudeTranscriptPath, type ClaudeProbeActivity } from "./claude-session-probe";
15
+ import { claudeProbeVerdict, claudeTranscriptMtimeMs, readManagedClaudeStatus, resolveClaudePid, resolveManagedClaudeTranscriptPath, type ClaudeProbeActivity } from "./claude-session-probe";
15
16
  import { evaluateClaudePromptGatePane, initialClaudePromptGatePaneState, type ClaudePromptGatePaneState } from "./claude-prompt-gates";
16
17
  import { claudeActivityFromProbeAndPane, claudePaneLooksReady, claudePaneIsBusy, claudeRateLimitStatus, claudeConnectionRetryObservation, claudeConnectionRetryClearedObservation, claudeModelUnavailableObservation, debounceClaudeModelUnavailableObservation, initialClaudeModelUnavailableObservationState } from "./claude-status-detectors";
17
18
  import { ClaudeSessionCapture, collectClaudeTranscriptArchiveSegment, collectClaudeTranscriptSessionEvents } from "./claude-session-capture";
@@ -103,14 +104,13 @@ export class ClaudeAdapter implements ProviderAdapter {
103
104
  return { pid: proc.pid, process: proc, meta: { monitor: config.monitor, env: args.env } };
104
105
  }
105
106
 
106
- async shutdown(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<void> {
107
+ async shutdown(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<ShutdownOutcome> {
107
108
  const tmuxSession = process.meta?.tmuxSession as string | undefined;
108
109
  const tmuxSocket = process.meta?.tmuxSocket as string | undefined;
109
110
  if (tmuxSession) {
110
- await this.shutdownTmux(process, tmuxSession, opts, tmuxSocket);
111
- return;
111
+ return this.shutdownTmux(process, tmuxSession, opts, tmuxSocket);
112
112
  }
113
- await terminateSingleProcess(process, opts);
113
+ return terminateSingleProcess(process, opts);
114
114
  }
115
115
 
116
116
  async compact(process: ManagedProcess, opts?: { instructions?: string }): Promise<Record<string, unknown>> {
@@ -209,7 +209,12 @@ export class ClaudeAdapter implements ProviderAdapter {
209
209
  // #1621: a busy probe that hasn't been rewritten inside its trust window may be describing a
210
210
  // turn that was already cancelled out of band (no transition → no rewrite). It keeps the fast
211
211
  // path (no pane capture) only while fresh; once stale it has to face the pane.
212
- const { activity: probe, busyStale } = claudeProbeVerdict(managed?.reading, Date.now(), { paneInterruptAt: this.paneInterruptAt });
212
+ // #1642: but a turn that is still appending to its transcript is demonstrably live — the probe
213
+ // freezes between status transitions while tool_use/thinking/text keep streaming. Feed the
214
+ // transcript mtime in as the "events still flowing" heartbeat so an actively-streaming turn is
215
+ // never aged out to the pane (where a missing spinner in the last 10 lines would false-idle it).
216
+ const lastActivityAt = claudeTranscriptMtimeMs(managed?.reading, managed?.configHome);
217
+ const { activity: probe, busyStale } = claudeProbeVerdict(managed?.reading, Date.now(), { paneInterruptAt: this.paneInterruptAt, lastActivityAt });
213
218
  if (probe === "busy" && !busyStale) return "busy";
214
219
  let pane: string | undefined;
215
220
  try { pane = captureTmuxPane(session, socket); } catch { return probe ?? "unknown"; }
@@ -570,13 +575,13 @@ export class ClaudeAdapter implements ProviderAdapter {
570
575
  opts: { graceful: boolean; timeoutMs: number },
571
576
  socketName?: string,
572
577
  deps: ClaudeShutdownDeps = {},
573
- ): Promise<void> {
578
+ ): Promise<ShutdownOutcome> {
574
579
  this.stopTurnWatch();
575
580
  if (this.tmuxWatcher) {
576
581
  clearInterval(this.tmuxWatcher);
577
582
  this.tmuxWatcher = undefined;
578
583
  }
579
- await shutdownClaudeTmux(process, sessionName, opts, socketName, this.providerEpoch, deps);
584
+ return shutdownClaudeTmux(process, sessionName, opts, socketName, this.providerEpoch, deps);
580
585
  }
581
586
  }
582
587
 
@@ -593,7 +598,7 @@ function messagesAreSelfResumeContinuation(messages: Message[]): boolean {
593
598
  // #1647 — the exit command and its two writers now live in ./claude-exit, behind the
594
599
  // one-shot grant. Re-exported so existing `./claude` consumers + tests keep their path.
595
600
  export { CLAUDE_EXIT_COMMAND } from "./claude-exit";
596
- export { claudeShutdownGraceMs, shutdownClaudeTmux, observeClaudeExitState, type ClaudeShutdownDeps } from "./claude-shutdown";
601
+ export { claudeShutdownGraceMs, shutdownClaudeTmux, terminateClaudeTmux, observeClaudeExitState, CLAUDE_REAP_ESCALATION_STEP_MS, type ClaudeShutdownDeps } from "./claude-shutdown";
597
602
  // tmux transport plumbing moved to ./claude-tmux (#300 ratchet); re-exported so
598
603
  // existing `./claude` consumers + tests keep their import paths.
599
604
  export { CLAUDE_TMUX_SUBMIT_DELAY_MS, CLAUDE_TMUX_SUBMIT_KEY, CLAUDE_TMUX_READY_TIMEOUT_MS, CLAUDE_CLEAR_COMMIT_POLL_MS, tmuxSessionName, tmuxSocketName, tmuxSocketPath, assertTmuxSocketPathFits, tmuxEnvKeys, waitForClaudeInputReady, waitForClaudeClearCommitted, submitTextToTmux } from "./claude-tmux";
@@ -675,13 +680,15 @@ export function findClaudeRigRC(cwd: string): string | null {
675
680
  }
676
681
  }
677
682
 
678
- async function terminateSingleProcess(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<void> {
683
+ async function terminateSingleProcess(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<ShutdownOutcome> {
679
684
  const proc = process.process;
680
- if (!proc) return;
685
+ if (!proc) return "killed"; // nothing to kill — vacuously terminal
686
+ const pid = proc.pid;
681
687
  try {
682
688
  proc.kill(opts.graceful ? "SIGTERM" : "SIGKILL");
683
689
  } catch {
684
- return;
690
+ // Already gone (ESRCH) or unsignalable — re-observe rather than trust the throw. (#1746)
691
+ return pid !== undefined && isPidAlive(pid) ? "failed-to-kill" : "killed";
685
692
  }
686
693
  const timeout = new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), opts.timeoutMs));
687
694
  const result = await Promise.race([proc.exited, timeout]);
@@ -691,4 +698,6 @@ async function terminateSingleProcess(process: ManagedProcess, opts: { graceful:
691
698
  } catch {}
692
699
  await proc.exited.catch(() => {});
693
700
  }
701
+ // Confirm death rather than assume the kill took (#1746).
702
+ return pid !== undefined && isPidAlive(pid) ? "failed-to-kill" : "killed";
694
703
  }
@@ -1,7 +1,7 @@
1
1
  import type { ContextState, InteractivePrompt, LivenessSignal, Message, MessageTurnEndReason, ProviderState, ToolCallCategory } from "agent-relay-sdk";
2
2
  import { isRecord, stringValue } from "agent-relay-sdk";
3
3
  import { isPidAlive, killPid, processTreePids, processTreePidsFromTable, waitForPidsExit } from "agent-relay-sdk/process-utils";
4
- import { profileAllowsRelayFeature, providerMessageText, relayContext, type ManagedProcess, type ProviderAdapter, type ProviderConfig, type ProviderPermissionDecisionInput, type ProviderSessionEvent, type ProviderStatusUpdate, type RunnerSpawnConfig, type SpawnArgs, type TerminalAttachSpec } from "../adapter";
4
+ import { profileAllowsRelayFeature, providerMessageText, relayContext, type ManagedProcess, type ProviderAdapter, type ProviderConfig, type ProviderPermissionDecisionInput, type ProviderSessionEvent, type ProviderStatusUpdate, type RunnerSpawnConfig, type ShutdownOutcome, type SpawnArgs, type TerminalAttachSpec } from "../adapter";
5
5
  import { workspaceDepsNoteFromEnv } from "../relay-instructions";
6
6
  import { CODEX_TURN_IN_PROGRESS_MESSAGE } from "../runner-helpers";
7
7
  import { tomlString } from "../relay-mcp";
@@ -242,10 +242,10 @@ export class CodexAdapter implements ProviderAdapter {
242
242
  return process;
243
243
  }
244
244
 
245
- async shutdown(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<void> {
245
+ async shutdown(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<ShutdownOutcome> {
246
246
  const client = process.meta?.client as CodexAppClient | undefined;
247
247
  client?.close();
248
- await terminateProcessTree(process, opts);
248
+ return terminateProcessTree(process, opts);
249
249
  }
250
250
 
251
251
  async compact(process: ManagedProcess): Promise<Record<string, unknown>> {
@@ -1170,33 +1170,29 @@ async function connectWithRetry(client: CodexAppClient, attempts = 40): Promise<
1170
1170
  throw lastError instanceof Error ? lastError : new Error(String(lastError));
1171
1171
  }
1172
1172
 
1173
- async function terminateProcessTree(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<void> {
1173
+ async function terminateProcessTree(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<ShutdownOutcome> {
1174
1174
  const processes = [
1175
1175
  process.meta?.tui as Bun.Subprocess | undefined,
1176
1176
  process.meta?.appServer as Bun.Subprocess | undefined,
1177
1177
  process.process,
1178
1178
  ].filter(Boolean) as Bun.Subprocess[];
1179
- if (processes.length === 0) return;
1179
+ if (processes.length === 0) return "killed"; // nothing to kill — vacuously terminal
1180
1180
  const rootPids = processes.map((proc) => proc.pid).filter((pid): pid is number => typeof pid === "number" && pid > 0);
1181
1181
  const pids = await processTreePids(rootPids).catch(() => rootPids);
1182
1182
  const signal = opts.graceful ? "SIGTERM" : "SIGKILL";
1183
- try {
1184
- for (const pid of pids) killPid(pid, signal);
1185
- } catch {
1186
- return;
1187
- }
1183
+ for (const pid of pids) killPid(pid, signal);
1188
1184
  const exited = await waitForPidsExit(pids, opts.timeoutMs);
1189
1185
  const alive = pids.filter(isPidAlive);
1190
1186
  if (!exited || alive.length > 0) {
1191
- try {
1192
- for (const pid of alive) killPid(pid, "SIGKILL");
1193
- } catch {}
1187
+ for (const pid of alive) killPid(pid, "SIGKILL");
1194
1188
  await waitForPidsExit(alive, 1_000);
1195
1189
  await Promise.race([
1196
1190
  Promise.all(processes.map((proc) => proc.exited.catch(() => {}))),
1197
1191
  Bun.sleep(1_000),
1198
1192
  ]);
1199
1193
  }
1194
+ // #1746 — report the terminal verdict by RE-OBSERVING, never by trusting the kill calls.
1195
+ return pids.some(isPidAlive) ? "failed-to-kill" : "killed";
1200
1196
  }
1201
1197
 
1202
1198
  function codexRelayContextEnabled(process: ManagedProcess): boolean {
@@ -7,7 +7,7 @@ import { contextStateFromProbeMetrics, quotaStateFromProbeMetrics } from "agent-
7
7
  import { isPidAlive } from "agent-relay-sdk/process-utils";
8
8
  import { providerAttachmentText, providerMessageText } from "./adapter";
9
9
  import { computeLivenessSignal, type LivenessInputs } from "./liveness";
10
- import type { ManagedProcess, PaneObservation, ProviderAdapter, ProviderConfig, ProviderPermissionDecision, ProviderPermissionDecisionInput, ProviderSessionEvent, ProviderSessionEventBatch, ProviderSessionTurnContext, ProviderSessionTurnInput, ProviderStatusUpdate, ProviderUserPromptInput, RunnerSpawnConfig, SemanticStatus, TerminalAttachSpec } from "./adapter";
10
+ import type { ManagedProcess, PaneObservation, ProviderAdapter, ProviderConfig, ProviderPermissionDecision, ProviderPermissionDecisionInput, ProviderSessionEvent, ProviderSessionEventBatch, ProviderSessionTurnContext, ProviderSessionTurnInput, ProviderStatusUpdate, ProviderUserPromptInput, RunnerSpawnConfig, SemanticStatus, ShutdownOutcome, TerminalAttachSpec } from "./adapter";
11
11
  import { messagesWithCachedAttachments } from "./attachment-cache";
12
12
  import { ClaimTracker } from "./claim-tracker";
13
13
  import { ProviderQuotaHarvest } from "./adapters/provider-quota-harvest";
@@ -871,6 +871,10 @@ export class AgentRunner {
871
871
 
872
872
  const exitAfterCommand = type === "agent.shutdown" || type === "agent.kill";
873
873
  if (exitAfterCommand) this.exitCommandInProgress = true;
874
+ // #1746 — the confirmed terminal outcome of an exit command's provider teardown. Stays
875
+ // undefined if shutdownProvider never ran or threw; only a positive `killed` authorizes
876
+ // deregistration (a roster removal must imply the process is gone).
877
+ let killOutcome: ShutdownOutcome | undefined;
874
878
  const nativeSelfResume = isNativeSelfResumeCompact(type, params)
875
879
  ? this.nativeSelfResume.begin(commandId, params)
876
880
  : undefined;
@@ -926,7 +930,28 @@ export class AgentRunner {
926
930
  providerResult = await this.respondToPermissionDecision(params);
927
931
  } else if (type === "prompt.inject") {
928
932
  providerResult = await this.injectPrompt(params, ticket);
929
- } else await this.shutdownProvider(type === "agent.kill", commandTimeoutMs(params));
933
+ } else {
934
+ try {
935
+ killOutcome = await this.shutdownProvider(type === "agent.kill", commandTimeoutMs(params));
936
+ } catch (shutdownError) {
937
+ // #1725 fix-forward — a throw INSIDE shutdownProvider (e.g. tmux missing from PATH ⇒
938
+ // defaultKillSession's Bun.spawnSync throws ENOENT) previously left killOutcome undefined
939
+ // and the "kill-failed" pill unset (that pill is set inside shutdownProvider, which never
940
+ // completed) — so the confirm-dead gate below deleted nothing AND published nothing, wedging
941
+ // the agent at "shutting-down" forever while the process exited anyway. Treat the throw
942
+ // exactly like a returned "failed-to-kill": surface the terminal pill and proceed, so the
943
+ // row reflects reality (a visible, re-targetable kill-failed) instead of an indefinite
944
+ // in-flight state, and the confirm-dead gate keeps it registered rather than deregistering
945
+ // a process whose death was never confirmed.
946
+ killOutcome = "failed-to-kill";
947
+ this.stopped = true;
948
+ this.claims.setTerminalStatus("offline");
949
+ this.lifecycleAction = "kill-failed";
950
+ this.publishStatus();
951
+ await this.bus.statusAsync({ agentStatus: "idle", ready: false, meta: { lifecycleAction: this.lifecycleAction, lifecycleActionAt: Date.now() } }).catch(() => {});
952
+ logger.error("lifecycle", `provider shutdown threw for ${this.agentId}: ${errMessage(shutdownError)}; treating as failed-to-kill and keeping agent registered`);
953
+ }
954
+ }
930
955
  await this.updateCommand(commandId, "succeeded", {
931
956
  action: type,
932
957
  agentId: this.agentId,
@@ -944,7 +969,12 @@ export class AgentRunner {
944
969
  if (exitAfterCommand) {
945
970
  if (params.preserveRegistration === true) {
946
971
  await this.http.setStatus(this.agentId, "offline", this.options.instanceId).catch(() => {});
947
- } else {
972
+ } else if (killOutcome === "killed") {
973
+ // #1746 — CONFIRM-DEAD-BEFORE-DEREGISTER. Only remove the roster row once the
974
+ // provider is positively confirmed gone. If the force-kill could not confirm death
975
+ // (killOutcome "failed-to-kill", or undefined because shutdownProvider threw), leave
976
+ // the agent registered so it stays visible (as "kill-failed") rather than becoming a
977
+ // silent zombie — a roster removal must imply the process is dead.
948
978
  await this.http.deleteAgent(this.agentId).catch(() => {});
949
979
  }
950
980
  if (this.options.exitProcessOnShutdown !== false) {
@@ -1403,19 +1433,29 @@ export class AgentRunner {
1403
1433
  return undefined;
1404
1434
  }
1405
1435
 
1406
- private async shutdownProvider(hard: boolean, timeoutMs = this.options.providerConfig.headless.shutdownTimeoutMs): Promise<void> {
1436
+ private async shutdownProvider(hard: boolean, timeoutMs = this.options.providerConfig.headless.shutdownTimeoutMs): Promise<ShutdownOutcome> {
1407
1437
  this.lifecycleAction = hard ? "killing" : "shutting-down";
1408
1438
  this.publishStatus();
1409
1439
  await this.bus.statusAsync({ agentStatus: "idle", ready: false, meta: { lifecycleAction: this.lifecycleAction, lifecycleActionAt: Date.now() } });
1440
+ // #1746 — no process to kill is vacuously terminal. Otherwise the adapter now returns a
1441
+ // VERIFIED terminal outcome (it re-observes liveness, it does not trust the kill call);
1442
+ // a legacy adapter that returns void is treated as `killed` for back-compat.
1443
+ let outcome: ShutdownOutcome = "killed";
1410
1444
  if (this.process) {
1411
- await this.options.adapter.shutdown(this.process, {
1412
- graceful: !hard,
1413
- timeoutMs,
1414
- });
1445
+ outcome = (await this.options.adapter.shutdown(this.process, { graceful: !hard, timeoutMs })) ?? "killed";
1415
1446
  }
1416
1447
  this.claims.setTerminalStatus("offline");
1417
- this.publishStatus();
1418
1448
  this.stopped = true;
1449
+ if (outcome === "failed-to-kill") {
1450
+ // Do NOT clear to a normal terminal state: surface the failure so the operator sees a
1451
+ // "kill-failed" pill instead of an indefinite "shutting-down", and so the confirm-dead
1452
+ // gate in handleCommand keeps the agent on the roster rather than deregistering a zombie.
1453
+ this.lifecycleAction = "kill-failed";
1454
+ await this.bus.statusAsync({ agentStatus: "idle", ready: false, meta: { lifecycleAction: this.lifecycleAction, lifecycleActionAt: Date.now() } }).catch(() => {});
1455
+ logger.error("lifecycle", `provider force-kill failed for ${this.agentId}: process survived SIGKILL; keeping agent registered`);
1456
+ }
1457
+ this.publishStatus();
1458
+ return outcome;
1419
1459
  }
1420
1460
 
1421
1461
  private publishRunnerTimelineEvent(event: RunnerTimelineEvent): void {
@@ -2,6 +2,11 @@ export type SessionDestroyReason = "compact" | "clear" | "restart" | "shutdown"
2
2
 
3
3
  export type LifecycleAction =
4
4
  | "shutting-down" | "killing" | "restarting"
5
+ // #1746 — a TERMINAL marker: the graceful+force-kill path ran and the provider process
6
+ // still could not be confirmed dead. The runner deliberately keeps the agent registered
7
+ // (a roster removal must imply the process is gone), so this pill replaces an indefinite
8
+ // "shutting-down" with an operator-visible "the kill did not take" state.
9
+ | "kill-failed"
5
10
  | `finalizing-${SessionDestroyReason}`;
6
11
 
7
12
  export const PRE_DESTROY_TIMEOUT_MS = 4_000;