lurker: fall back to opencode on ANY claude dead-end, not just a rate-limit phrase
A bullpen fix round stalled when @testdesigner's claude run returned rc 1 with no usable
result. The lurker rendered that as "(@nick: no result, rc 1)" and POSTED it, hanging the
round behind a useless message. The opencode swap never fired because it only matched a
rate-limit PHRASE in the answer — and a structured API failure (is_error / api_error_status,
e.g. a 429) carries no such phrase; the CLI collapses it to "no result".
* run_claude now returns (answer, ok). ok is False for an empty/error result, a structured
API error (surfaced with its api_error_status so it is diagnosable, not swallowed), a
timeout, or a non-zero rc.
* run_agent swaps to opencode on ok=False OR the rate-limit phrase. If opencode also
dead-ends, it returns the more informative of the two failures.
Same failure family as the bullseye 200-on-refused-post and the opencode dropped-final-text:
a failure that looks like success/silence. Falling back on any dead-end is strictly better
than posting one.
The _drive_lurker harness stub was updated to the (answer, ok) contract. Its own new test
(test_lurker_swap.py) initially LEAKED — it assigned subprocess.run directly instead of via
monkeypatch, so the stub bled into every later test that shelled out (25 spurious
test_show_shell_safe failures). Fixed to monkeypatch, which auto-restores.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+34
-9
@@ -186,16 +186,32 @@ def run_opencode(prompt):
|
||||
return _cap(f"(@{NICK} opencode error: {type(e).__name__}: {e})")
|
||||
|
||||
def run_agent(prompt):
|
||||
"""Per-nick runtime, with automatic swap when Anthropic rate-limits."""
|
||||
"""Per-nick runtime, with automatic swap when claude can't deliver.
|
||||
|
||||
Swap to opencode on TWO signals, not one: an explicit rate-limit phrase in the answer, OR
|
||||
any claude FAILURE at all (empty result, is_error, API error, timeout, non-zero rc). The
|
||||
old code only matched the phrase, so a 429 that arrives as structured JSON (is_error=true,
|
||||
api_error_status, empty result) — which the CLI renders as "(no result, rc 1)", NOT as a
|
||||
rate-limit string — never triggered the swap. The round then stalled behind a useless post.
|
||||
Falling back on ANY dead-end is strictly better than posting one."""
|
||||
if RUNTIME == "opencode":
|
||||
return run_opencode(prompt)
|
||||
ans = run_claude(prompt)
|
||||
if RATE_LIMIT_RE.search(ans or ""):
|
||||
sys.stderr.write(f"@{NICK}: claude RATE-LIMITED -> swapping to opencode ({_ocmodel()})\n")
|
||||
return run_opencode(prompt)
|
||||
ans, ok = run_claude(prompt)
|
||||
if not ok or RATE_LIMIT_RE.search(ans or ""):
|
||||
why = "rate-limited" if RATE_LIMIT_RE.search(ans or "") else "failed"
|
||||
sys.stderr.write(f"@{NICK}: claude {why} ({(ans or '')[:120]!r}) -> opencode ({_ocmodel()})\n")
|
||||
oc = run_opencode(prompt)
|
||||
# If opencode ALSO dead-ends, hand back the more informative of the two so the room at
|
||||
# least sees a real diagnosis instead of two layers of "(no result)".
|
||||
if oc and not oc.lstrip().startswith(f"(@{NICK}"):
|
||||
return oc
|
||||
return ans if (ans and not ok) else oc
|
||||
return ans
|
||||
|
||||
def run_claude(prompt):
|
||||
"""Returns (answer, ok). ok is False when claude produced nothing usable — an empty/error
|
||||
result, a structured API error (is_error / api_error_status, e.g. a 429), a timeout, or a
|
||||
non-zero rc — so the caller can fall back rather than post the failure verbatim."""
|
||||
cmd = [CLAUDE, "-p", prompt, "--dangerously-skip-permissions",
|
||||
"--output-format", "json", "--max-turns", MAX_TURNS]
|
||||
if MODEL:
|
||||
@@ -203,14 +219,23 @@ def run_claude(prompt):
|
||||
try:
|
||||
p = subprocess.run(cmd, cwd=LURKER_DIR, capture_output=True, text=True, timeout=RUN_TIMEOUT)
|
||||
except subprocess.TimeoutExpired:
|
||||
return f"(@{NICK} timed out)"
|
||||
return f"(@{NICK} timed out)", False
|
||||
try:
|
||||
d = json.loads(p.stdout)
|
||||
return _cap(d.get("result") or d.get("error") or f"(@{NICK}: no result, rc {p.returncode})")
|
||||
result = d.get("result") or d.get("error") or ""
|
||||
# A structured API failure (429/5xx) carries no usable result but sets these fields; make
|
||||
# it visible AND matchable so the rate-limit regex and the ok flag can both act on it.
|
||||
if d.get("is_error") or d.get("api_error_status"):
|
||||
api = d.get("api_error_status") or d.get("subtype") or "error"
|
||||
return _cap(f"(@{NICK}: claude API error [{api}] {result}".strip() + ")"), False
|
||||
if not result.strip():
|
||||
return _cap(f"(@{NICK}: no result, rc {p.returncode})"), False
|
||||
return _cap(result), True
|
||||
except Exception:
|
||||
if p.returncode != 0:
|
||||
return _cap(f"(@{NICK} error rc{p.returncode}: {(p.stderr or p.stdout)[:200].strip()})")
|
||||
return _cap((p.stdout or f"(@{NICK}: unparsable output)").strip())
|
||||
return _cap(f"(@{NICK} error rc{p.returncode}: {(p.stderr or p.stdout)[:200].strip()})"), False
|
||||
txt = (p.stdout or "").strip()
|
||||
return (_cap(txt), True) if txt else (_cap(f"(@{NICK}: unparsable output)"), False)
|
||||
|
||||
def build_prompt(m):
|
||||
asker = m.get("from", "someone")
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Spec: a claude dead-end must fall back to opencode, not stall the room.
|
||||
|
||||
The bullpen fix round stalled on 2026-07-23 when @testdesigner's claude run returned rc 1 with
|
||||
no usable result. The lurker rendered that as the string "(@nick: no result, rc 1)" and posted
|
||||
it — the round hung behind a useless message. The rate-limit swap didn't fire because it only
|
||||
matched a rate-limit PHRASE, and a structured API failure (is_error / api_error_status, e.g. a
|
||||
429) carries no such phrase. run_claude now reports (answer, ok); run_agent swaps on ok=False.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
from importlib.machinery import SourceFileLoader
|
||||
|
||||
import pytest
|
||||
|
||||
LURKER = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"lurker", "bullpen-lurker")
|
||||
|
||||
|
||||
def _load():
|
||||
loader = SourceFileLoader("lurker_swap_under_test", LURKER)
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[loader.name] = mod
|
||||
loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def lurker():
|
||||
m = _load()
|
||||
m.RUNTIME = "claude" # exercise the swap path, not the opencode-native path
|
||||
return m
|
||||
|
||||
|
||||
def _fake_claude(mod, monkeypatch, out):
|
||||
"""Drive run_claude with a canned subprocess result (json stdout + rc).
|
||||
|
||||
Uses monkeypatch so subprocess.run is RESTORED after the test — an earlier version assigned
|
||||
mod.subprocess.run directly, which mutated the shared subprocess module globally and made
|
||||
every later test that shelled out (test_show_shell_safe) run against the stub. Test scratch
|
||||
that leaks into other tests is exactly the failure this suite is meant to prevent."""
|
||||
import types
|
||||
def fake_run(*a, **k):
|
||||
return types.SimpleNamespace(stdout=out.get("stdout", ""),
|
||||
stderr=out.get("stderr", ""),
|
||||
returncode=out.get("rc", 0))
|
||||
monkeypatch.setattr(mod.subprocess, "run", fake_run)
|
||||
|
||||
|
||||
def test_run_claude_flags_a_structured_api_error_as_failure(lurker, monkeypatch):
|
||||
import json
|
||||
_fake_claude(lurker, monkeypatch, {"stdout": json.dumps(
|
||||
{"is_error": True, "api_error_status": 429, "result": ""}), "rc": 1})
|
||||
ans, ok = lurker.run_claude("x")
|
||||
assert ok is False
|
||||
assert "429" in ans, "the API status must be visible, not swallowed into 'no result'"
|
||||
|
||||
|
||||
def test_run_claude_flags_empty_result_as_failure(lurker, monkeypatch):
|
||||
import json
|
||||
_fake_claude(lurker, monkeypatch, {"stdout": json.dumps({"result": "", "is_error": False}), "rc": 1})
|
||||
ans, ok = lurker.run_claude("x")
|
||||
assert ok is False
|
||||
|
||||
|
||||
def test_run_claude_reports_a_real_answer_as_success(lurker, monkeypatch):
|
||||
import json
|
||||
_fake_claude(lurker, monkeypatch, {"stdout": json.dumps({"result": "the answer", "is_error": False}), "rc": 0})
|
||||
ans, ok = lurker.run_claude("x")
|
||||
assert ok is True and "the answer" in ans
|
||||
|
||||
|
||||
def test_run_agent_swaps_to_opencode_on_any_claude_failure(lurker, monkeypatch):
|
||||
monkeypatch.setattr(lurker, "run_claude", lambda p: ("(@x: no result, rc 1)", False))
|
||||
monkeypatch.setattr(lurker, "run_opencode", lambda p: "opencode delivered")
|
||||
assert lurker.run_agent("do the thing") == "opencode delivered", (
|
||||
"a claude dead-end with no rate-limit phrase must still fall back to opencode")
|
||||
|
||||
|
||||
def test_run_agent_does_not_swap_when_claude_succeeds(lurker, monkeypatch):
|
||||
monkeypatch.setattr(lurker, "run_claude", lambda p: ("claude answer", True))
|
||||
monkeypatch.setattr(lurker, "run_opencode",
|
||||
lambda p: pytest.fail("must not call opencode when claude succeeded"))
|
||||
assert lurker.run_agent("x") == "claude answer"
|
||||
|
||||
|
||||
def test_run_agent_keeps_claude_diagnosis_when_opencode_also_dead_ends(lurker, monkeypatch):
|
||||
monkeypatch.setattr(lurker, "NICK", "x")
|
||||
monkeypatch.setattr(lurker, "run_claude", lambda p: ("(@x: claude API error [429] )", False))
|
||||
monkeypatch.setattr(lurker, "run_opencode", lambda p: "(@x opencode: no output rc1)")
|
||||
out = lurker.run_agent("x")
|
||||
assert "429" in out, "when both fail, surface the more informative failure, not empty noise"
|
||||
@@ -449,7 +449,9 @@ def _drive_lurker(tmp_path, monkeypatch, messages, name):
|
||||
raise _Exit()
|
||||
|
||||
mod.room_read = fake_room_read
|
||||
mod.run_claude = lambda prompt: (calls["claude"].append(prompt), "an answer")[1]
|
||||
# run_claude returns (answer, ok) since the rate-limit/failure-swap change; the stub must
|
||||
# honour that contract or run_agent's `ans, ok = run_claude(...)` unpack fails.
|
||||
mod.run_claude = lambda prompt: (calls["claude"].append(prompt), ("an answer", True))[1]
|
||||
mod.say = lambda body, to="", typ="chat", in_reply_to=None: (
|
||||
calls["said"].append((typ, to, body)), True)[1]
|
||||
monkeypatch.setattr(mod.time, "sleep", lambda s: None)
|
||||
|
||||
Reference in New Issue
Block a user