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 +1 -1
- package/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/adapter.ts +4 -0
- package/src/adapters/codex.ts +21 -0
- package/src/claim-tracker.ts +31 -0
- package/src/control-server.ts +7 -0
- package/src/runner-core.ts +59 -1
package/package.json
CHANGED
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
|
}
|
package/src/adapters/codex.ts
CHANGED
|
@@ -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);
|
package/src/claim-tracker.ts
CHANGED
|
@@ -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
|
}
|
package/src/control-server.ts
CHANGED
|
@@ -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 } : {}),
|
package/src/runner-core.ts
CHANGED
|
@@ -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
|
-
|
|
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
|