Skip to content

Commit 270bb8c

Browse files
MagicMock/mock.effective_git_name/139641991453328claude
authored andcommitted
Distinguish timeouts from usage limits and enable seamless resumption
Timeouts were misclassified as UsageLimitError, causing false "usage limit hit" log messages and — for CLI backend — losing all session state so retries started from scratch. This introduces InvocationTimeoutError as a separate exception and ensures conversation state is saved before the timeout propagates: - API backend: catch TimeoutError, save conversation + commit WIP - CLI backend: pre-generate session ID via --session-id and save it before subprocess.run, so it survives timeouts - Task files: catch InvocationTimeoutError with accurate log messages Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ee9192c commit 270bb8c

6 files changed

Lines changed: 184 additions & 38 deletions

File tree

src/clayde/claude.py

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import shutil
88
import subprocess
99
import time
10+
import uuid
1011
from abc import ABC, abstractmethod
1112
from pathlib import Path
1213

@@ -61,6 +62,18 @@ def __init__(self, message: str, cost_eur: float = 0.0):
6162
self.cost_eur = cost_eur
6263

6364

65+
class InvocationTimeoutError(Exception):
66+
"""Raised when a Claude invocation exceeds the configured timeout.
67+
68+
Distinct from UsageLimitError — timeouts are not rate limits.
69+
WIP is committed and conversation state is saved for seamless resumption.
70+
"""
71+
72+
def __init__(self, message: str, cost_eur: float = 0.0):
73+
super().__init__(message)
74+
self.cost_eur = cost_eur
75+
76+
6477
def format_cost_line(cost_eur: float) -> str:
6578
"""Format a cost line for inclusion in GitHub comments.
6679
@@ -338,6 +351,17 @@ def invoke(
338351
repo_path=repo_path, span=span, timeout_s=tool_loop_timeout_s,
339352
token_counter=token_counter,
340353
)
354+
except TimeoutError as e:
355+
log.error("Claude API tool loop timed out after %ds", tool_loop_timeout_s)
356+
if branch_name:
357+
commit_wip(repo_path, branch_name)
358+
if conversation_path:
359+
self._save_conversation(conversation_path, messages)
360+
partial_cost_eur = _calculate_cost_usd(model, token_counter["input"], token_counter["output"]) * _EUR_PER_USD
361+
span.set_attribute("claude.timeout", True)
362+
timeout_exc = InvocationTimeoutError(str(e), cost_eur=partial_cost_eur)
363+
span.record_exception(timeout_exc)
364+
raise timeout_exc from e
341365
except anthropic.APIConnectionError as e:
342366
log.error("Claude API connection error: %s", e)
343367
raise self._build_usage_limit_error(
@@ -477,23 +501,37 @@ def invoke(
477501
span.set_attribute("claude.backend", "cli")
478502
span.set_attribute("claude.cli_bin", cli_bin)
479503

504+
# Determine session ID: load existing or generate new
505+
session_id = None
506+
resumed = False
507+
if conversation_path:
508+
session_id = self._load_session_id(conversation_path)
509+
if session_id:
510+
resumed = True
511+
512+
if not session_id:
513+
session_id = str(uuid.uuid4())
514+
480515
cmd = [
481516
cli_bin, "-p", prompt,
482517
"--append-system-prompt", identity,
483518
"--output-format", "json",
484519
"--dangerously-skip-permissions",
485520
]
486521

487-
# Resume from a previous session if available
522+
if resumed:
523+
cmd.extend(["--resume", session_id])
524+
span.set_attribute("claude.resumed", True)
525+
span.set_attribute("claude.resumed_session_id", session_id)
526+
log.info("Resuming CLI session %s", session_id)
527+
else:
528+
cmd.extend(["--session-id", session_id])
529+
span.set_attribute("claude.resumed", False)
530+
span.set_attribute("claude.session_id", session_id)
531+
532+
# Save session ID immediately so it survives timeouts
488533
if conversation_path:
489-
session_id = self._load_session_id(conversation_path)
490-
if session_id:
491-
cmd.extend(["--resume", session_id])
492-
span.set_attribute("claude.resumed", True)
493-
span.set_attribute("claude.resumed_session_id", session_id)
494-
log.info("Resuming CLI session %s", session_id)
495-
else:
496-
span.set_attribute("claude.resumed", False)
534+
self._save_session_id(conversation_path, session_id)
497535

498536
try:
499537
result = subprocess.run(
@@ -505,7 +543,7 @@ def invoke(
505543
if branch_name:
506544
commit_wip(repo_path, branch_name)
507545
span.set_attribute("claude.timeout", True)
508-
exc = UsageLimitError(f"Claude CLI timed out after {timeout_s}s")
546+
exc = InvocationTimeoutError(f"Claude CLI timed out after {timeout_s}s")
509547
span.record_exception(exc)
510548
raise exc
511549

@@ -515,14 +553,15 @@ def invoke(
515553
and "no conversation found with session id" in (result.stderr or "").lower()
516554
and conversation_path
517555
):
518-
log.warning("CLI session not found — deleting stale conversation file and retrying fresh")
519-
conversation_path.unlink(missing_ok=True)
520-
# Rebuild command without --resume
556+
log.warning("CLI session not found — retrying with new session")
557+
session_id = str(uuid.uuid4())
558+
self._save_session_id(conversation_path, session_id)
521559
cmd = [
522560
cli_bin, "-p", prompt,
523561
"--append-system-prompt", identity,
524562
"--output-format", "json",
525563
"--dangerously-skip-permissions",
564+
"--session-id", session_id,
526565
]
527566
span.set_attribute("claude.stale_session_retry", True)
528567
try:
@@ -535,7 +574,7 @@ def invoke(
535574
if branch_name:
536575
commit_wip(repo_path, branch_name)
537576
span.set_attribute("claude.timeout", True)
538-
exc = UsageLimitError(f"Claude CLI timed out after {timeout_s}s")
577+
exc = InvocationTimeoutError(f"Claude CLI timed out after {timeout_s}s")
539578
span.record_exception(exc)
540579
raise exc
541580

src/clayde/tasks/implement.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import subprocess
99
from pathlib import Path
1010

11-
from clayde.claude import UsageLimitError, format_cost_line, invoke_claude
11+
from clayde.claude import InvocationTimeoutError, UsageLimitError, format_cost_line, invoke_claude
1212
from clayde.config import DATA_DIR, get_github_client, get_settings
1313
from clayde.git import ensure_repo
1414
from clayde.prompts import collect_comments_after, render_template
@@ -85,6 +85,15 @@ def run(issue_url: str) -> None:
8585
"interrupted_phase": IssueStatus.IMPLEMENTING,
8686
})
8787
return
88+
except InvocationTimeoutError as e:
89+
log.warning("[%s: %s] Timed out during implementation — will resume next cycle", issue_ref(owner, repo, number), issue.title)
90+
accumulate_cost(issue_url, e.cost_eur)
91+
span.set_attribute("implement.status", "timeout")
92+
update_issue_state(issue_url, {
93+
"status": IssueStatus.INTERRUPTED,
94+
"interrupted_phase": IssueStatus.IMPLEMENTING,
95+
})
96+
return
8897

8998
total_cost = pop_accumulated_cost(issue_url) + result.cost_eur
9099

src/clayde/tasks/plan.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from github import Github
1212
from github.Issue import Issue
1313

14-
from clayde.claude import UsageLimitError, format_cost_line, invoke_claude
14+
from clayde.claude import InvocationTimeoutError, UsageLimitError, format_cost_line, invoke_claude
1515
from clayde.config import get_github_client
1616
from clayde.git import ensure_repo
1717
from clayde.github import (
@@ -65,10 +65,11 @@ def run_preliminary(issue_url: str) -> None:
6565
log.info("[%s: %s] Invoking Claude for preliminary plan", issue_ref(owner, repo, number), issue.title)
6666
try:
6767
result = invoke_claude(prompt, repo_path)
68-
except UsageLimitError as e:
69-
log.warning("Usage limit hit during preliminary planning #%d", number)
68+
except (UsageLimitError, InvocationTimeoutError) as e:
69+
label = "Timed out" if isinstance(e, InvocationTimeoutError) else "Usage limit hit"
70+
log.warning("%s during preliminary planning #%d", label, number)
7071
accumulate_cost(issue_url, e.cost_eur)
71-
span.set_attribute("plan.status", "limit")
72+
span.set_attribute("plan.status", "timeout" if isinstance(e, InvocationTimeoutError) else "limit")
7273
update_issue_state(issue_url, {
7374
"status": IssueStatus.INTERRUPTED,
7475
"interrupted_phase": IssueStatus.PRELIMINARY_PLANNING,
@@ -161,10 +162,11 @@ def run_thorough(issue_url: str) -> None:
161162
log.info("[%s: %s] Invoking Claude for thorough plan", issue_ref(owner, repo, number), issue.title)
162163
try:
163164
result = invoke_claude(prompt, repo_path)
164-
except UsageLimitError as e:
165-
log.warning("Usage limit hit during thorough planning #%d", number)
165+
except (UsageLimitError, InvocationTimeoutError) as e:
166+
label = "Timed out" if isinstance(e, InvocationTimeoutError) else "Usage limit hit"
167+
log.warning("%s during thorough planning #%d", label, number)
166168
accumulate_cost(issue_url, e.cost_eur)
167-
span.set_attribute("plan.status", "limit")
169+
span.set_attribute("plan.status", "timeout" if isinstance(e, InvocationTimeoutError) else "limit")
168170
update_issue_state(issue_url, {
169171
"status": IssueStatus.INTERRUPTED,
170172
"interrupted_phase": IssueStatus.PLANNING,
@@ -278,10 +280,11 @@ def run_update(issue_url: str, phase: str) -> None:
278280
log.info("[%s: %s] Invoking Claude for plan update (%s phase)", issue_ref(owner, repo, number), issue.title, phase)
279281
try:
280282
result = invoke_claude(prompt, repo_path)
281-
except UsageLimitError as e:
282-
log.warning("Usage limit hit during plan update #%d", number)
283+
except (UsageLimitError, InvocationTimeoutError) as e:
284+
label = "Timed out" if isinstance(e, InvocationTimeoutError) else "Usage limit hit"
285+
log.warning("%s during plan update #%d", label, number)
283286
accumulate_cost(issue_url, e.cost_eur)
284-
span.set_attribute("plan.update_status", "limit")
287+
span.set_attribute("plan.update_status", "timeout" if isinstance(e, InvocationTimeoutError) else "limit")
285288
update_issue_state(issue_url, {
286289
"status": IssueStatus.INTERRUPTED,
287290
"interrupted_phase": IssueStatus.PRELIMINARY_PLANNING if phase == "preliminary" else IssueStatus.PLANNING,

src/clayde/tasks/review.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import logging
44

5-
from clayde.claude import UsageLimitError, format_cost_line, invoke_claude
5+
from clayde.claude import InvocationTimeoutError, UsageLimitError, format_cost_line, invoke_claude
66
from clayde.config import DATA_DIR, get_github_client, get_settings
77
from clayde.git import ensure_repo
88
from clayde.prompts import render_template
@@ -112,10 +112,11 @@ def run(issue_url: str) -> None:
112112
branch_name=branch_name,
113113
conversation_path=conversation_path,
114114
)
115-
except UsageLimitError as e:
116-
log.warning("[%s] Usage limit hit during review handling", issue_label)
115+
except (UsageLimitError, InvocationTimeoutError) as e:
116+
label_msg = "Timed out" if isinstance(e, InvocationTimeoutError) else "Usage limit hit"
117+
log.warning("[%s] %s during review handling", issue_label, label_msg)
117118
accumulate_cost(issue_url, e.cost_eur)
118-
span.set_attribute("review.status", "limit")
119+
span.set_attribute("review.status", "timeout" if isinstance(e, InvocationTimeoutError) else "limit")
119120
update_issue_state(issue_url, {
120121
"status": IssueStatus.INTERRUPTED,
121122
"interrupted_phase": IssueStatus.ADDRESSING_REVIEW,

tests/test_claude.py

Lines changed: 79 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
ApiBackend,
1212
CliBackend,
1313
InvocationResult,
14+
InvocationTimeoutError,
1415
UsageLimitError,
1516
_calculate_cost_usd,
1617
_get_backend,
@@ -333,9 +334,39 @@ def fake_monotonic():
333334
patch.object(backend, "_get_client", return_value=mock_client), \
334335
patch.object(ApiBackend, "_execute_tool", return_value="output"), \
335336
patch("clayde.claude.time.monotonic", side_effect=fake_monotonic):
336-
with pytest.raises(TimeoutError):
337+
with pytest.raises(InvocationTimeoutError):
337338
backend.invoke("implement", str(tmp_path))
338339

340+
def test_tool_loop_timeout_saves_conversation(self, tmp_path):
341+
(tmp_path / "CLAUDE.md").write_text("identity")
342+
conv_path = tmp_path / "conv.json"
343+
tool_block = _make_tool_use_block("bash", "tool-1", {"command": "echo loop"})
344+
tool_response = _make_tool_response([tool_block])
345+
mock_client = MagicMock()
346+
mock_client.beta.messages.create.return_value = tool_response
347+
backend = ApiBackend()
348+
349+
call_count = [0]
350+
def fake_monotonic():
351+
call_count[0] += 1
352+
if call_count[0] <= 1:
353+
return 0.0
354+
return 2000.0
355+
356+
with patch("clayde.claude.APP_DIR", tmp_path), \
357+
patch("clayde.claude.get_settings", return_value=_mock_settings()), \
358+
patch.object(backend, "_get_client", return_value=mock_client), \
359+
patch.object(ApiBackend, "_execute_tool", return_value="output"), \
360+
patch("clayde.claude.time.monotonic", side_effect=fake_monotonic), \
361+
patch("clayde.claude.commit_wip") as mock_wip:
362+
with pytest.raises(InvocationTimeoutError) as exc_info:
363+
backend.invoke("implement", str(tmp_path),
364+
branch_name="branch", conversation_path=conv_path)
365+
mock_wip.assert_called_once_with(str(tmp_path), "branch")
366+
367+
assert conv_path.exists()
368+
assert exc_info.value.cost_eur >= 0.0
369+
339370
def test_token_usage_accumulated_across_turns(self, tmp_path):
340371
(tmp_path / "CLAUDE.md").write_text("identity")
341372
tool_block = _make_tool_use_block("bash", "t1", {"command": "echo x"})
@@ -652,14 +683,15 @@ def test_saves_session_id(self, tmp_path):
652683

653684
assert conv_path.exists()
654685
data = json.loads(conv_path.read_text())
686+
# Session ID from response overwrites the pre-generated one
655687
assert data["session_id"] == "my-session-id"
656688

657689
def test_resumes_from_session_id(self, tmp_path):
658690
(tmp_path / "CLAUDE.md").write_text("identity")
659691
conv_path = tmp_path / "conv.json"
660692
conv_path.write_text(json.dumps({"session_id": "prev-session"}))
661693
mock_result = MagicMock()
662-
mock_result.stdout = self._cli_json_output("resumed")
694+
mock_result.stdout = self._cli_json_output("resumed", "prev-session")
663695
mock_result.stderr = ""
664696
mock_result.returncode = 0
665697
backend = CliBackend()
@@ -731,7 +763,7 @@ def test_limit_saves_session_before_raising(self, tmp_path):
731763
data = json.loads(conv_path.read_text())
732764
assert data["session_id"] == "limit-session"
733765

734-
def test_timeout_raises_usage_limit_error(self, tmp_path):
766+
def test_timeout_raises_invocation_timeout_error(self, tmp_path):
735767
(tmp_path / "CLAUDE.md").write_text("identity")
736768
backend = CliBackend()
737769

@@ -740,10 +772,48 @@ def test_timeout_raises_usage_limit_error(self, tmp_path):
740772
patch("clayde.claude._resolve_cli_bin", return_value="/usr/bin/claude"), \
741773
patch("clayde.claude.subprocess.run", side_effect=__import__("subprocess").TimeoutExpired("claude", 1800)), \
742774
patch("clayde.claude.commit_wip") as mock_wip:
743-
with pytest.raises(UsageLimitError):
775+
with pytest.raises(InvocationTimeoutError):
744776
backend.invoke("prompt", "/repo", branch_name="branch")
745777
mock_wip.assert_called_once_with("/repo", "branch")
746778

779+
def test_timeout_saves_session_id_for_resumption(self, tmp_path):
780+
"""When a fresh CLI session times out, the pre-generated session ID is saved for resumption."""
781+
(tmp_path / "CLAUDE.md").write_text("identity")
782+
conv_path = tmp_path / "conv.json"
783+
backend = CliBackend()
784+
785+
with patch("clayde.claude.APP_DIR", tmp_path), \
786+
patch("clayde.claude.get_settings", return_value=_mock_settings(backend="cli")), \
787+
patch("clayde.claude._resolve_cli_bin", return_value="/usr/bin/claude"), \
788+
patch("clayde.claude.subprocess.run", side_effect=__import__("subprocess").TimeoutExpired("claude", 1800)), \
789+
patch("clayde.claude.commit_wip"):
790+
with pytest.raises(InvocationTimeoutError):
791+
backend.invoke("prompt", "/repo", branch_name="branch", conversation_path=conv_path)
792+
793+
# Session ID should be saved even though the process timed out
794+
assert conv_path.exists()
795+
data = json.loads(conv_path.read_text())
796+
assert data["session_id"] # a UUID was generated and saved
797+
798+
def test_timeout_preserves_session_id_for_resumed(self, tmp_path):
799+
"""When a resumed CLI session times out, the session ID is preserved for next resumption."""
800+
(tmp_path / "CLAUDE.md").write_text("identity")
801+
conv_path = tmp_path / "conv.json"
802+
conv_path.write_text(json.dumps({"session_id": "my-session"}))
803+
backend = CliBackend()
804+
805+
with patch("clayde.claude.APP_DIR", tmp_path), \
806+
patch("clayde.claude.get_settings", return_value=_mock_settings(backend="cli")), \
807+
patch("clayde.claude._resolve_cli_bin", return_value="/usr/bin/claude"), \
808+
patch("clayde.claude.subprocess.run", side_effect=__import__("subprocess").TimeoutExpired("claude", 1800)), \
809+
patch("clayde.claude.commit_wip"):
810+
with pytest.raises(InvocationTimeoutError):
811+
backend.invoke("prompt", "/repo", branch_name="branch", conversation_path=conv_path)
812+
813+
# Session ID should still be in the conversation file
814+
data = json.loads(conv_path.read_text())
815+
assert data["session_id"] == "my-session"
816+
747817
def test_fallback_on_non_json_stdout(self, tmp_path):
748818
(tmp_path / "CLAUDE.md").write_text("identity")
749819
mock_result = MagicMock()
@@ -760,8 +830,8 @@ def test_fallback_on_non_json_stdout(self, tmp_path):
760830

761831
assert result.output == "plain text output"
762832

763-
def test_stale_session_retries_fresh(self, tmp_path):
764-
"""When CLI reports 'No conversation found', delete conv file and retry without --resume."""
833+
def test_stale_session_retries_with_new_session_id(self, tmp_path):
834+
"""When CLI reports 'No conversation found', retry with a new session ID."""
765835
(tmp_path / "CLAUDE.md").write_text("identity")
766836
conv_path = tmp_path / "conv.json"
767837
conv_path.write_text(json.dumps({"session_id": "stale-session"}))
@@ -788,12 +858,13 @@ def test_stale_session_retries_fresh(self, tmp_path):
788858
result = backend.invoke("prompt", str(tmp_path), conversation_path=conv_path)
789859

790860
assert result.output == "fresh output"
791-
# First call should have --resume, second should not
861+
# First call should have --resume, second should have --session-id (new UUID)
792862
first_cmd = mock_run.call_args_list[0][0][0]
793863
second_cmd = mock_run.call_args_list[1][0][0]
794864
assert "--resume" in first_cmd
795865
assert "--resume" not in second_cmd
796-
# Conv file should now have the new session ID
866+
assert "--session-id" in second_cmd
867+
# Conv file should now have the new session ID from the response
797868
data = json.loads(conv_path.read_text())
798869
assert data["session_id"] == "new-session"
799870

0 commit comments

Comments
 (0)