agent-relay-runner 0.129.11 → 0.129.12

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.
@@ -1,4 +1,4 @@
1
- import type { ContextState, InteractivePrompt, LivenessSignal, Message, MessageTurnEndReason, ProviderState } from "agent-relay-sdk";
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
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";
@@ -79,6 +79,10 @@ export class CodexAdapter implements ProviderAdapter {
79
79
  // cap below keeps a runaway session from growing memory unbounded.
80
80
  private sessionEvents: SessionEvent[] = [];
81
81
  private static readonly SESSION_EVENTS_CAP = 50_000;
82
+ // #1570 SUB-A: how many relay deliveries this thread has received, so providerMessageText can
83
+ // gate the reply-reminder block to first-delivery + periodic instead of every message. Reset
84
+ // alongside the rest of the thread state (fresh process, or an explicit /clear-style thread reset).
85
+ private deliveryCount = 0;
82
86
 
83
87
  onStatusChange(cb: (status: ProviderStatusUpdate) => void): void {
84
88
  this.statusCb = cb;
@@ -108,6 +112,7 @@ export class CodexAdapter implements ProviderAdapter {
108
112
  this.subagentThreads.clear();
109
113
  this.pendingApprovals.clear();
110
114
  this.activeTurnId = undefined;
115
+ this.deliveryCount = 0;
111
116
  this.agentMessageCapture.reset();
112
117
  this.itemTextBuffers.clear();
113
118
  this.itemTextBufferTypes.clear();
@@ -217,8 +222,8 @@ export class CodexAdapter implements ProviderAdapter {
217
222
  exitReported = true;
218
223
  this.statusCb(status);
219
224
  };
220
- void appServer.exited.then((code) => reportExit(code === 0 ? "offline" : "error"));
221
- if (tui) void tui.exited.then((code) => reportExit(code === 0 ? "idle" : "error"));
225
+ void appServer.exited.then((code) => reportExit(code === 0 ? "offline" : "error")).catch(() => reportExit("error")).catch(() => {});
226
+ if (tui) void tui.exited.then((code) => reportExit(code === 0 ? "idle" : "error")).catch(() => reportExit("error")).catch(() => {});
222
227
  const process: ManagedProcess = {
223
228
  pid: tui?.pid ?? appServer.pid,
224
229
  process: appServer,
@@ -373,14 +378,24 @@ export class CodexAdapter implements ProviderAdapter {
373
378
  }
374
379
  throw new Error(CODEX_TURN_IN_PROGRESS_MESSAGE);
375
380
  }
376
- let text = [codexLaunchContext(process), providerMessageText(messages)].filter(Boolean).join("\n\n");
381
+ this.deliveryCount += 1;
382
+ let text = [codexLaunchContext(process), providerMessageText(messages, { deliveryCount: this.deliveryCount })].filter(Boolean).join("\n\n");
377
383
  if (codexRelayContextEnabled(process) && !process.meta?.relayContextSent) {
378
384
  text = codexRelayContextBlock() + "\n\n" + text;
379
385
  process.meta = { ...(process.meta ?? {}), relayContextSent: true };
380
386
  }
381
387
  logger.info("codex", codexDeliveryNotice(messages, threadId));
382
388
  const client = process.meta?.client as CodexAppClient;
383
- await client.turnStart(threadId, text);
389
+ const started = await client.turnStart(threadId, text);
390
+ // #1565 F1 — claim the turn from the RESPONSE, not from the later `turn/started` notification.
391
+ // The guard above is only as good as the state it reads: with the turn id arriving a few ms
392
+ // later over the event stream, a second delivery in that window passed the guard and issued a
393
+ // concurrent turn/start on the same thread, which the app-server silently drops — while its
394
+ // command still reported success, so nothing retried it. Claiming it here closes the window:
395
+ // the second payload is DEFERRED (the runner re-offers it when this turn ends) instead of
396
+ // being lost. A later `turn/started` overwrites this id; `turn/completed`, `turn/failed`,
397
+ // `turn/interrupted` and the 4s probeActivity sweep all clear it, so it cannot wedge.
398
+ this.ensureMainTurnActive(stringValue(isRecord(started?.turn) ? started.turn.id : undefined));
384
399
  }
385
400
 
386
401
  async respondToPermissionDecision(process: ManagedProcess, input: ProviderPermissionDecisionInput): Promise<Record<string, unknown>> {
@@ -609,7 +624,7 @@ export class CodexAdapter implements ProviderAdapter {
609
624
  if (codexItemFailed(item)) this.recordInsightEvent({ type: "tool_error" });
610
625
  // stepId = the app-server item id: the server upserts the running step emitted on
611
626
  // item/started into this completed state IN PLACE, so the tool persists as ONE row.
612
- this.sessionEventCb({ type: "tool", origin: "provider", body: tool.body, label: tool.label, status: "completed", ...(turnId ? { turnId } : {}), ...(itemId ? { stepId: itemId } : {}) });
627
+ this.sessionEventCb({ type: "tool", origin: "provider", body: tool.body, label: tool.label, category: codexToolCategory(type, item), status: "completed", ...(turnId ? { turnId } : {}), ...(itemId ? { stepId: itemId } : {}) });
613
628
  }
614
629
  if (itemId) this.itemTextBuffers.delete(itemId);
615
630
  if (itemId) this.itemTextBufferTypes.delete(itemId);
@@ -685,7 +700,7 @@ export class CodexAdapter implements ProviderAdapter {
685
700
  this.flushBufferedReasoning();
686
701
  const tool = codexToolSummary(type, item ?? params ?? {});
687
702
  // stepId = item id so the server upserts this running step → completed IN PLACE (one row).
688
- if (tool) this.sessionEventCb({ type: "tool", origin: "provider", body: tool.body, label: tool.label, status: "running", streaming: true, ...(turnId ? { turnId } : {}), ...(itemId ? { stepId: itemId } : {}) });
703
+ if (tool) this.sessionEventCb({ type: "tool", origin: "provider", body: tool.body, label: tool.label, category: codexToolCategory(type, item ?? params ?? {}), status: "running", streaming: true, ...(turnId ? { turnId } : {}), ...(itemId ? { stepId: itemId } : {}) });
689
704
  return;
690
705
  }
691
706
 
@@ -880,6 +895,24 @@ export function codexInsightToolName(type: string | undefined, item: Record<stri
880
895
  }
881
896
  }
882
897
 
898
+ // #1498: Codex app-server item types → the provider-independent category taxonomy. Codex has
899
+ // no MCP-tool-search or native read/ask-user-question item, so it simply never emits those
900
+ // categories; webSearch and any other/future item type fall to "generic" rather than being
901
+ // force-fit — the fallback exists precisely so nothing is hidden from the activity trace.
902
+ // dynamicToolCall is a distinct client-provided dynamic tool (not MCP) so it falls to the
903
+ // default generic case; collabAgentToolCall covers several collab ops (sendInput, resumeAgent,
904
+ // wait, closeAgent, ...) and only the spawnAgent op is actually an agent spawn.
905
+ export function codexToolCategory(type: string | undefined, item: Record<string, unknown>): ToolCallCategory {
906
+ switch (type) {
907
+ case "commandExecution": return "cli-bash";
908
+ case "fileChange": return "edit";
909
+ case "mcpToolCall": return "mcp-tool-call";
910
+ case "collabAgentToolCall": return stringValue(item.tool) === "spawnAgent" ? "agent-spawn" : "generic";
911
+ case "plan": return "plan";
912
+ default: return "generic";
913
+ }
914
+ }
915
+
883
916
  // Did a completed tool item fail? Mirrors Claude's tool_result is_error outcome proxy.
884
917
  export function codexItemFailed(item: Record<string, unknown>): boolean {
885
918
  if (stringValue(item.status) === "failed") return true;
@@ -920,7 +953,12 @@ export function codexToolSummary(type: string | undefined, item: Record<string,
920
953
  const detail = prompt || (targets ? `${targets} agent${targets === 1 ? "" : "s"}` : tool);
921
954
  return { label: `Collab/${tool}`, body: clip(detail) };
922
955
  }
923
- return null;
956
+ // reasoning/agentMessage/userMessage are handled earlier in the event stream and never reach
957
+ // here as a "tool" item — those genuinely aren't tool activity, so they stay null.
958
+ if (type === "reasoning" || type === "agentMessage" || type === "userMessage") return null;
959
+ // Any other/future Codex tool item type still gets a minimal summary rather than being
960
+ // dropped — the "nothing dropped, unknown→generic" guarantee must hold even here.
961
+ return { label: type ?? "Tool", body: clip(type ?? "tool") };
924
962
  }
925
963
 
926
964
  function codexApprovalMethod(method: string): boolean {
@@ -1,3 +1,6 @@
1
+ import { errMessage } from "agent-relay-sdk";
2
+ import type { PaneBusyReading } from "./adapter";
3
+
1
4
  type ProviderActivity = "busy" | "idle" | "unknown";
2
5
 
3
6
  const BUSY_RECONCILE_POLL_MS = 4_000;
@@ -14,9 +17,10 @@ interface BusyReconcilerDeps {
14
17
  hasProviderTurn(): boolean;
15
18
  canProbeActivity(): boolean;
16
19
  probeActivity(): Promise<ProviderActivity> | undefined;
17
- // #769 P1-A: optional independent pane-busy read consulted ONLY at the force-clear
18
- // moment. When it resolves true, a probe-`idle` must not clear the live turn.
19
- probePaneBusy?(): Promise<boolean> | undefined;
20
+ // #769 P1-A / #1656: optional independent pane-busy read. `busy` vetoes a clear; `not-busy`
21
+ // is the positive corroboration every counted idle tick requires; anything else is unknown
22
+ // and holds the turn. Omitted entirely by providers with no pane to read.
23
+ probePaneBusy?(): Promise<PaneBusyReading> | undefined;
20
24
  clearProviderTurn(reason: string): void;
21
25
  sessionDebug(message: string): void;
22
26
  }
@@ -33,7 +37,7 @@ export class BusyReconciler {
33
37
  if (this.timer || !this.deps.canProbeActivity()) return;
34
38
  this.idleStreak = 0;
35
39
  this.sawBusy = false;
36
- this.timer = setInterval(() => { void this.run(); }, BUSY_RECONCILE_POLL_MS);
40
+ this.timer = setInterval(() => this.runInBackground(), BUSY_RECONCILE_POLL_MS);
37
41
  }
38
42
 
39
43
  disarm(): void {
@@ -46,17 +50,36 @@ export class BusyReconciler {
46
50
  scheduleInterruptReconcile(): void {
47
51
  setTimeout(() => {
48
52
  if (this.deps.isStopped() || !this.deps.hasProcess()) return;
49
- void (async () => {
50
- if (this.deps.currentStatus() !== "busy" || this.deps.isProviderBlocked()) return;
51
- const probe = this.deps.probeActivity();
52
- let activity: ProviderActivity = "unknown";
53
- try { if (probe) activity = await probe; } catch { return; }
54
- this.deps.sessionDebug(`post-interrupt reconcile probe=${activity}`);
55
- if (activity === "idle") this.deps.clearProviderTurn("post-interrupt");
56
- })();
53
+ this.reconcileInterruptInBackground();
57
54
  }, INTERRUPT_RECONCILE_DELAY_MS);
58
55
  }
59
56
 
57
+ // These two calls run from timers, so their promises have no caller that can observe a
58
+ // rejection. In particular clearProviderTurn can synchronously throw while publishing its
59
+ // durable timeline event during teardown. Catch at this boundary so an expected race cannot
60
+ // become Bun's process-fatal unhandled rejection; retain the error in the session log.
61
+ private runInBackground(): void {
62
+ void this.run().catch((error) => this.reportBackgroundRejection("busy reconcile", error));
63
+ }
64
+
65
+ private reconcileInterruptInBackground(): void {
66
+ void this.reconcileInterrupt().catch((error) => this.reportBackgroundRejection("post-interrupt reconcile", error));
67
+ }
68
+
69
+ private reportBackgroundRejection(scope: string, error: unknown): void {
70
+ // Logging must not rethrow from the rejection handler and recreate the same unhandled path.
71
+ try { this.deps.sessionDebug(`${scope} failed: ${errMessage(error)}`); } catch {}
72
+ }
73
+
74
+ private async reconcileInterrupt(): Promise<void> {
75
+ if (this.deps.currentStatus() !== "busy" || this.deps.isProviderBlocked()) return;
76
+ const probe = this.deps.probeActivity();
77
+ let activity: ProviderActivity = "unknown";
78
+ try { if (probe) activity = await probe; } catch { return; }
79
+ this.deps.sessionDebug(`post-interrupt reconcile probe=${activity}`);
80
+ if (activity === "idle") this.deps.clearProviderTurn("post-interrupt");
81
+ }
82
+
60
83
  /** Await the same provider-turn-clear signal maintained by status events and the
61
84
  * interrupt backstop. Redirect uses this boundary before starting its next turn. */
62
85
  waitForProviderTurnClear(timeoutMs = PROVIDER_TURN_CLEAR_TIMEOUT_MS): Promise<void> {
@@ -93,34 +116,49 @@ export class BusyReconciler {
93
116
  this.disarm();
94
117
  return;
95
118
  }
119
+ // #1656: a probe we could not read is `unknown`, not `idle`. It breaks the streak
120
+ // exactly like any other non-idle reading — it must never be silently skipped past.
96
121
  let activity: ProviderActivity;
97
- try { activity = await probe; } catch { return; }
122
+ try { activity = await probe; } catch { activity = "unknown"; }
98
123
  if (activity === "busy") this.sawBusy = true;
99
124
  if (activity !== "idle") {
100
125
  this.idleStreak = 0;
101
126
  this.deps.sessionDebug(`reconcile probe=${activity} sawBusy=${this.sawBusy} streak=${this.idleStreak}`);
102
127
  return;
103
128
  }
129
+ // #769 P1-A / #1656: the probe alone does not get to end a turn. An independent pane read
130
+ // runs on EVERY idle tick — not just once at the force-clear moment — and the streak counts
131
+ // only ticks where it positively reported `not-busy`. That is the substance of #1656: the
132
+ // old counter measured "how long since I last saw busy", which a pane that cannot be read at
133
+ // all satisfies perfectly. It now measures accumulated positive evidence that the turn ended,
134
+ // and any tick that fails to produce such evidence resets it to zero.
135
+ const pane = await this.readPaneBusy();
136
+ if (pane === "busy") {
137
+ this.idleStreak = 0;
138
+ this.sawBusy = true;
139
+ this.deps.sessionDebug("reconcile probe=idle vetoed: pane still reads busy");
140
+ return;
141
+ }
142
+ if (pane === "no-session" || pane === "unreadable") {
143
+ this.idleStreak = 0;
144
+ this.deps.sessionDebug(`reconcile probe=idle but pane=${pane} — unknown, holding the turn`);
145
+ return;
146
+ }
104
147
  this.idleStreak += 1;
105
148
  const confirm = this.sawBusy ? BUSY_RECONCILE_IDLE_CONFIRM : BUSY_RECONCILE_IDLE_CONFIRM_NO_BUSY;
106
- this.deps.sessionDebug(`reconcile probe=idle sawBusy=${this.sawBusy} streak=${this.idleStreak}/${confirm}`);
149
+ this.deps.sessionDebug(`reconcile not-busy pane=${pane ?? "none"} sawBusy=${this.sawBusy} streak=${this.idleStreak}/${confirm}`);
107
150
  if (this.idleStreak < confirm) return;
108
- // #769 P1-A: before force-clearing, give an independent pane-busy read a veto.
109
- // If the pane spinner still says busy, the probe-`idle` is untrustworthy (e.g. a
110
- // connection retry wrote status:idle mid-turn) — hold the turn instead of clearing.
111
- // Symmetric defense-in-depth: only clear when BOTH sources agree the turn is done.
112
- const paneBusy = this.deps.probePaneBusy?.();
113
- if (paneBusy) {
114
- let stillBusy = false;
115
- try { stillBusy = (await paneBusy) === true; } catch { stillBusy = false; }
116
- if (stillBusy) {
117
- this.idleStreak = 0;
118
- this.sawBusy = true;
119
- this.deps.sessionDebug("reconcile clear vetoed: pane still reads busy");
120
- return;
121
- }
122
- }
123
151
  this.disarm();
124
152
  this.deps.clearProviderTurn(this.sawBusy ? "backstop reconciler" : "backstop reconciler (no-busy-observed)");
125
153
  }
154
+
155
+ /** The pane half of one tick. `undefined` means the provider wired no pane read at all (no
156
+ * pane exists to consult) — distinct from a pane that exists and could not be read, which is
157
+ * `unreadable` and blocks. A dep that rejects is `unreadable` for the same reason: a failed
158
+ * read is a fact about the read, never about the provider. */
159
+ private async readPaneBusy(): Promise<PaneBusyReading | undefined> {
160
+ const pending = this.deps.probePaneBusy?.();
161
+ if (!pending) return undefined;
162
+ try { return await pending; } catch { return "unreadable"; }
163
+ }
126
164
  }
package/src/config.ts CHANGED
@@ -133,6 +133,16 @@ export function nativeSelfResumeTimeoutMsFromEnv(): number {
133
133
  return Number.isFinite(parsed) && parsed > 0 ? parsed : 120_000;
134
134
  }
135
135
 
136
+ /**
137
+ * #1621 — how long a Claude session-probe `busy`/`shell` assertion stays authoritative with no
138
+ * rewrite before the pane must corroborate it. See claudeProbeBusyIsStale for the full rationale:
139
+ * this is a TRUST window, not a busy timeout.
140
+ */
141
+ export function claudeProbeBusyStaleMsFromEnv(): number {
142
+ const parsed = Number(process.env.AGENT_RELAY_CLAUDE_PROBE_BUSY_STALE_MS);
143
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 300_000;
144
+ }
145
+
136
146
  export function agentProfileNameFromEnv(): string | undefined {
137
147
  return process.env.AGENT_RELAY_AGENT_PROFILE;
138
148
  }
@@ -202,6 +212,12 @@ export function runnerInfoFileFromEnv(): string | undefined {
202
212
  return process.env.AGENT_RELAY_RUNNER_INFO_FILE;
203
213
  }
204
214
 
215
+ // #1514 — the owning orchestrator's state-home id, stamped onto each tmux session
216
+ // (@agent-relay-owner) so the wedged-session reaper only reaps sessions it owns.
217
+ export function tmuxOwnerIdFromEnv(): string | undefined {
218
+ return process.env.AGENT_RELAY_TMUX_OWNER;
219
+ }
220
+
205
221
  export function runnerOutboxDirFromEnv(): string | undefined {
206
222
  return process.env.AGENT_RELAY_RUNNER_OUTBOX_DIR;
207
223
  }