agent-relay-runner 0.62.3 → 0.63.0

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.62.3",
3
+ "version": "0.63.0",
4
4
  "description": "Unified provider lifecycle runner for Agent Relay",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,7 +20,7 @@
20
20
  "directory": "runner"
21
21
  },
22
22
  "dependencies": {
23
- "agent-relay-sdk": "0.2.40"
23
+ "agent-relay-sdk": "0.2.41"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/bun": "latest",
@@ -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.62.3",
4
+ "version": "0.63.0",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -0,0 +1,84 @@
1
+ type ProviderActivity = "busy" | "idle" | "unknown";
2
+
3
+ const BUSY_RECONCILE_POLL_MS = 4_000;
4
+ const BUSY_RECONCILE_IDLE_CONFIRM = 8;
5
+ const BUSY_RECONCILE_IDLE_CONFIRM_NO_BUSY = 15;
6
+ const INTERRUPT_RECONCILE_DELAY_MS = 1_500;
7
+
8
+ interface BusyReconcilerDeps {
9
+ isStopped(): boolean;
10
+ hasProcess(): boolean;
11
+ currentStatus(): "idle" | "busy" | "offline" | "error";
12
+ isProviderBlocked(): boolean;
13
+ hasProviderTurn(): boolean;
14
+ canProbeActivity(): boolean;
15
+ probeActivity(): Promise<ProviderActivity> | undefined;
16
+ clearProviderTurn(reason: string): void;
17
+ sessionDebug(message: string): void;
18
+ }
19
+
20
+ export class BusyReconciler {
21
+ private idleStreak = 0;
22
+ private timer?: ReturnType<typeof setInterval>;
23
+ private sawBusy = false;
24
+
25
+ constructor(private readonly deps: BusyReconcilerDeps) {}
26
+
27
+ arm(): void {
28
+ if (this.timer || !this.deps.canProbeActivity()) return;
29
+ this.idleStreak = 0;
30
+ this.sawBusy = false;
31
+ this.timer = setInterval(() => { void this.run(); }, BUSY_RECONCILE_POLL_MS);
32
+ }
33
+
34
+ disarm(): void {
35
+ if (this.timer) clearInterval(this.timer);
36
+ this.timer = undefined;
37
+ this.idleStreak = 0;
38
+ this.sawBusy = false;
39
+ }
40
+
41
+ scheduleInterruptReconcile(): void {
42
+ setTimeout(() => {
43
+ if (this.deps.isStopped() || !this.deps.hasProcess()) return;
44
+ void (async () => {
45
+ if (this.deps.currentStatus() !== "busy" || this.deps.isProviderBlocked()) return;
46
+ const probe = this.deps.probeActivity();
47
+ let activity: ProviderActivity = "unknown";
48
+ try { if (probe) activity = await probe; } catch { return; }
49
+ this.deps.sessionDebug(`post-interrupt reconcile probe=${activity}`);
50
+ if (activity === "idle") this.deps.clearProviderTurn("post-interrupt");
51
+ })();
52
+ }, INTERRUPT_RECONCILE_DELAY_MS);
53
+ }
54
+
55
+ private async run(): Promise<void> {
56
+ const probe = this.deps.probeActivity();
57
+ if (this.deps.isStopped() || !this.deps.hasProcess() || !probe) {
58
+ this.disarm();
59
+ return;
60
+ }
61
+ if (this.deps.currentStatus() !== "busy" || this.deps.isProviderBlocked()) {
62
+ this.idleStreak = 0;
63
+ return;
64
+ }
65
+ if (!this.deps.hasProviderTurn()) {
66
+ this.disarm();
67
+ return;
68
+ }
69
+ let activity: ProviderActivity;
70
+ try { activity = await probe; } catch { return; }
71
+ if (activity === "busy") this.sawBusy = true;
72
+ if (activity !== "idle") {
73
+ this.idleStreak = 0;
74
+ this.deps.sessionDebug(`reconcile probe=${activity} sawBusy=${this.sawBusy} streak=${this.idleStreak}`);
75
+ return;
76
+ }
77
+ this.idleStreak += 1;
78
+ const confirm = this.sawBusy ? BUSY_RECONCILE_IDLE_CONFIRM : BUSY_RECONCILE_IDLE_CONFIRM_NO_BUSY;
79
+ this.deps.sessionDebug(`reconcile probe=idle sawBusy=${this.sawBusy} streak=${this.idleStreak}/${confirm}`);
80
+ if (this.idleStreak < confirm) return;
81
+ this.disarm();
82
+ this.deps.clearProviderTurn(this.sawBusy ? "backstop reconciler" : "backstop reconciler (no-busy-observed)");
83
+ }
84
+ }
@@ -0,0 +1,86 @@
1
+ import type { SendMessageInput } from "agent-relay-sdk";
2
+ import type { RelayHttpClient } from "agent-relay-sdk";
3
+ import { relayMcpEndpoint } from "./relay-mcp";
4
+ import type { OutboxRecord } from "./outbox";
5
+ import { deliverContinuationArchiveRecord } from "./continuation-archive";
6
+ import { logger } from "./logger";
7
+ import { isHttpAuthError, isHttpStatusError } from "./runner-helpers";
8
+
9
+ interface RunnerOutboxDelivery {
10
+ record: OutboxRecord;
11
+ http: RelayHttpClient;
12
+ relayUrl: string;
13
+ token?: string;
14
+ updatePayload(seq: number, payload: unknown): void;
15
+ sessionLog(message: string): void;
16
+ recoverRuntimeTokenAfterAuthFailure(source: string): void;
17
+ }
18
+
19
+ export async function deliverRunnerOutboxEvent(input: RunnerOutboxDelivery): Promise<void> {
20
+ const { record, http } = input;
21
+ try {
22
+ if (record.kind === "session-message") {
23
+ await http.sendMessage({
24
+ ...(record.payload as SendMessageInput),
25
+ occurredAt: record.occurredAt,
26
+ idempotencyKey: record.idempotencyKey,
27
+ });
28
+ return;
29
+ }
30
+ if (record.kind === "insight") {
31
+ await http.recordInsightObservation({
32
+ ...(record.payload as Parameters<RelayHttpClient["recordInsightObservation"]>[0]),
33
+ occurredAt: record.occurredAt,
34
+ });
35
+ return;
36
+ }
37
+ if (record.kind === "continuation-archive") {
38
+ await deliverContinuationArchiveRecord({
39
+ record,
40
+ http,
41
+ updatePayload: input.updatePayload,
42
+ sessionLog: input.sessionLog,
43
+ });
44
+ return;
45
+ }
46
+ if (record.kind === "mcp-tool-call") {
47
+ await deliverBufferedMcpCall(input);
48
+ return;
49
+ }
50
+ logger.warn("outbox", `dropping event with unknown kind: ${record.kind}`);
51
+ } catch (error) {
52
+ if (isHttpStatusError(error, 409)) return;
53
+ if (isHttpAuthError(error)) input.recoverRuntimeTokenAfterAuthFailure("outbox");
54
+ throw error;
55
+ }
56
+ }
57
+
58
+ export async function deliverBufferedMcpCall(input: {
59
+ record: OutboxRecord;
60
+ relayUrl: string;
61
+ token?: string;
62
+ recoverRuntimeTokenAfterAuthFailure(source: string): void;
63
+ }): Promise<void> {
64
+ const payload = input.record.payload as { tool: string; arguments: Record<string, unknown> };
65
+ const headers: Record<string, string> = { "content-type": "application/json" };
66
+ if (input.token) headers.authorization = `Bearer ${input.token}`;
67
+ const response = await fetch(relayMcpEndpoint(input.relayUrl), {
68
+ method: "POST",
69
+ headers,
70
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: payload.tool, arguments: payload.arguments } }),
71
+ });
72
+ if (response.status === 401 || response.status === 403) {
73
+ input.recoverRuntimeTokenAfterAuthFailure("mcp-outbox");
74
+ throw new Error(`relay rejected buffered ${payload.tool} with ${response.status}`);
75
+ }
76
+ if (response.status >= 500) throw new Error(`relay ${response.status} on buffered ${payload.tool}`);
77
+ if (!response.ok) {
78
+ const body = await response.text().catch(() => "");
79
+ logger.warn("mcp-outbox", `buffered ${payload.tool} permanently rejected (${response.status}); dropping: ${body.slice(0, 200)}`);
80
+ return;
81
+ }
82
+ const json = await response.json().catch(() => null) as { error?: { message?: string } } | null;
83
+ if (json?.error) {
84
+ logger.warn("mcp-outbox", `buffered ${payload.tool} returned a tool error; dropping: ${json.error.message ?? "(no detail)"}`);
85
+ }
86
+ }
package/src/rate-limit.ts CHANGED
@@ -14,7 +14,7 @@ export const RATE_LIMIT_BLOCK_REASON = "rate_limit";
14
14
  // fall back to the resume sweep's poll window.
15
15
  const MAX_RESET_AHEAD_MS = 7 * 24 * 60 * 60 * 1000;
16
16
 
17
- export interface RateLimitHoldInput {
17
+ interface RateLimitHoldInput {
18
18
  errorType?: string;
19
19
  /** Unix ms the limit window resets, when known. */
20
20
  resetAt?: number;
@@ -58,7 +58,7 @@ const LIMIT_PHRASE_RE = /(?:hit (?:your|the)\s*(?:[\w-]+\s+)*limit|usage limit|\
58
58
  const RESET_AT_RE = /\bresets?\b(?:\s+at)?\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*(?:\(([^)]+)\))?/i;
59
59
  const TRY_AGAIN_RE = /\btry again in\s+(\d+)\s*(second|minute|hour|day)s?/i;
60
60
 
61
- export interface PaneRateLimit {
61
+ interface PaneRateLimit {
62
62
  /** The matched limit line, for the chat notice / observability. */
63
63
  message: string;
64
64
  /** Unix ms reset, when parseable from the pane (else undefined → resume falls back to poll). */