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 +2 -2
- package/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/adapter.ts +14 -1
- package/src/adapters/claude-session-probe.ts +44 -6
- package/src/adapters/claude-shutdown.ts +88 -8
- package/src/adapters/claude.ts +22 -13
- package/src/adapters/codex.ts +9 -13
- package/src/runner-core.ts +49 -9
- package/src/session-destroy.ts +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-runner",
|
|
3
|
-
"version": "0.129.
|
|
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.
|
|
26
|
+
"agent-relay-sdk": "0.2.131",
|
|
27
27
|
"callmux": "0.24.2"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
});
|
package/src/adapters/claude.ts
CHANGED
|
@@ -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<
|
|
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
|
-
|
|
111
|
-
return;
|
|
111
|
+
return this.shutdownTmux(process, tmuxSession, opts, tmuxSocket);
|
|
112
112
|
}
|
|
113
|
-
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
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
|
}
|
package/src/adapters/codex.ts
CHANGED
|
@@ -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<
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
package/src/runner-core.ts
CHANGED
|
@@ -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
|
|
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<
|
|
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 {
|
package/src/session-destroy.ts
CHANGED
|
@@ -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;
|