agent-relay-runner 0.15.0 → 0.15.1

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.15.0",
3
+ "version": "0.15.1",
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.15.0",
4
+ "version": "0.15.1",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -96,7 +96,10 @@ relay_pending_reply_stop_decision() {
96
96
  local port="${AGENT_RELAY_RUNNER_PORT:-}"
97
97
  [ -z "$port" ] && return 0
98
98
  local response
99
- response="$(curl -fsS "http://127.0.0.1:${port}/reply-obligations/claude-stop" 2>/dev/null || true)"
99
+ # --max-time guards the Claude Stop hook's 5s budget: a slow runner/server (e.g. an
100
+ # un-indexed obligation query) must never block past the timeout, or Claude SIGKILLs
101
+ # the hook before it clears the turn -> stuck "busy" (#199). On timeout: no block.
102
+ response="$(curl -fsS --max-time 2 "http://127.0.0.1:${port}/reply-obligations/claude-stop" 2>/dev/null || true)"
100
103
  case "$response" in
101
104
  *'"decision":"block"'*|*'"decision": "block"'*) ;;
102
105
  *) return 0 ;;
@@ -7,7 +7,9 @@ stop_hook_active="$(relay_json_bool_field stop_hook_active "$payload")"
7
7
  if [ "$stop_hook_active" != "true" ]; then
8
8
  last_assistant_msg="$(echo "$payload" | jq -c '.last_assistant_message // empty' 2>/dev/null || true)"
9
9
  relay_post_session_turn "$(relay_json_string_field transcript_path "$payload")" "$last_assistant_msg"
10
- stop_decision="$(relay_pending_reply_stop_decision)"
10
+ # `|| true`: under `set -e`, a non-zero from the obligation check must never abort
11
+ # the hook before the idle-clear below — clearing the turn is the critical path (#199).
12
+ stop_decision="$(relay_pending_reply_stop_decision || true)"
11
13
  if [ -n "$stop_decision" ]; then
12
14
  printf '%s\n' "$stop_decision"
13
15
  exit 0
package/src/runner.ts CHANGED
@@ -76,12 +76,18 @@ const LOG_TAIL_BYTES = 128 * 1024;
76
76
  const PROMPT_ECHO_DEDUP_MS = 30_000;
77
77
  // Busy reconciler: a conservative LAST-RESORT backstop for a turn that ended
78
78
  // without the provider's Stop hook clearing busy (e.g. ESC straight into the web
79
- // terminal). It must never fire during a live turn, so it (a) only counts idle
80
- // after it has actually observed the provider busy, and (b) requires a long,
79
+ // terminal). It must never fire during a live turn, so it requires a long,
81
80
  // unbroken idle streak — an active turn shows its working spinner well within
82
81
  // this window, which resets the streak. ~32s of uninterrupted idle = really done.
83
82
  const BUSY_RECONCILE_POLL_MS = 4_000;
84
83
  const BUSY_RECONCILE_IDLE_CONFIRM = 8;
84
+ // When the reconciler never observed the provider busy this turn (a turn faster
85
+ // than the 4s poll — common for short voice/autosend replies), it can't trust a
86
+ // quick idle the way it does after seeing the spinner. But refusing forever wedged
87
+ // fast turns in "busy" when the Stop hook's idle was lost (#199). So we still
88
+ // force-clear, just after a much longer unbroken-idle window — an active turn would
89
+ // have flashed its spinner into at least one of these probes and reset the streak.
90
+ const BUSY_RECONCILE_IDLE_CONFIRM_NO_BUSY = 15;
85
91
  // After a dashboard interrupt, give the provider a moment to drop out of its turn,
86
92
  // then reconcile immediately so the user sees "stopped" without waiting for the backstop.
87
93
  const INTERRUPT_RECONCILE_DELAY_MS = 1_500;
@@ -1146,18 +1152,21 @@ export class AgentRunner {
1146
1152
  let activity: "busy" | "idle" | "unknown";
1147
1153
  try { activity = await this.options.adapter.probeActivity(this.process); } catch { return; }
1148
1154
  if (activity === "busy") this.busyReconcileSawBusy = true;
1149
- // Reset the streak on anything that isn't a confident idle — and never start
1150
- // counting until we've actually observed the provider busy this turn.
1151
- if (activity !== "idle" || !this.busyReconcileSawBusy) {
1152
- if (activity !== "idle") this.busyReconcileIdleStreak = 0;
1155
+ // Reset the streak on anything that isn't a confident idle.
1156
+ if (activity !== "idle") {
1157
+ this.busyReconcileIdleStreak = 0;
1153
1158
  this.sessionDebug(`reconcile probe=${activity} sawBusy=${this.busyReconcileSawBusy} streak=${this.busyReconcileIdleStreak}`);
1154
1159
  return;
1155
1160
  }
1156
1161
  this.busyReconcileIdleStreak += 1;
1157
- this.sessionDebug(`reconcile probe=idle streak=${this.busyReconcileIdleStreak}/${BUSY_RECONCILE_IDLE_CONFIRM}`);
1158
- if (this.busyReconcileIdleStreak < BUSY_RECONCILE_IDLE_CONFIRM) return;
1162
+ // Confirm faster once we've seen the spinner this turn; otherwise demand a much
1163
+ // longer all-idle window before trusting it (rescues fast turns without
1164
+ // false-clearing a live turn that simply hasn't flashed busy into a probe yet).
1165
+ const confirm = this.busyReconcileSawBusy ? BUSY_RECONCILE_IDLE_CONFIRM : BUSY_RECONCILE_IDLE_CONFIRM_NO_BUSY;
1166
+ this.sessionDebug(`reconcile probe=idle sawBusy=${this.busyReconcileSawBusy} streak=${this.busyReconcileIdleStreak}/${confirm}`);
1167
+ if (this.busyReconcileIdleStreak < confirm) return;
1159
1168
  this.disarmBusyReconciler();
1160
- this.forceClearProviderTurn("backstop reconciler");
1169
+ this.forceClearProviderTurn(this.busyReconcileSawBusy ? "backstop reconciler" : "backstop reconciler (no-busy-observed)");
1161
1170
  }
1162
1171
 
1163
1172
  // Force-clear a stuck provider-turn claim directly. Unlike the idle status path