devops-bot-sdk 1.4.8__tar.gz → 1.4.11__tar.gz

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.
Files changed (38) hide show
  1. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/pyproject.toml +1 -1
  4. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/__init__.py +2 -2
  5. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/client.py +30 -1
  6. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/ipc/handlers.py +131 -39
  7. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/local_exec.py +91 -11
  8. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/README.md +0 -0
  9. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/devops_bot_sdk.egg-info/SOURCES.txt +0 -0
  10. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  11. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
  12. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/devops_bot_sdk.egg-info/requires.txt +0 -0
  13. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  14. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/collectors/__init__.py +0 -0
  15. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/collectors/files.py +0 -0
  16. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/collectors/process.py +0 -0
  17. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/collectors/screenshot.py +0 -0
  18. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/config.py +0 -0
  19. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/crucial.py +0 -0
  20. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/exceptions.py +0 -0
  21. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/git_ops.py +0 -0
  22. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/graphify.py +0 -0
  23. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/hooks/__init__.py +0 -0
  24. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/hooks/crucial_guard.py +0 -0
  25. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/ipc/__init__.py +0 -0
  26. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/ipc/electron_bridge.py +0 -0
  27. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/models/__init__.py +0 -0
  28. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/models/envelope.py +0 -0
  29. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/models/requests.py +0 -0
  30. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/models/responses.py +0 -0
  31. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/models/snapshots.py +0 -0
  32. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/py.typed +0 -0
  33. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/run_auto.py +0 -0
  34. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/sse.py +0 -0
  35. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/test.py +0 -0
  36. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/test_pipeline.py +0 -0
  37. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/updater.py +0 -0
  38. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.4.8
3
+ Version: 1.4.11
4
4
  Summary: DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app
5
5
  Author: noumanaziz2128
6
6
  License-Expression: LicenseRef-Proprietary
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.4.8
3
+ Version: 1.4.11
4
4
  Summary: DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app
5
5
  Author: noumanaziz2128
6
6
  License-Expression: LicenseRef-Proprietary
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devops-bot-sdk"
7
- version = "1.4.8"
7
+ version = "1.4.11"
8
8
  description = "DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app"
9
9
  readme = "README.md"
10
10
  license = "LicenseRef-Proprietary"
@@ -1,6 +1,6 @@
1
1
  """AgentOS Desktop SDK — thin HTTPS/SSE client for the Electron app.
2
2
 
3
- Version: 1.4.8
3
+ Version: 1.4.11
4
4
 
5
5
  Public surface:
6
6
  BackendClient.from_config() — create client from ~/.agentos/config.toml
@@ -30,7 +30,7 @@ Rules:
30
30
  - All data egress through submit_webhook only
31
31
  """
32
32
 
33
- __version__ = "1.4.8"
33
+ __version__ = "1.4.11"
34
34
  __author__ = "AgentOS"
35
35
 
36
36
  from sdk.client import BackendClient
@@ -36,7 +36,7 @@ from sdk.sse import _check_status, stream_with_reconnect
36
36
 
37
37
  logger = logging.getLogger(__name__)
38
38
 
39
- SDK_VERSION = "1.4.8"
39
+ SDK_VERSION = "1.4.11"
40
40
  _POLL_INTERVAL = 3.0
41
41
  _POLL_TIMEOUT = 600.0
42
42
  _ORCHESTRATE_TIMEOUT = 2700.0 # 45 min — covers approval wait + VPS execution time
@@ -661,6 +661,35 @@ class BackendClient:
661
661
  except Exception:
662
662
  return None
663
663
 
664
+ async def move_to_backlog(self, task_id: str) -> bool:
665
+ """PUT {BE}/tasks-ml/{id} status=backlog (best-effort).
666
+
667
+ Used when a local run hits the Claude usage limit: the ticket goes BACK to
668
+ Backlog (not Done) and the loop retries it after the limit resets. Returns
669
+ True on success; logs and returns False otherwise (e.g. the ML backend
670
+ rejects the transition for a task with no running timer).
671
+ """
672
+ from sdk.config import BE_DEFAULT_URL
673
+
674
+ url = f"{BE_DEFAULT_URL.rstrip('/')}/tasks-ml/{task_id}"
675
+ # multipart form field `status=backlog` — drop any json Content-Type so
676
+ # httpx sets the multipart boundary itself.
677
+ headers = {
678
+ k: v for k, v in self._headers.items() if k.lower() != "content-type"
679
+ }
680
+ headers.update({"accept": "*/*", "developer-name": "null", "product": ""})
681
+ try:
682
+ async with httpx.AsyncClient(timeout=20.0) as client:
683
+ resp = await client.put(url, files={"status": (None, "backlog")}, headers=headers)
684
+ if resp.status_code >= 400:
685
+ logger.warning("client.move_to_backlog failed task=%s status=%s body=%s",
686
+ task_id, resp.status_code, resp.text[:200])
687
+ return False
688
+ return True
689
+ except Exception as exc: # noqa: BLE001
690
+ logger.warning("client.move_to_backlog error task=%s err=%s", task_id, exc)
691
+ return False
692
+
664
693
  async def notify_whatsapp(
665
694
  self,
666
695
  task_id: str,
@@ -29,6 +29,7 @@ from __future__ import annotations
29
29
 
30
30
  import asyncio
31
31
  import logging
32
+ import os
32
33
  import re
33
34
  from typing import Any, AsyncGenerator, Callable
34
35
 
@@ -711,6 +712,29 @@ async def handle_pipeline_approve(
711
712
  if pr_urls:
712
713
  agg_result += "\n\nPRs:\n" + "\n".join(pr_urls)
713
714
 
715
+ # ── Claude usage-limit hit: DON'T mark Done. Move the ticket back to Backlog
716
+ # and signal the auto-loop to retry this task after the window resets. ──
717
+ if any(r["result"].get("limit_exceeded") for r in runs):
718
+ reset_epoch = max((r["result"].get("limit_reset_epoch") or 0) for r in runs) or None
719
+ moved = await client.move_to_backlog(task_id)
720
+ await send(Envelope(
721
+ type="step_update", thread_id=thread_id,
722
+ data={"task_id": task_id,
723
+ "current_step": (("Claude usage limit reached — ticket moved to Backlog"
724
+ if moved else "Claude usage limit reached (Backlog move failed)")
725
+ + "; will retry after the limit resets")},
726
+ ).model_dump())
727
+ try:
728
+ await client.notify_whatsapp(
729
+ task_id,
730
+ f"⚠ Task {task_id}: Claude usage limit reached — moved to Backlog; "
731
+ "will auto-retry after the window resets.",
732
+ summary="usage limit",
733
+ )
734
+ except Exception: # noqa: BLE001
735
+ pass
736
+ return {"status": "limit_exceeded", "reset_epoch": reset_epoch, "task_id": task_id}
737
+
714
738
  # ── Notify on WhatsApp if nothing succeeded ──────────────────────────
715
739
  if not ok_any:
716
740
  err = "; ".join(str(r["result"].get("error") or "unknown") for r in runs) or "no repos processed"
@@ -1429,6 +1453,40 @@ _APPROVAL_POLL_TIMEOUT_S = 1200.0
1429
1453
  _MAX_CONTEXT_RESUMES = 3
1430
1454
  # Max mid-run crucial-decision approval gates per ticket (safety bound).
1431
1455
  _MAX_CRUCIAL_GATES = 10
1456
+ # Max times a ticket is retried after hitting the Claude usage limit.
1457
+ _MAX_LIMIT_RETRIES = 3
1458
+ # Cap on how long we'll sleep waiting for a usage-limit window to reset.
1459
+ _LIMIT_MAX_WAIT_S = 6 * 60 * 60.0
1460
+ # Fallback wait when the limit message carries no reset time.
1461
+ _LIMIT_FALLBACK_WAIT_S = float(os.getenv("AGENTOS_LIMIT_DEFAULT_WAIT_S", "") or 3600.0)
1462
+
1463
+
1464
+ def _limit_wait_seconds(reset_epoch: int | None) -> float:
1465
+ """Seconds to wait before retrying after a usage-limit hit.
1466
+
1467
+ Uses the reset timestamp the limit message carried (+60s buffer); otherwise a
1468
+ fallback. Always capped at _LIMIT_MAX_WAIT_S so a bad timestamp can't park the
1469
+ loop for days.
1470
+ """
1471
+ import time as _t
1472
+ if reset_epoch:
1473
+ wait = max(0.0, float(reset_epoch) - _t.time()) + 60.0
1474
+ else:
1475
+ wait = _LIMIT_FALLBACK_WAIT_S
1476
+ return min(wait, _LIMIT_MAX_WAIT_S)
1477
+
1478
+
1479
+ async def _cooperative_sleep(seconds: float, should_stop: Callable[[], bool] | None) -> bool:
1480
+ """Sleep `seconds`, checking should_stop periodically. Returns False if a stop
1481
+ was requested mid-sleep (so the caller can pause cleanly), else True."""
1482
+ waited = 0.0
1483
+ while waited < seconds:
1484
+ if should_stop is not None and should_stop():
1485
+ return False
1486
+ chunk = min(30.0, seconds - waited)
1487
+ await asyncio.sleep(chunk)
1488
+ waited += chunk
1489
+ return True
1432
1490
 
1433
1491
 
1434
1492
  async def _poll_approval(
@@ -1558,52 +1616,86 @@ async def handle_orchestrate_auto_local_run(
1558
1616
  # Per-ticket isolation: a failure here (network, git, a raising
1559
1617
  # repo) must NOT abort the remaining tickets — log it and move on.
1560
1618
  try:
1561
- # Phase 1 server plans + gates. NO local code/bash runs yet.
1562
- await _run_local_pipeline(client, task_payload, send)
1563
- session_id = next(
1564
- (sid for sid, p in _PENDING_LOCAL.items() if p.get("task_id") == tid),
1565
- None,
1566
- )
1567
- if not session_id:
1568
- # Planning never reached the approval gate for this task — skip it.
1569
- await send(Envelope(
1570
- type="step_update", thread_id=thread_id,
1571
- data={"current_step": f"{jira_key}: skipped (no approval gate reached)"},
1572
- ).model_dump())
1573
- continue
1619
+ # Retry the ticket if a run hits the Claude usage limit:
1620
+ # handle_pipeline_approve moves it to Backlog and signals here; we
1621
+ # wait for the window to reset and re-run — up to _MAX_LIMIT_RETRIES.
1622
+ attempt = 0
1623
+ while True:
1624
+ attempt += 1
1625
+ # Phase 1 — server plans + gates. NO local code/bash runs yet.
1626
+ await _run_local_pipeline(client, task_payload, send)
1627
+ session_id = next(
1628
+ (sid for sid, p in _PENDING_LOCAL.items() if p.get("task_id") == tid),
1629
+ None,
1630
+ )
1631
+ if not session_id:
1632
+ # Planning never reached the approval gate — skip this ticket.
1633
+ await send(Envelope(
1634
+ type="step_update", thread_id=thread_id,
1635
+ data={"current_step": f"{jira_key}: skipped (no approval gate reached)"},
1636
+ ).model_dump())
1637
+ break
1574
1638
 
1575
- # First-approval gate: when agentMode is ON, WAIT for the human's
1576
- # WhatsApp approval before ANY local code/bash. When OFF, proceed.
1577
- if agent_mode:
1578
- await send(Envelope(
1579
- type=Envelope.TYPE_AWAITING_APPROVAL, thread_id=thread_id,
1580
- data={"jira_key": jira_key, "session_id": session_id, "task_id": tid,
1581
- "current_step": f"{jira_key}: awaiting approval"},
1582
- ).model_dump())
1583
- decision = await _poll_approval(client, tid, should_stop)
1584
- if decision == "stopped":
1585
- _PENDING_LOCAL.pop(session_id, None)
1639
+ # First-approval gate: when agentMode is ON, WAIT for the human's
1640
+ # WhatsApp approval before ANY local code/bash. When OFF, proceed.
1641
+ if agent_mode:
1586
1642
  await send(Envelope(
1587
- type=Envelope.TYPE_DONE, thread_id=thread_id,
1588
- data={"mode": "orchestrate-auto-local", "status": "paused",
1589
- "processed": processed, "task_count": total},
1643
+ type=Envelope.TYPE_AWAITING_APPROVAL, thread_id=thread_id,
1644
+ data={"jira_key": jira_key, "session_id": session_id, "task_id": tid,
1645
+ "current_step": f"{jira_key}: awaiting approval"},
1590
1646
  ).model_dump())
1591
- return
1592
- if decision != "approved":
1593
- _PENDING_LOCAL.pop(session_id, None)
1647
+ decision = await _poll_approval(client, tid, should_stop)
1648
+ if decision == "stopped":
1649
+ _PENDING_LOCAL.pop(session_id, None)
1650
+ await send(Envelope(
1651
+ type=Envelope.TYPE_DONE, thread_id=thread_id,
1652
+ data={"mode": "orchestrate-auto-local", "status": "paused",
1653
+ "processed": processed, "task_count": total},
1654
+ ).model_dump())
1655
+ return
1656
+ if decision != "approved":
1657
+ _PENDING_LOCAL.pop(session_id, None)
1658
+ await send(Envelope(
1659
+ type="step_update", thread_id=thread_id,
1660
+ data={"current_step": f"{jira_key}: {decision} — skipped"},
1661
+ ).model_dump())
1662
+ break
1663
+
1664
+ # Phase 2 — run the user's local `claude`, report back.
1665
+ outcome = await handle_pipeline_approve(
1666
+ client,
1667
+ {"session_id": session_id, "decision": "approved", "thread_id": thread_id},
1668
+ send,
1669
+ )
1670
+
1671
+ # Usage limit → ticket is now in Backlog. Wait for reset + retry.
1672
+ if isinstance(outcome, dict) and outcome.get("status") == "limit_exceeded":
1673
+ if attempt <= _MAX_LIMIT_RETRIES:
1674
+ wait_s = _limit_wait_seconds(outcome.get("reset_epoch"))
1675
+ await send(Envelope(
1676
+ type="step_update", thread_id=thread_id,
1677
+ data={"current_step": f"{jira_key}: Claude usage limit — waiting "
1678
+ f"{round(wait_s / 60)}m for reset, then retry "
1679
+ f"({attempt}/{_MAX_LIMIT_RETRIES})"},
1680
+ ).model_dump())
1681
+ if not await _cooperative_sleep(wait_s, should_stop):
1682
+ await send(Envelope(
1683
+ type=Envelope.TYPE_DONE, thread_id=thread_id,
1684
+ data={"mode": "orchestrate-auto-local", "status": "paused",
1685
+ "processed": processed, "task_count": total},
1686
+ ).model_dump())
1687
+ return
1688
+ continue # re-run this ticket from phase 1
1689
+ # Out of retries — leave it in Backlog for a later run.
1594
1690
  await send(Envelope(
1595
1691
  type="step_update", thread_id=thread_id,
1596
- data={"current_step": f"{jira_key}: {decision}skipped"},
1692
+ data={"current_step": f"{jira_key}: usage limit max retries reached, "
1693
+ f"left in Backlog"},
1597
1694
  ).model_dump())
1598
- continue
1695
+ break
1599
1696
 
1600
- # Phase 2 — run the user's local `claude`, report back.
1601
- await handle_pipeline_approve(
1602
- client,
1603
- {"session_id": session_id, "decision": "approved", "thread_id": thread_id},
1604
- send,
1605
- )
1606
- processed += 1
1697
+ processed += 1
1698
+ break
1607
1699
  except Exception as exc: # noqa: BLE001 — isolate per-ticket failures
1608
1700
  logger.warning("orchestrate_auto.task_failed", extra={"task_id": tid, "error": str(exc)})
1609
1701
  await send(Envelope(
@@ -20,6 +20,7 @@ from __future__ import annotations
20
20
  import asyncio
21
21
  import json
22
22
  import os
23
+ import re
23
24
  import shutil
24
25
  from pathlib import Path
25
26
  from typing import Any, Awaitable, Callable
@@ -29,8 +30,21 @@ from typing import Any, Awaitable, Callable
29
30
  # user's machine, not the server.
30
31
  DEFAULT_ALLOWED_TOOLS = ["Read", "Write", "Edit", "Glob", "Grep", "Bash"]
31
32
 
32
- # Hard ceiling so a runaway agent can't hang the sidecar forever.
33
- _DEFAULT_TIMEOUT_S = 1800.0
33
+ # Run limits. The OLD design capped TOTAL runtime at 30 min, which killed long
34
+ # but healthy runs (big builds, many steps) mid-work. Instead we use an IDLE
35
+ # timeout — abort only if claude emits NOTHING for this long (truly stuck) — plus
36
+ # a generous absolute backstop so a runaway can't hang forever. Both env-tunable.
37
+ def _f(env: str, default: float) -> float:
38
+ try:
39
+ return float(os.getenv(env, "") or default)
40
+ except ValueError:
41
+ return default
42
+
43
+
44
+ # No output for this long → the run is stuck → abort.
45
+ _IDLE_TIMEOUT_S = _f("AGENTOS_LOCAL_IDLE_TIMEOUT_S", 900.0) # 15 min of silence
46
+ # Absolute backstop on total wall-clock regardless of activity.
47
+ _DEFAULT_TIMEOUT_S = _f("AGENTOS_LOCAL_MAX_S", 4 * 60 * 60.0) # 4 h
34
48
 
35
49
  # StreamReader buffer for the claude CLI's stdout. `--output-format stream-json`
36
50
  # emits one JSON object per line, and a single event can embed a whole file's
@@ -46,6 +60,37 @@ _CONTEXT_MARKERS = (
46
60
  "token limit", "prompt is too long", "exceeds the maximum", "context_length_exceeded",
47
61
  )
48
62
 
63
+ # Markers that indicate the Claude USAGE/RATE limit (the N-hour window) was hit —
64
+ # distinct from a context overflow. The run didn't fail on the task; the account
65
+ # is throttled until the window resets.
66
+ _LIMIT_MARKERS = (
67
+ "usage limit reached", "usage limit", "rate limit", "rate_limit",
68
+ "too many requests", "limit will reset", "limit reached", "overloaded_error",
69
+ "resets at", "429",
70
+ )
71
+ # A unix-seconds reset timestamp claude sometimes appends (e.g. "...reached|1719500400").
72
+ _RESET_EPOCH_RE = re.compile(r"\b(1[6-9]\d{8}|20\d{8})\b")
73
+ # Fallback wait when the limit message carries no reset time (env-tunable).
74
+ _LIMIT_DEFAULT_WAIT_S = _f("AGENTOS_LIMIT_DEFAULT_WAIT_S", 60 * 60.0) # 1 h
75
+
76
+
77
+ def _is_limit(text: str | None) -> bool:
78
+ low = (text or "").lower()
79
+ return any(m in low for m in _LIMIT_MARKERS)
80
+
81
+
82
+ def _parse_reset_epoch(text: str | None) -> int | None:
83
+ """A unix-seconds reset time embedded in a limit message, if present."""
84
+ if not text:
85
+ return None
86
+ m = _RESET_EPOCH_RE.search(text)
87
+ if m:
88
+ try:
89
+ return int(m.group(1))
90
+ except ValueError:
91
+ return None
92
+ return None
93
+
49
94
 
50
95
  def _ctx_exceeded(text: str | None) -> bool:
51
96
  low = (text or "").lower()
@@ -74,6 +119,7 @@ async def run_claude_local(
74
119
  github_token: str | None = None,
75
120
  resume_session_id: str | None = None,
76
121
  approval_dir: str | None = None,
122
+ idle_timeout: float = _IDLE_TIMEOUT_S,
77
123
  ) -> dict[str, Any]:
78
124
  """Run the local `claude` CLI non-interactively against ``project_path``.
79
125
 
@@ -168,13 +214,22 @@ async def run_claude_local(
168
214
  result_event: dict | None = None
169
215
  files_changed: list[str] = []
170
216
  session_id: str | None = resume_session_id
217
+ import time as _time
218
+ started = _time.monotonic()
219
+ stall = {"timed_out": False, "reason": ""}
171
220
 
172
221
  async def _drain_stdout() -> None:
173
222
  nonlocal result_event, session_id
174
223
  assert proc.stdout is not None
175
224
  while True:
176
225
  try:
177
- raw = await proc.stdout.readline()
226
+ # IDLE timeout: abort only if claude produces NO output for this
227
+ # long (genuinely stuck), so long-but-active runs aren't killed.
228
+ raw = await asyncio.wait_for(proc.stdout.readline(), timeout=idle_timeout)
229
+ except asyncio.TimeoutError:
230
+ stall["timed_out"] = True
231
+ stall["reason"] = f"no output for {idle_timeout:.0f}s (stuck)"
232
+ return
178
233
  except ValueError:
179
234
  # Line exceeded even _STREAM_LIMIT_BYTES. readline() has already
180
235
  # advanced past the oversized chunk, so we can safely skip this
@@ -182,6 +237,11 @@ async def run_claude_local(
182
237
  continue
183
238
  if not raw:
184
239
  break
240
+ # Absolute backstop on total wall-clock (checked as events arrive).
241
+ if _time.monotonic() - started > timeout:
242
+ stall["timed_out"] = True
243
+ stall["reason"] = f"exceeded absolute cap {timeout:.0f}s"
244
+ return
185
245
  line = raw.decode("utf-8", errors="replace").strip()
186
246
  if not line:
187
247
  continue
@@ -212,16 +272,20 @@ async def run_claude_local(
212
272
  except Exception:
213
273
  pass # progress forwarding is best-effort
214
274
 
215
- try:
216
- await asyncio.wait_for(_drain_stdout(), timeout=timeout)
217
- await asyncio.wait_for(proc.wait(), timeout=30.0)
218
- except asyncio.TimeoutError:
275
+ await _drain_stdout()
276
+ if not stall["timed_out"]:
277
+ try:
278
+ await asyncio.wait_for(proc.wait(), timeout=30.0)
279
+ except asyncio.TimeoutError:
280
+ stall["timed_out"] = True
281
+ stall["reason"] = "process did not exit after the stream closed"
282
+ if stall["timed_out"]:
219
283
  try:
220
284
  proc.kill()
221
285
  except ProcessLookupError:
222
286
  pass
223
287
  return {
224
- "ok": False, "result": "", "error": f"claude CLI timed out after {timeout:.0f}s",
288
+ "ok": False, "result": "", "error": f"claude CLI timed out {stall['reason']}",
225
289
  "input_tokens": 0, "output_tokens": 0, "total_tokens": 0,
226
290
  "cost_usd": 0.0, "files_changed": files_changed,
227
291
  "session_id": session_id, "context_exceeded": False,
@@ -235,17 +299,31 @@ async def run_claude_local(
235
299
  "input_tokens": 0, "output_tokens": 0, "total_tokens": 0,
236
300
  "cost_usd": 0.0, "files_changed": files_changed,
237
301
  "session_id": session_id, "context_exceeded": _ctx_exceeded(err),
302
+ "limit_exceeded": _is_limit(err), "limit_reset_epoch": _parse_reset_epoch(err),
238
303
  }
239
304
 
240
305
  usage = result_event.get("usage") or {}
241
306
  in_tok = int(usage.get("input_tokens", 0) or 0)
242
307
  out_tok = int(usage.get("output_tokens", 0) or 0)
243
308
  is_error = bool(result_event.get("is_error", False))
309
+ result_text = str(result_event.get("result", ""))
310
+ subtype = str(result_event.get("subtype", ""))
311
+
312
+ # Claude USAGE/RATE limit: detected from the result/subtype even when the CLI
313
+ # didn't flag is_error — so a limit-truncated run is NOT treated as success
314
+ # (which would wrongly mark the ticket Done).
315
+ limit_hit = _is_limit(result_text) or "limit" in subtype.lower()
316
+ reset_epoch = _parse_reset_epoch(result_text)
317
+
318
+ err_text = None
319
+ if is_error:
320
+ err_text = result_text or "claude reported an error"
321
+ elif limit_hit:
322
+ err_text = result_text or "claude usage limit reached"
244
323
 
245
- err_text = None if not is_error else str(result_event.get("result", "claude reported an error"))
246
324
  return {
247
- "ok": not is_error,
248
- "result": str(result_event.get("result", "")),
325
+ "ok": (not is_error) and (not limit_hit),
326
+ "result": result_text,
249
327
  "input_tokens": in_tok,
250
328
  "output_tokens": out_tok,
251
329
  "total_tokens": in_tok + out_tok,
@@ -254,4 +332,6 @@ async def run_claude_local(
254
332
  "error": err_text,
255
333
  "session_id": session_id,
256
334
  "context_exceeded": is_error and _ctx_exceeded(err_text),
335
+ "limit_exceeded": limit_hit,
336
+ "limit_reset_epoch": reset_epoch,
257
337
  }