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.
- {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/PKG-INFO +1 -1
- {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
- {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/pyproject.toml +1 -1
- {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/__init__.py +2 -2
- {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/client.py +30 -1
- {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/ipc/handlers.py +131 -39
- {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/local_exec.py +91 -11
- {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/README.md +0 -0
- {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/devops_bot_sdk.egg-info/SOURCES.txt +0 -0
- {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
- {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
- {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/devops_bot_sdk.egg-info/requires.txt +0 -0
- {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/devops_bot_sdk.egg-info/top_level.txt +0 -0
- {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/collectors/__init__.py +0 -0
- {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/collectors/files.py +0 -0
- {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/collectors/process.py +0 -0
- {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/collectors/screenshot.py +0 -0
- {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/config.py +0 -0
- {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/crucial.py +0 -0
- {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/exceptions.py +0 -0
- {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/git_ops.py +0 -0
- {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/graphify.py +0 -0
- {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/hooks/__init__.py +0 -0
- {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/hooks/crucial_guard.py +0 -0
- {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/ipc/__init__.py +0 -0
- {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/ipc/electron_bridge.py +0 -0
- {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/models/__init__.py +0 -0
- {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/models/envelope.py +0 -0
- {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/models/requests.py +0 -0
- {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/models/responses.py +0 -0
- {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/models/snapshots.py +0 -0
- {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/py.typed +0 -0
- {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/run_auto.py +0 -0
- {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/sse.py +0 -0
- {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/test.py +0 -0
- {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/test_pipeline.py +0 -0
- {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/sdk/updater.py +0 -0
- {devops_bot_sdk-1.4.8 → devops_bot_sdk-1.4.11}/setup.cfg +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "devops-bot-sdk"
|
|
7
|
-
version = "1.4.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
#
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
)
|
|
1573
|
-
|
|
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
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
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.
|
|
1588
|
-
data={"
|
|
1589
|
-
"
|
|
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
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
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}:
|
|
1692
|
+
data={"current_step": f"{jira_key}: usage limit — max retries reached, "
|
|
1693
|
+
f"left in Backlog"},
|
|
1597
1694
|
).model_dump())
|
|
1598
|
-
|
|
1695
|
+
break
|
|
1599
1696
|
|
|
1600
|
-
|
|
1601
|
-
|
|
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
|
-
#
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
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":
|
|
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
|
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|