devops-bot-sdk 1.4.8__tar.gz → 1.4.13__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.13}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.13}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.13}/pyproject.toml +1 -1
  4. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.13}/sdk/__init__.py +2 -2
  5. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.13}/sdk/client.py +30 -1
  6. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.13}/sdk/ipc/handlers.py +149 -40
  7. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.13}/sdk/local_exec.py +91 -11
  8. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.13}/README.md +0 -0
  9. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.13}/devops_bot_sdk.egg-info/SOURCES.txt +0 -0
  10. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.13}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  11. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.13}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
  12. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.13}/devops_bot_sdk.egg-info/requires.txt +0 -0
  13. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.13}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  14. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.13}/sdk/collectors/__init__.py +0 -0
  15. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.13}/sdk/collectors/files.py +0 -0
  16. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.13}/sdk/collectors/process.py +0 -0
  17. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.13}/sdk/collectors/screenshot.py +0 -0
  18. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.13}/sdk/config.py +0 -0
  19. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.13}/sdk/crucial.py +0 -0
  20. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.13}/sdk/exceptions.py +0 -0
  21. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.13}/sdk/git_ops.py +0 -0
  22. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.13}/sdk/graphify.py +0 -0
  23. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.13}/sdk/hooks/__init__.py +0 -0
  24. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.13}/sdk/hooks/crucial_guard.py +0 -0
  25. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.13}/sdk/ipc/__init__.py +0 -0
  26. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.13}/sdk/ipc/electron_bridge.py +0 -0
  27. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.13}/sdk/models/__init__.py +0 -0
  28. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.13}/sdk/models/envelope.py +0 -0
  29. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.13}/sdk/models/requests.py +0 -0
  30. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.13}/sdk/models/responses.py +0 -0
  31. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.13}/sdk/models/snapshots.py +0 -0
  32. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.13}/sdk/py.typed +0 -0
  33. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.13}/sdk/run_auto.py +0 -0
  34. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.13}/sdk/sse.py +0 -0
  35. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.13}/sdk/test.py +0 -0
  36. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.13}/sdk/test_pipeline.py +0 -0
  37. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.13}/sdk/updater.py +0 -0
  38. {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.13}/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.13
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.13
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.13"
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.13
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.13"
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.13"
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"
@@ -1180,7 +1204,13 @@ _PATH_ROOTS = (
1180
1204
  )
1181
1205
  _ABS_PATH_RE = re.compile(rf"(?<![\w.])/(?:{_PATH_ROOTS})(?:/[A-Za-z0-9._\-]+)+")
1182
1206
  _HOME_PATH_RE = re.compile(r"(?<![\w.])~(?:/[A-Za-z0-9._\-]+)+")
1183
- _WIN_PATH_RE = re.compile(r"[A-Za-z]:\\(?:[^\s\\/:*?\"<>|]+\\?)+")
1207
+ # Windows path. Folder names may contain SPACES (e.g. "BA activities clone"), so a
1208
+ # segment is any run of valid path chars (everything except \ / : * ? " < > | and
1209
+ # newline) up to the next separator — it must NOT stop at the first space.
1210
+ _WIN_PATH_RE = re.compile(r"[A-Za-z]:\\(?:[^\\/:*?\"<>|\r\n]+\\?)+")
1211
+ # A QUOTED path (single/double/back quotes) — the reliable way to give a path that
1212
+ # contains spaces, on any OS. Captures the full content between the quotes.
1213
+ _QUOTED_PATH_RE = re.compile(r"""["'`]\s*((?:[A-Za-z]:\\|~/|/)[^"'`\r\n]+?)\s*["'`]""")
1184
1214
  _FILE_SEG_RE = re.compile(r"\.[A-Za-z0-9]{1,6}$")
1185
1215
 
1186
1216
 
@@ -1217,6 +1247,13 @@ def _extract_path_from_text(text: str | None) -> str | None:
1217
1247
  return None
1218
1248
  from pathlib import Path
1219
1249
 
1250
+ # Quoted path first — handles spaces deterministically (any OS).
1251
+ m = _QUOTED_PATH_RE.search(text)
1252
+ if m:
1253
+ raw = _clean_path(m.group(1))
1254
+ if raw.startswith("~/"):
1255
+ return _dir_of(str(Path.home() / raw[2:])) or None
1256
+ return _dir_of(raw) or None
1220
1257
  m = _ABS_PATH_RE.search(text)
1221
1258
  if m:
1222
1259
  return _dir_of(_clean_path(m.group(0))) or None
@@ -1243,6 +1280,10 @@ def _extract_all_paths(text: str | None) -> list[str]:
1243
1280
  from pathlib import Path
1244
1281
 
1245
1282
  found: list[tuple[int, str]] = []
1283
+ for m in _QUOTED_PATH_RE.finditer(text):
1284
+ raw = _clean_path(m.group(1))
1285
+ p = str(Path.home() / raw[2:]) if raw.startswith("~/") else raw
1286
+ found.append((m.start(), _dir_of(p)))
1246
1287
  for m in _ABS_PATH_RE.finditer(text):
1247
1288
  found.append((m.start(), _dir_of(_clean_path(m.group(0)))))
1248
1289
  for m in _HOME_PATH_RE.finditer(text):
@@ -1429,6 +1470,40 @@ _APPROVAL_POLL_TIMEOUT_S = 1200.0
1429
1470
  _MAX_CONTEXT_RESUMES = 3
1430
1471
  # Max mid-run crucial-decision approval gates per ticket (safety bound).
1431
1472
  _MAX_CRUCIAL_GATES = 10
1473
+ # Max times a ticket is retried after hitting the Claude usage limit.
1474
+ _MAX_LIMIT_RETRIES = 3
1475
+ # Cap on how long we'll sleep waiting for a usage-limit window to reset.
1476
+ _LIMIT_MAX_WAIT_S = 6 * 60 * 60.0
1477
+ # Fallback wait when the limit message carries no reset time.
1478
+ _LIMIT_FALLBACK_WAIT_S = float(os.getenv("AGENTOS_LIMIT_DEFAULT_WAIT_S", "") or 3600.0)
1479
+
1480
+
1481
+ def _limit_wait_seconds(reset_epoch: int | None) -> float:
1482
+ """Seconds to wait before retrying after a usage-limit hit.
1483
+
1484
+ Uses the reset timestamp the limit message carried (+60s buffer); otherwise a
1485
+ fallback. Always capped at _LIMIT_MAX_WAIT_S so a bad timestamp can't park the
1486
+ loop for days.
1487
+ """
1488
+ import time as _t
1489
+ if reset_epoch:
1490
+ wait = max(0.0, float(reset_epoch) - _t.time()) + 60.0
1491
+ else:
1492
+ wait = _LIMIT_FALLBACK_WAIT_S
1493
+ return min(wait, _LIMIT_MAX_WAIT_S)
1494
+
1495
+
1496
+ async def _cooperative_sleep(seconds: float, should_stop: Callable[[], bool] | None) -> bool:
1497
+ """Sleep `seconds`, checking should_stop periodically. Returns False if a stop
1498
+ was requested mid-sleep (so the caller can pause cleanly), else True."""
1499
+ waited = 0.0
1500
+ while waited < seconds:
1501
+ if should_stop is not None and should_stop():
1502
+ return False
1503
+ chunk = min(30.0, seconds - waited)
1504
+ await asyncio.sleep(chunk)
1505
+ waited += chunk
1506
+ return True
1432
1507
 
1433
1508
 
1434
1509
  async def _poll_approval(
@@ -1558,52 +1633,86 @@ async def handle_orchestrate_auto_local_run(
1558
1633
  # Per-ticket isolation: a failure here (network, git, a raising
1559
1634
  # repo) must NOT abort the remaining tickets — log it and move on.
1560
1635
  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
1636
+ # Retry the ticket if a run hits the Claude usage limit:
1637
+ # handle_pipeline_approve moves it to Backlog and signals here; we
1638
+ # wait for the window to reset and re-run — up to _MAX_LIMIT_RETRIES.
1639
+ attempt = 0
1640
+ while True:
1641
+ attempt += 1
1642
+ # Phase 1 — server plans + gates. NO local code/bash runs yet.
1643
+ await _run_local_pipeline(client, task_payload, send)
1644
+ session_id = next(
1645
+ (sid for sid, p in _PENDING_LOCAL.items() if p.get("task_id") == tid),
1646
+ None,
1647
+ )
1648
+ if not session_id:
1649
+ # Planning never reached the approval gate — skip this ticket.
1650
+ await send(Envelope(
1651
+ type="step_update", thread_id=thread_id,
1652
+ data={"current_step": f"{jira_key}: skipped (no approval gate reached)"},
1653
+ ).model_dump())
1654
+ break
1574
1655
 
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)
1656
+ # First-approval gate: when agentMode is ON, WAIT for the human's
1657
+ # WhatsApp approval before ANY local code/bash. When OFF, proceed.
1658
+ if agent_mode:
1586
1659
  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},
1660
+ type=Envelope.TYPE_AWAITING_APPROVAL, thread_id=thread_id,
1661
+ data={"jira_key": jira_key, "session_id": session_id, "task_id": tid,
1662
+ "current_step": f"{jira_key}: awaiting approval"},
1590
1663
  ).model_dump())
1591
- return
1592
- if decision != "approved":
1593
- _PENDING_LOCAL.pop(session_id, None)
1664
+ decision = await _poll_approval(client, tid, should_stop)
1665
+ if decision == "stopped":
1666
+ _PENDING_LOCAL.pop(session_id, None)
1667
+ await send(Envelope(
1668
+ type=Envelope.TYPE_DONE, thread_id=thread_id,
1669
+ data={"mode": "orchestrate-auto-local", "status": "paused",
1670
+ "processed": processed, "task_count": total},
1671
+ ).model_dump())
1672
+ return
1673
+ if decision != "approved":
1674
+ _PENDING_LOCAL.pop(session_id, None)
1675
+ await send(Envelope(
1676
+ type="step_update", thread_id=thread_id,
1677
+ data={"current_step": f"{jira_key}: {decision} — skipped"},
1678
+ ).model_dump())
1679
+ break
1680
+
1681
+ # Phase 2 — run the user's local `claude`, report back.
1682
+ outcome = await handle_pipeline_approve(
1683
+ client,
1684
+ {"session_id": session_id, "decision": "approved", "thread_id": thread_id},
1685
+ send,
1686
+ )
1687
+
1688
+ # Usage limit → ticket is now in Backlog. Wait for reset + retry.
1689
+ if isinstance(outcome, dict) and outcome.get("status") == "limit_exceeded":
1690
+ if attempt <= _MAX_LIMIT_RETRIES:
1691
+ wait_s = _limit_wait_seconds(outcome.get("reset_epoch"))
1692
+ await send(Envelope(
1693
+ type="step_update", thread_id=thread_id,
1694
+ data={"current_step": f"{jira_key}: Claude usage limit — waiting "
1695
+ f"{round(wait_s / 60)}m for reset, then retry "
1696
+ f"({attempt}/{_MAX_LIMIT_RETRIES})"},
1697
+ ).model_dump())
1698
+ if not await _cooperative_sleep(wait_s, should_stop):
1699
+ await send(Envelope(
1700
+ type=Envelope.TYPE_DONE, thread_id=thread_id,
1701
+ data={"mode": "orchestrate-auto-local", "status": "paused",
1702
+ "processed": processed, "task_count": total},
1703
+ ).model_dump())
1704
+ return
1705
+ continue # re-run this ticket from phase 1
1706
+ # Out of retries — leave it in Backlog for a later run.
1594
1707
  await send(Envelope(
1595
1708
  type="step_update", thread_id=thread_id,
1596
- data={"current_step": f"{jira_key}: {decision}skipped"},
1709
+ data={"current_step": f"{jira_key}: usage limit max retries reached, "
1710
+ f"left in Backlog"},
1597
1711
  ).model_dump())
1598
- continue
1712
+ break
1599
1713
 
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
1714
+ processed += 1
1715
+ break
1607
1716
  except Exception as exc: # noqa: BLE001 — isolate per-ticket failures
1608
1717
  logger.warning("orchestrate_auto.task_failed", extra={"task_id": tid, "error": str(exc)})
1609
1718
  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
  }