Compare commits
1 Commits
worktree-s
...
9d2536a667
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d2536a667 |
@@ -40,6 +40,14 @@ logger = logging.getLogger(__name__)
|
|||||||
DEFAULT_TIMEOUT = 1800
|
DEFAULT_TIMEOUT = 1800
|
||||||
LONG_TIMEOUT = 3600 # opus block writing on full case context
|
LONG_TIMEOUT = 3600 # opus block writing on full case context
|
||||||
|
|
||||||
|
# #85 — `claude -p` fails intermittently with a fast non-zero exit and empty
|
||||||
|
# stderr (observed on large/slow cold prompts: CEO write_interim_draft,
|
||||||
|
# learning_loop distillation). The SAME prompt succeeds on retry, so the bail is
|
||||||
|
# transient — retry with linear backoff. Timeouts and "CLI not found" are
|
||||||
|
# deterministic and are NOT retried.
|
||||||
|
MAX_RETRIES = 3
|
||||||
|
RETRY_BACKOFF_BASE = 5 # seconds; sleep = base * attempt_number
|
||||||
|
|
||||||
|
|
||||||
async def query(
|
async def query(
|
||||||
prompt: str,
|
prompt: str,
|
||||||
@@ -94,53 +102,69 @@ async def query(
|
|||||||
if effort:
|
if effort:
|
||||||
cmd += ["--effort", effort]
|
cmd += ["--effort", effort]
|
||||||
|
|
||||||
try:
|
size_info = f"; prompt_len={len(full_prompt):,} chars" if len(full_prompt) > 100_000 else ""
|
||||||
proc = await asyncio.create_subprocess_exec(
|
last_err = "unknown error"
|
||||||
*cmd,
|
|
||||||
stdin=asyncio.subprocess.PIPE,
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.PIPE,
|
|
||||||
)
|
|
||||||
except FileNotFoundError:
|
|
||||||
raise RuntimeError(
|
|
||||||
"Claude CLI not found. This module only works when invoked "
|
|
||||||
"from the local MCP server — see the architectural rule in "
|
|
||||||
"the module docstring. If this error came from a FastAPI "
|
|
||||||
"endpoint in the container, refactor the call into an MCP "
|
|
||||||
"tool that the chair triggers from Claude Code."
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
for attempt in range(1, MAX_RETRIES + 1):
|
||||||
stdout_b, stderr_b = await asyncio.wait_for(
|
|
||||||
proc.communicate(input=full_prompt.encode("utf-8")),
|
|
||||||
timeout=timeout,
|
|
||||||
)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
# wait_for cancellation alone leaves the child running.
|
|
||||||
try:
|
try:
|
||||||
proc.kill()
|
proc = await asyncio.create_subprocess_exec(
|
||||||
await proc.wait()
|
*cmd,
|
||||||
except ProcessLookupError:
|
stdin=asyncio.subprocess.PIPE,
|
||||||
pass
|
stdout=asyncio.subprocess.PIPE,
|
||||||
raise RuntimeError(f"Claude CLI timed out after {timeout}s")
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
# Deterministic — never retry.
|
||||||
|
raise RuntimeError(
|
||||||
|
"Claude CLI not found. This module only works when invoked "
|
||||||
|
"from the local MCP server — see the architectural rule in "
|
||||||
|
"the module docstring. If this error came from a FastAPI "
|
||||||
|
"endpoint in the container, refactor the call into an MCP "
|
||||||
|
"tool that the chair triggers from Claude Code."
|
||||||
|
)
|
||||||
|
|
||||||
if proc.returncode != 0:
|
try:
|
||||||
stderr = stderr_b.decode("utf-8", errors="replace").strip()[:500] or "unknown error"
|
stdout_b, stderr_b = await asyncio.wait_for(
|
||||||
size_info = f"; prompt_len={len(full_prompt):,} chars" if len(full_prompt) > 100_000 else ""
|
proc.communicate(input=full_prompt.encode("utf-8")),
|
||||||
raise RuntimeError(f"Claude CLI failed (exit {proc.returncode}): {stderr}{size_info}")
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
# wait_for cancellation alone leaves the child running. A timeout is
|
||||||
|
# a real ceiling, not a transient blip — don't retry.
|
||||||
|
try:
|
||||||
|
proc.kill()
|
||||||
|
await proc.wait()
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
raise RuntimeError(f"Claude CLI timed out after {timeout}s")
|
||||||
|
|
||||||
stdout = stdout_b.decode("utf-8", errors="replace").strip()
|
if proc.returncode != 0:
|
||||||
if not stdout:
|
stderr = stderr_b.decode("utf-8", errors="replace").strip()[:500] or "unknown error"
|
||||||
raise RuntimeError("Claude CLI returned empty response")
|
last_err = f"exit {proc.returncode}: {stderr}"
|
||||||
|
else:
|
||||||
|
stdout = stdout_b.decode("utf-8", errors="replace").strip()
|
||||||
|
if stdout:
|
||||||
|
# claude -p --output-format json returns {"type":"result","result":"..."}
|
||||||
|
try:
|
||||||
|
data = json.loads(stdout)
|
||||||
|
if isinstance(data, dict) and "result" in data:
|
||||||
|
return data["result"]
|
||||||
|
return stdout
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return stdout
|
||||||
|
last_err = "empty response"
|
||||||
|
|
||||||
# claude -p --output-format json returns {"type":"result","result":"..."}
|
# Transient failure — retry with linear backoff unless this was the last try.
|
||||||
try:
|
if attempt < MAX_RETRIES:
|
||||||
data = json.loads(stdout)
|
logger.warning(
|
||||||
if isinstance(data, dict) and "result" in data:
|
"claude -p attempt %d/%d failed (%s%s) — retrying in %ds",
|
||||||
return data["result"]
|
attempt, MAX_RETRIES, last_err, size_info, RETRY_BACKOFF_BASE * attempt,
|
||||||
return stdout
|
)
|
||||||
except json.JSONDecodeError:
|
await asyncio.sleep(RETRY_BACKOFF_BASE * attempt)
|
||||||
return stdout
|
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Claude CLI failed after {MAX_RETRIES} attempts ({last_err}){size_info}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def query_json(
|
async def query_json(
|
||||||
|
|||||||
Reference in New Issue
Block a user