agent-relay-runner 0.88.2 → 0.88.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
- "version": "0.88.2",
3
+ "version": "0.88.3",
4
4
  "description": "Unified provider lifecycle runner for Agent Relay",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
3
  "description": "Thin Agent Relay runner bridge for Claude Code",
4
- "version": "0.88.2",
4
+ "version": "0.88.3",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
package/src/adapter.ts CHANGED
@@ -25,6 +25,10 @@ export interface ProviderStatusEvent {
25
25
  label?: string;
26
26
  role?: string;
27
27
  parentId?: string;
28
+ // OS pids of the process(es) this busy work tracks (#650). When supplied, the
29
+ // runner reconciles them against actual liveness and clears the busy marker if
30
+ // every pid is dead — recovering from a tracked process killed out-of-band.
31
+ pids?: number[];
28
32
  providerState?: Record<string, unknown>;
29
33
  metadata?: Record<string, unknown>;
30
34
  }
@@ -50,6 +50,9 @@ export class CodexAdapter implements ProviderAdapter {
50
50
  // Active turn id for the main thread, captured from turn/started so an interrupt
51
51
  // can target the in-flight turn. Cleared on turn/completed.
52
52
  private activeTurnId?: string;
53
+ // #653: monotonic id for a turn we synthesize from live item activity when a real
54
+ // turn/started never reached us — so a working Codex agent isn't reported `idle`.
55
+ private syntheticTurnSeq = 0;
53
56
  private readonly agentMessageCapture = new CodexAgentMessageCapture();
54
57
  private readonly itemTextBuffers = new Map<string, string>();
55
58
  private readonly itemTextBufferTypes = new Map<string, string>();
@@ -578,8 +581,26 @@ export class CodexAdapter implements ProviderAdapter {
578
581
  return [...this.sessionEvents];
579
582
  }
580
583
 
584
+ // #653: Codex busy-state normally flips on turn/started, but the app-server can stream
585
+ // item activity (which already drives the dashboard chat mirror) before — or without —
586
+ // a turn/started reaching us, leaving a visibly-working agent reported `idle` (a reap
587
+ // hazard). Derive busy from that SAME live activity: any in-flight item event with no
588
+ // active turn means a turn IS running we missed, so synthesize one and report busy.
589
+ // turn/completed → finishMainTurn clears it; we never synthesize on a closing
590
+ // item.completed, so a legitimately ended turn is not re-flipped to busy.
591
+ private ensureMainTurnActive(turnId?: string): void {
592
+ if (this.activeTurnId) return;
593
+ this.activeTurnId = turnId ?? `codex-synth-turn-${++this.syntheticTurnSeq}`;
594
+ this.statusCb({ status: "busy", reason: "provider-turn", id: this.activeTurnId });
595
+ }
596
+
581
597
  private handleCodexItemDelta(method: string, params: Record<string, unknown> | undefined): void {
582
598
  if (!method.includes("item/") && !method.includes("item.")) return;
599
+ // Live item stream (start/delta/progress) = a turn in flight. A closing
600
+ // item.completed is handled by handleCodexItem and must not synthesize a turn.
601
+ if (!method.includes("/completed") && !method.includes(".completed")) {
602
+ this.ensureMainTurnActive(stringValue(params?.turnId));
603
+ }
583
604
  const item = isRecord(params?.item) ? params.item : undefined;
584
605
  const itemId = codexItemId(params) ?? codexItemId(item);
585
606
  const type = codexItemTypeFromMethod(method) ?? stringValue(item?.type);
@@ -17,6 +17,10 @@ interface WorkRecord {
17
17
  role?: string;
18
18
  parentId?: string;
19
19
  metadata?: Record<string, unknown>;
20
+ // OS pids of the underlying process(es) this work tracks (#650). When present,
21
+ // a periodic liveness reconcile clears the record once every pid is dead — so a
22
+ // tracked process killed out-of-band (no exit event) can't strand the agent busy.
23
+ pids?: number[];
20
24
  startedAt: number;
21
25
  }
22
26
 
@@ -40,6 +44,7 @@ export class ClaimTracker {
40
44
  role: details.role ?? existing?.role,
41
45
  parentId: details.parentId ?? existing?.parentId,
42
46
  metadata: details.metadata ?? existing?.metadata,
47
+ pids: details.pids ?? existing?.pids,
43
48
  startedAt: existing?.startedAt ?? Date.now(),
44
49
  });
45
50
  return before !== this.currentStatus();
@@ -106,4 +111,30 @@ export class ClaimTracker {
106
111
  activeWork(): WorkRecord[] {
107
112
  return [...this.work.values()];
108
113
  }
114
+
115
+ /** True iff any active work carries pids the liveness reconcile can check (#650). */
116
+ hasLivenessTrackedWork(): boolean {
117
+ for (const item of this.work.values()) {
118
+ if (item.pids && item.pids.length > 0) return true;
119
+ }
120
+ return false;
121
+ }
122
+
123
+ /**
124
+ * Reconcile pid-bearing work against actual process liveness (#650): drop any
125
+ * record whose tracked pids are ALL dead — its underlying process died out-of-band
126
+ * (killed/crashed/orphaned) with no exit event to clear the busy marker. Records
127
+ * with no pids are never touched (no liveness signal to act on). Returns the removed
128
+ * records so the caller can emit the matching completion/abort + republish status.
129
+ */
130
+ reconcileLiveness(isAlive: (pid: number) => boolean): WorkRecord[] {
131
+ const removed: WorkRecord[] = [];
132
+ for (const [key, item] of this.work) {
133
+ if (!item.pids || item.pids.length === 0) continue;
134
+ if (item.pids.some(isAlive)) continue;
135
+ this.work.delete(key);
136
+ removed.push(item);
137
+ }
138
+ return removed;
139
+ }
109
140
  }
@@ -446,6 +446,12 @@ async function handleStatus(req: Request, options: ControlServerOptions): Promis
446
446
  const isWorkKind = (item: unknown): item is "provider-turn" | "subagent" | "background-script" =>
447
447
  item === "provider-turn" || item === "subagent" || item === "background-script";
448
448
  const clear = Array.isArray(body?.clear) ? body.clear.filter(isWorkKind) : undefined;
449
+ // #650: optional pids the runner reconciles against process liveness so a tracked
450
+ // command/shell killed out-of-band clears the busy marker instead of stranding it.
451
+ const pidsInput = Array.isArray((body as { pids?: unknown })?.pids) ? (body as { pids: unknown[] }).pids : undefined;
452
+ const pids = pidsInput
453
+ ?.map((p) => (typeof p === "number" ? p : Number(p)))
454
+ .filter((p) => Number.isInteger(p) && p > 0);
449
455
  const timeline = statusTimelineEvent(body);
450
456
  const update: ProviderStatusEvent = {
451
457
  status,
@@ -454,6 +460,7 @@ async function handleStatus(req: Request, options: ControlServerOptions): Promis
454
460
  ...(clear?.length ? { clear: [...new Set(clear)] } : {}),
455
461
  ...(timeline ? { timeline } : {}),
456
462
  ...(typeof body?.id === "string" ? { id: body.id } : {}),
463
+ ...(pids?.length ? { pids } : {}),
457
464
  ...(typeof body?.label === "string" ? { label: body.label } : {}),
458
465
  ...(typeof body?.role === "string" ? { role: body.role } : {}),
459
466
  ...(typeof body?.parentId === "string" ? { parentId: body.parentId } : {}),
@@ -5,6 +5,7 @@ import { dirname, join } from "node:path";
5
5
  import type { AgentLifecycle, AgentProfile, ContextState, Message, MessageSessionMeta, SendMessageInput, TaskStatusInput, WorkspaceMetadata } from "agent-relay-sdk";
6
6
  import { errMessage, RelayBusClient, RelayHttpClient } from "agent-relay-sdk";
7
7
  import { contextStateFromProbeMetrics, quotaStateFromProbeMetrics } from "agent-relay-sdk/context-probe";
8
+ import { isPidAlive } from "agent-relay-sdk/process-utils";
8
9
  import { providerAttachmentText, providerMessageText } from "./adapter";
9
10
  import type { ManagedProcess, ProviderAdapter, ProviderConfig, ProviderPermissionDecision, ProviderPermissionDecisionInput, ProviderSessionEvent, ProviderStatusUpdate, RunnerSpawnConfig, SemanticStatus, TerminalAttachSpec } from "./adapter";
10
11
  import { messagesWithCachedAttachments } from "./attachment-cache";
@@ -109,6 +110,8 @@ interface ActiveTaskClaim {
109
110
 
110
111
  const CLAIM_RENEW_INTERVAL_MS = 5 * 60 * 1000;
111
112
  const HTTP_LIVENESS_INTERVAL_MS = 20_000;
113
+ // #650: how often to reconcile pid-bearing busy work against actual process liveness.
114
+ const BUSY_LIVENESS_RECONCILE_MS = 5_000;
112
115
  const HTTP_LIVENESS_LOG_INTERVAL_MS = 5 * 60 * 1000;
113
116
  const TOKEN_RENEW_RETRY_MS = 60_000;
114
117
  // Debounce reactive token recovery so a burst of 401-ing calls in the same window
@@ -204,6 +207,7 @@ export class AgentRunner {
204
207
  private drainTimer?: Timer;
205
208
  private claimRenewTimer?: Timer;
206
209
  private httpLivenessTimer?: Timer;
210
+ private livenessReconcileTimer?: Timer;
207
211
  private httpLivenessInFlight = false;
208
212
  private httpLivenessAuthFailed = false;
209
213
  private httpLivenessLastLog?: { key: string; at: number };
@@ -472,6 +476,7 @@ export class AgentRunner {
472
476
  this.httpLivenessTimer = undefined;
473
477
  if (this.tokenRenewTimer) clearTimeout(this.tokenRenewTimer);
474
478
  this.tokenRenewTimer = undefined;
479
+ this.disarmBusyLivenessReconcile();
475
480
  this.busyReconciler.disarm();
476
481
  this.stopReasoningTail();
477
482
  this.obligationCache.stop();
@@ -1247,15 +1252,30 @@ export class AgentRunner {
1247
1252
  if (status === "busy" || status === "idle") this.terminalProviderExit = undefined;
1248
1253
  if (status === "busy") {
1249
1254
  this.claims.clearTerminalStatus();
1250
- this.claims.startWork(reason, id, typeof update === "string" ? {} : {
1255
+ // #650: liveness-track the pids a reporter explicitly supplied; otherwise, for a
1256
+ // provider-turn, fall back to the managed provider process itself. If that process
1257
+ // dies out-of-band with no exit event reaching the runner (crash/orphan/external
1258
+ // kill), the reconcile clears the stuck turn → idle. A dead pid means the turn is
1259
+ // definitively over, so this can never falsely clear a live turn (#653's hazard).
1260
+ const reportedPids = typeof update === "string" ? undefined : update.pids;
1261
+ const livenessPids = reportedPids?.length
1262
+ ? reportedPids
1263
+ : reason === "provider-turn" && typeof this.process?.pid === "number" && this.process.pid > 0
1264
+ ? [this.process.pid]
1265
+ : undefined;
1266
+ this.claims.startWork(reason, id, typeof update === "string" ? (livenessPids ? { pids: livenessPids } : {}) : {
1251
1267
  label: update.label,
1252
1268
  role: update.role,
1253
1269
  parentId: update.parentId,
1270
+ ...(livenessPids ? { pids: livenessPids } : {}),
1254
1271
  metadata: {
1255
1272
  ...(update.metadata ?? {}),
1256
1273
  ...(update.providerState ? { providerState: update.providerState } : {}),
1257
1274
  },
1258
1275
  });
1276
+ // A busy report with liveness-trackable pids arms the periodic reconcile (self-disarms
1277
+ // once no pid-bearing work remains, so idle/untracked agents pay nothing).
1278
+ if (livenessPids?.length) this.armBusyLivenessReconcile();
1259
1279
  if (reason === "provider-turn") {
1260
1280
  for (const claim of this.activeTaskClaims.values()) claim.observedProviderBusy = true;
1261
1281
  }
@@ -1690,6 +1710,44 @@ export class AgentRunner {
1690
1710
  this.publishStatus();
1691
1711
  }
1692
1712
 
1713
+ // #650: reconcile pid-bearing busy work against actual process liveness. A tracked
1714
+ // command/shell that dies out-of-band (killed by the operator, crashes, orphaned)
1715
+ // sends no exit event, so its busy marker would otherwise never clear and the agent
1716
+ // shows `busy` forever — blocking transient auto-reap and (for Claude) holding quota.
1717
+ // The timer self-disarms once no pid-bearing work remains; it is armed lazily when a
1718
+ // busy report carrying pids arrives, so idle/non-tracked agents pay nothing.
1719
+ private armBusyLivenessReconcile(): void {
1720
+ if (this.livenessReconcileTimer || this.stopped) return;
1721
+ this.livenessReconcileTimer = setInterval(() => this.runBusyLivenessReconcile(), BUSY_LIVENESS_RECONCILE_MS);
1722
+ }
1723
+
1724
+ private disarmBusyLivenessReconcile(): void {
1725
+ if (this.livenessReconcileTimer) clearInterval(this.livenessReconcileTimer);
1726
+ this.livenessReconcileTimer = undefined;
1727
+ }
1728
+
1729
+ private runBusyLivenessReconcile(): void {
1730
+ if (this.stopped) {
1731
+ this.disarmBusyLivenessReconcile();
1732
+ return;
1733
+ }
1734
+ const removed = this.claims.reconcileLiveness(isPidAlive);
1735
+ if (removed.length > 0) {
1736
+ this.sessionLog(`cleared ${removed.length} stuck busy work item(s) whose tracked process died out-of-band: ${removed.map((w) => `${w.kind}:${w.id}`).join(", ")}`);
1737
+ // A cleared provider-turn must also reset the turn bookkeeping so a later idle
1738
+ // doesn't double-end it and the reasoning tail stops.
1739
+ if (removed.some((w) => w.kind === "provider-turn")) {
1740
+ this.currentTurnId = undefined;
1741
+ this.currentTurnStartedAt = undefined;
1742
+ this.compactionMidTurn = false;
1743
+ this.busyReconciler.disarm();
1744
+ this.stopReasoningTail();
1745
+ }
1746
+ this.publishStatus();
1747
+ }
1748
+ if (!this.claims.hasLivenessTrackedWork()) this.disarmBusyLivenessReconcile();
1749
+ }
1750
+
1693
1751
  // --- Turn-step tailer (item 5) ------------------------------------------------------
1694
1752
  // Tail the in-flight turn's Claude transcript and surface new narration/reasoning/tool
1695
1753
  // steps as session events, in transcript order. `narration` (the agent's intermediate