devops-bot-sdk 1.4.6__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.6 → devops_bot_sdk-1.4.11}/PKG-INFO +1 -1
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/pyproject.toml +1 -1
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/__init__.py +2 -2
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/client.py +30 -1
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/git_ops.py +58 -14
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/ipc/handlers.py +333 -47
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/local_exec.py +91 -11
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/README.md +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/devops_bot_sdk.egg-info/SOURCES.txt +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/devops_bot_sdk.egg-info/requires.txt +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/devops_bot_sdk.egg-info/top_level.txt +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/collectors/__init__.py +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/collectors/files.py +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/collectors/process.py +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/collectors/screenshot.py +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/config.py +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/crucial.py +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/exceptions.py +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/graphify.py +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/hooks/__init__.py +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/hooks/crucial_guard.py +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/ipc/__init__.py +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/ipc/electron_bridge.py +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/models/__init__.py +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/models/envelope.py +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/models/requests.py +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/models/responses.py +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/models/snapshots.py +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/py.typed +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/run_auto.py +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/sse.py +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/test.py +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/test_pipeline.py +0 -0
- {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/updater.py +0 -0
- {devops_bot_sdk-1.4.6 → 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,
|
|
@@ -143,34 +143,78 @@ async def ensure_repo(
|
|
|
143
143
|
|
|
144
144
|
|
|
145
145
|
async def start_branch(
|
|
146
|
-
project_path: str,
|
|
146
|
+
project_path: str,
|
|
147
|
+
jira_key: str | None,
|
|
148
|
+
summary: str | None,
|
|
149
|
+
github_token: str | None,
|
|
150
|
+
base: str | None = None,
|
|
147
151
|
) -> dict | None:
|
|
148
|
-
"""Create + check out `feature/<jira_key>-<slug>` off the
|
|
152
|
+
"""Create + check out `feature/<jira_key>-<slug>` off the resolved base.
|
|
153
|
+
|
|
154
|
+
Base resolution (per repo):
|
|
155
|
+
- `base` given (named in the Jira humanInstructions, possibly per-repo) →
|
|
156
|
+
branch off the FRESH `origin/<base>` (fetched), falling back to a local
|
|
157
|
+
`<base>` ref. This is the explicit-override path.
|
|
158
|
+
- `base` omitted → the repo's CURRENT checked-out branch (the branch the
|
|
159
|
+
user is actually working on); the feature branch is cut from local HEAD
|
|
160
|
+
so the user's working state is preserved. If HEAD is detached or already
|
|
161
|
+
one of OUR `feature/*` branches (a stale checkout from a prior re-run in
|
|
162
|
+
a shared working tree), fall back to `detect_default_branch` so we never
|
|
163
|
+
chain a feature branch onto another feature branch.
|
|
149
164
|
|
|
150
165
|
Returns {"branch", "base"} or None when the path isn't a git repo (e.g. a
|
|
151
|
-
fresh scratch dir) or the checkout failed.
|
|
166
|
+
fresh scratch dir) or the checkout failed. The returned `base` is the branch
|
|
167
|
+
the feature was actually cut from — `finish_pr` uses it as the PR base.
|
|
152
168
|
"""
|
|
153
169
|
path = str(Path(project_path).expanduser())
|
|
154
170
|
env = _git_env(github_token)
|
|
155
171
|
if not await _is_git_repo(path, env):
|
|
156
172
|
return None
|
|
157
|
-
|
|
158
|
-
# detect the base (avoids resolving the base to the current feature branch).
|
|
159
|
-
await _run(["git", "fetch", "origin"], path, env, timeout=180.0)
|
|
160
|
-
base = await detect_default_branch(path, env)
|
|
173
|
+
|
|
161
174
|
key = slugify(jira_key or "task", max_len=24)
|
|
162
175
|
branch = f"feature/{key}-{slugify(summary)}"
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
176
|
+
# Fetch so any remote base ref (origin/HEAD, origin/<base>) is present/fresh.
|
|
177
|
+
await _run(["git", "fetch", "origin"], path, env, timeout=180.0)
|
|
178
|
+
|
|
179
|
+
explicit = (base or "").strip()
|
|
180
|
+
if explicit:
|
|
181
|
+
# Explicit base from instructions → branch off the FRESH remote tip
|
|
182
|
+
# (satisfies "pull latest from <base>, then branch"). Fall back to a
|
|
183
|
+
# local ref of that name, then to a bare branch, if the remote lacks it.
|
|
184
|
+
await _run(["git", "fetch", "origin", explicit], path, env, timeout=120.0)
|
|
185
|
+
resolved = "main" if explicit == branch else explicit
|
|
186
|
+
rc, _, _ = await _run(["git", "checkout", "-B", branch, f"origin/{resolved}"], path, env)
|
|
187
|
+
if rc != 0:
|
|
188
|
+
rc, _, _ = await _run(["git", "checkout", "-B", branch, resolved], path, env)
|
|
189
|
+
if rc != 0:
|
|
190
|
+
rc, _, _ = await _run(["git", "checkout", "-B", branch], path, env)
|
|
191
|
+
if rc != 0:
|
|
192
|
+
return None
|
|
193
|
+
logger.info("git_ops.branch | %s ← explicit base %s", branch, resolved)
|
|
194
|
+
return {"branch": branch, "base": resolved}
|
|
195
|
+
|
|
196
|
+
# Default: base = the CURRENT checked-out branch (the user's working branch).
|
|
197
|
+
rc, cur, _ = await _run(["git", "rev-parse", "--abbrev-ref", "HEAD"], path, env)
|
|
198
|
+
cur = cur.strip() if rc == 0 else ""
|
|
199
|
+
if cur and cur != "HEAD" and not cur.startswith("feature/"):
|
|
200
|
+
# Cut from local HEAD — preserve the user's working state, do not pull.
|
|
201
|
+
rc, _, _ = await _run(["git", "checkout", "-B", branch], path, env)
|
|
202
|
+
if rc != 0:
|
|
203
|
+
return None
|
|
204
|
+
logger.info("git_ops.branch | %s ← current branch %s", branch, cur)
|
|
205
|
+
return {"branch": branch, "base": cur}
|
|
206
|
+
|
|
207
|
+
# HEAD detached or a stale feature/* checkout → fall back to repo default.
|
|
208
|
+
resolved = await detect_default_branch(path, env)
|
|
209
|
+
if resolved == branch:
|
|
210
|
+
resolved = "main"
|
|
211
|
+
rc, _, _ = await _run(["git", "checkout", "-B", branch, f"origin/{resolved}"], path, env)
|
|
169
212
|
if rc != 0:
|
|
170
213
|
rc, _, _ = await _run(["git", "checkout", "-B", branch], path, env)
|
|
171
214
|
if rc != 0:
|
|
172
215
|
return None
|
|
173
|
-
|
|
216
|
+
logger.info("git_ops.branch | %s ← default base %s (HEAD was %r)", branch, resolved, cur or "detached")
|
|
217
|
+
return {"branch": branch, "base": resolved}
|
|
174
218
|
|
|
175
219
|
|
|
176
220
|
async def _remote_owner_repo(path: str, env: dict) -> tuple[str, str] | None:
|
|
@@ -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
|
|
|
@@ -117,6 +118,32 @@ _POLL_INTERVAL_S = 1.5
|
|
|
117
118
|
_PLAN_DEADLINE_S = 180.0 # planning + approval gate should be quick
|
|
118
119
|
|
|
119
120
|
|
|
121
|
+
# Raw task-description budget (chars). The structured plan already distills the
|
|
122
|
+
# task into acceptance criteria + steps, so the raw description is REFERENCE: a
|
|
123
|
+
# very large one is bounded so it cannot bury the operator instructions or the
|
|
124
|
+
# plan (the "follows my instructions sometimes, not others" failure mode). ~6k
|
|
125
|
+
# tokens at ~4 chars/token.
|
|
126
|
+
_DESC_MAX_CHARS = 24_000
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _clip_text(text: str, max_chars: int = _DESC_MAX_CHARS) -> str:
|
|
130
|
+
"""Bound a reference string to a char budget, keeping head + tail.
|
|
131
|
+
|
|
132
|
+
Pure-python (no tiktoken in the thin SDK). Keeps the start and end and elides
|
|
133
|
+
the middle with a marker pointing back at the distilled plan.
|
|
134
|
+
"""
|
|
135
|
+
if not text or len(text) <= max_chars:
|
|
136
|
+
return text
|
|
137
|
+
head = max_chars // 2
|
|
138
|
+
tail = max_chars - head
|
|
139
|
+
elided = len(text) - head - tail
|
|
140
|
+
return (
|
|
141
|
+
f"{text[:head]}\n\n[... {elided} chars elided — the acceptance criteria "
|
|
142
|
+
f"and implementation steps above are the distilled, authoritative "
|
|
143
|
+
f"requirements ...]\n\n{text[-tail:]}"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
120
147
|
def _build_local_prompt(
|
|
121
148
|
user_input: str | None,
|
|
122
149
|
structured_plan: dict | None,
|
|
@@ -125,6 +152,12 @@ def _build_local_prompt(
|
|
|
125
152
|
) -> str:
|
|
126
153
|
"""Compose the coding prompt for the local claude CLI from the server plan.
|
|
127
154
|
|
|
155
|
+
Prompt order matters: a model attends most to the start (and end) of a long
|
|
156
|
+
prompt, so the MUST-FOLLOW operator instructions LEAD, the distilled plan
|
|
157
|
+
(acceptance criteria + steps) comes next, and the raw task description is
|
|
158
|
+
DEMOTED to bounded reference material at the end — never first, never
|
|
159
|
+
unbounded. This stops a large Jira description from burying the instructions.
|
|
160
|
+
|
|
128
161
|
`user_input` may be empty when the desktop relied on backend hydration
|
|
129
162
|
(jira_task_id-only run) — the structured plan then carries the task detail.
|
|
130
163
|
|
|
@@ -135,6 +168,7 @@ def _build_local_prompt(
|
|
|
135
168
|
locally itself.
|
|
136
169
|
"""
|
|
137
170
|
parts: list[str] = []
|
|
171
|
+
# 1. Operator instructions — FIRST, verbatim, authoritative.
|
|
138
172
|
if human_instructions and human_instructions.strip():
|
|
139
173
|
parts.append(
|
|
140
174
|
"=== Operator instructions (Jira humanInstructions) — AUTHORITATIVE ===\n"
|
|
@@ -149,8 +183,8 @@ def _build_local_prompt(
|
|
|
149
183
|
"before changing it.\n\n"
|
|
150
184
|
+ human_instructions.strip()
|
|
151
185
|
)
|
|
152
|
-
|
|
153
|
-
|
|
186
|
+
# 2. Distilled plan (acceptance criteria + steps) — the compact, actionable
|
|
187
|
+
# content the backend planner derived from the full description.
|
|
154
188
|
sp = structured_plan or {}
|
|
155
189
|
criteria = sp.get("acceptance_criteria") or []
|
|
156
190
|
steps = sp.get("task_steps") or []
|
|
@@ -163,6 +197,13 @@ def _build_local_prompt(
|
|
|
163
197
|
lines.append(f"- {s.get('title', '')}: {s.get('description', '')}".rstrip(": "))
|
|
164
198
|
if lines:
|
|
165
199
|
parts.append("Implementation steps:\n" + "\n".join(lines))
|
|
200
|
+
# 3. Raw task description — DEMOTED to bounded reference (plan above wins).
|
|
201
|
+
if user_input and user_input.strip():
|
|
202
|
+
parts.append(
|
|
203
|
+
"=== Task description (reference — the operator instructions and plan "
|
|
204
|
+
"above are authoritative if anything conflicts) ===\n"
|
|
205
|
+
+ _clip_text(user_input.strip())
|
|
206
|
+
)
|
|
166
207
|
parts.append(
|
|
167
208
|
"Implement all of the above in the current project directory. "
|
|
168
209
|
"Create and edit every file needed for a complete, working result.\n"
|
|
@@ -313,6 +354,45 @@ async def _run_local_pipeline(
|
|
|
313
354
|
await send(_error_envelope(thread_id, RuntimeError("Planning timed out before approval gate.")).model_dump())
|
|
314
355
|
|
|
315
356
|
|
|
357
|
+
def _tool_detail(tool_name: str, inp: dict) -> str:
|
|
358
|
+
"""A short, human-readable detail for a tool_use event, for the step timeline.
|
|
359
|
+
|
|
360
|
+
Turns a bare "Bash" into the actual command, "Read" into the file, "Grep"
|
|
361
|
+
into the pattern, etc. — so the progress log says what the agent is doing.
|
|
362
|
+
"""
|
|
363
|
+
inp = inp or {}
|
|
364
|
+
|
|
365
|
+
def _short(s, n=140):
|
|
366
|
+
return " ".join(str(s).split())[:n]
|
|
367
|
+
|
|
368
|
+
if tool_name == "Bash":
|
|
369
|
+
# Prefer claude's own one-line description, then the command itself.
|
|
370
|
+
return _short(inp.get("description") or inp.get("command") or "")
|
|
371
|
+
if tool_name in ("Read", "Write", "Edit", "MultiEdit", "NotebookEdit"):
|
|
372
|
+
fp = str(inp.get("file_path") or inp.get("notebook_path") or "")
|
|
373
|
+
return fp.split("/")[-1] if fp else ""
|
|
374
|
+
if tool_name == "Grep":
|
|
375
|
+
pat = inp.get("pattern", "")
|
|
376
|
+
where = inp.get("path") or inp.get("glob") or ""
|
|
377
|
+
return _short(f"{pat}" + (f" in {where}" if where else ""))
|
|
378
|
+
if tool_name == "Glob":
|
|
379
|
+
return _short(inp.get("pattern", ""))
|
|
380
|
+
if tool_name in ("WebFetch", "WebSearch"):
|
|
381
|
+
return _short(inp.get("url") or inp.get("query") or "")
|
|
382
|
+
if tool_name == "Task":
|
|
383
|
+
return _short(inp.get("description") or inp.get("subagent_type") or "")
|
|
384
|
+
if tool_name == "TodoWrite":
|
|
385
|
+
todos = inp.get("todos") or []
|
|
386
|
+
active = next((t.get("content") for t in todos if t.get("status") == "in_progress"), None)
|
|
387
|
+
return _short(active) if active else f"{len(todos)} item(s)"
|
|
388
|
+
# Generic fallback: first useful string field.
|
|
389
|
+
for k in ("description", "prompt", "query", "command", "path", "url", "file_path"):
|
|
390
|
+
v = inp.get(k)
|
|
391
|
+
if isinstance(v, str) and v.strip():
|
|
392
|
+
return _short(v)
|
|
393
|
+
return ""
|
|
394
|
+
|
|
395
|
+
|
|
316
396
|
async def _code_one_repo(
|
|
317
397
|
client: BackendClient,
|
|
318
398
|
*,
|
|
@@ -326,6 +406,7 @@ async def _code_one_repo(
|
|
|
326
406
|
send: Callable,
|
|
327
407
|
thread_id: str,
|
|
328
408
|
task_id: str,
|
|
409
|
+
base: str | None = None,
|
|
329
410
|
) -> dict:
|
|
330
411
|
"""Clone-if-needed → branch → run claude → open a PR, for ONE repo.
|
|
331
412
|
|
|
@@ -347,8 +428,8 @@ async def _code_one_repo(
|
|
|
347
428
|
for c in event.get("message", {}).get("content", []):
|
|
348
429
|
if c.get("type") == "tool_use":
|
|
349
430
|
tname = c.get("name", "tool")
|
|
350
|
-
|
|
351
|
-
label = f"Local claude [{name}]: {tname}" + (f" → {
|
|
431
|
+
detail = _tool_detail(tname, c.get("input") or {})
|
|
432
|
+
label = f"Local claude [{name}]: {tname}" + (f" → {detail}" if detail else "")
|
|
352
433
|
await send(Envelope(
|
|
353
434
|
type="step_update", thread_id=thread_id,
|
|
354
435
|
data={"status": "running", "current_step": label},
|
|
@@ -371,11 +452,13 @@ async def _code_one_repo(
|
|
|
371
452
|
except Exception: # noqa: BLE001
|
|
372
453
|
pass
|
|
373
454
|
|
|
374
|
-
# 2. feature branch off the
|
|
455
|
+
# 2. feature branch off the resolved base (explicit base from instructions,
|
|
456
|
+
# else the repo's current checked-out branch — see git_ops.start_branch).
|
|
375
457
|
branch_info = None
|
|
376
458
|
try:
|
|
377
459
|
branch_info = await git_ops.start_branch(
|
|
378
460
|
repo_path, pending.get("jira_key"), pending.get("summary"), gh_token,
|
|
461
|
+
base=base,
|
|
379
462
|
)
|
|
380
463
|
if branch_info:
|
|
381
464
|
await send(Envelope(
|
|
@@ -574,7 +657,11 @@ async def handle_pipeline_approve(
|
|
|
574
657
|
# Branch + code + PR each independently; fall back to the single resolved path.
|
|
575
658
|
targets = _extract_repo_targets(pending.get("human_instructions"))
|
|
576
659
|
if not targets:
|
|
577
|
-
|
|
660
|
+
# No repos named in the instructions → the single resolved project path.
|
|
661
|
+
# It still honours a global base mentioned in the instructions; None →
|
|
662
|
+
# start_branch falls back to the repo's current checked-out branch.
|
|
663
|
+
_, _global_base = _extract_base_branches(pending.get("human_instructions"))
|
|
664
|
+
targets = [{"path": pending["project_path"], "url": None, "base": _global_base}]
|
|
578
665
|
await send(Envelope(
|
|
579
666
|
type="step_update", thread_id=thread_id,
|
|
580
667
|
data={"current_step": f"{len(targets)} repo(s) to update: "
|
|
@@ -587,6 +674,7 @@ async def handle_pipeline_approve(
|
|
|
587
674
|
try:
|
|
588
675
|
runs.append(await _code_one_repo(
|
|
589
676
|
client, repo_path=tgt["path"], repo_url=tgt.get("url"),
|
|
677
|
+
base=tgt.get("base"),
|
|
590
678
|
pending=pending, prompt=prompt, gh_token=gh_token,
|
|
591
679
|
agent_mode=agent_mode, approval_dir=approval_dir,
|
|
592
680
|
send=send, thread_id=thread_id, task_id=task_id,
|
|
@@ -624,6 +712,29 @@ async def handle_pipeline_approve(
|
|
|
624
712
|
if pr_urls:
|
|
625
713
|
agg_result += "\n\nPRs:\n" + "\n".join(pr_urls)
|
|
626
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
|
+
|
|
627
738
|
# ── Notify on WhatsApp if nothing succeeded ──────────────────────────
|
|
628
739
|
if not ok_any:
|
|
629
740
|
err = "; ".join(str(r["result"].get("error") or "unknown") for r in runs) or "no repos processed"
|
|
@@ -1171,13 +1282,113 @@ def _extract_all_paths(text: str | None) -> list[str]:
|
|
|
1171
1282
|
return out
|
|
1172
1283
|
|
|
1173
1284
|
|
|
1285
|
+
# Base-branch directives in free-text humanInstructions. Order matters — the
|
|
1286
|
+
# more explicit "branch from X" / "base branch: X" forms are tried before the
|
|
1287
|
+
# looser "X branch" form so "create a feature branch from main" resolves to
|
|
1288
|
+
# `main`, not `feature`. Noise words are filtered out so "the/new/feature/this
|
|
1289
|
+
# branch" never resolves to a base.
|
|
1290
|
+
import re as _re # noqa: E402
|
|
1291
|
+
|
|
1292
|
+
_BASE_NOISE = {
|
|
1293
|
+
"the", "a", "an", "this", "that", "new", "feature", "features",
|
|
1294
|
+
"your", "same", "current", "default", "its", "please", "create",
|
|
1295
|
+
}
|
|
1296
|
+
_BASE_DIRECTIVE_RES = [
|
|
1297
|
+
# "branch from/off <base>" — most explicit, tried first. Greedy so a full
|
|
1298
|
+
# name like "release/2.0" is captured, not truncated to "release".
|
|
1299
|
+
_re.compile(r"branch(?:es|ed|ing)?\s+(?:from|off(?:\s+of)?)\s+(?:the\s+)?[\"'`]?([A-Za-z0-9._\-/]+)[\"'`]?(?:\s+branch)?\b", _re.I),
|
|
1300
|
+
# "base branch: <base>" / "base is <base>"
|
|
1301
|
+
_re.compile(r"base(?:\s+branch)?\s*(?:[:=]|\bis\b|\bshould\s+be\b)\s*[\"'`]?([A-Za-z0-9._\-/]+)[\"'`]?\b", _re.I),
|
|
1302
|
+
# "from the <base> branch"
|
|
1303
|
+
_re.compile(r"from\s+(?:the\s+)?[\"'`]?([A-Za-z0-9._\-/]+)[\"'`]?\s+branch\b", _re.I),
|
|
1304
|
+
# loose "<base> branch" (e.g. "main branch") — last, most permissive.
|
|
1305
|
+
_re.compile(r"[\"'`]?([A-Za-z0-9._\-/]+)[\"'`]?\s+branch\b", _re.I),
|
|
1306
|
+
]
|
|
1307
|
+
|
|
1308
|
+
# Repo-role keywords so a base named alongside "FE"/"backend"/… maps to the
|
|
1309
|
+
# matching target even when the path basename doesn't literally contain it.
|
|
1310
|
+
_REPO_ROLE_KEYWORDS = (
|
|
1311
|
+
"frontend", "front-end", "fe", "backend", "back-end", "be", "desktop",
|
|
1312
|
+
"mobile", "api", "web", "server", "client",
|
|
1313
|
+
)
|
|
1314
|
+
|
|
1315
|
+
|
|
1316
|
+
def _find_base_in(text: str | None) -> str | None:
|
|
1317
|
+
"""First plausible base-branch name in `text`, or None. Filters noise words."""
|
|
1318
|
+
if not text:
|
|
1319
|
+
return None
|
|
1320
|
+
for rx in _BASE_DIRECTIVE_RES:
|
|
1321
|
+
for m in rx.finditer(text):
|
|
1322
|
+
cand = (m.group(1) or "").strip().strip("./")
|
|
1323
|
+
if cand and cand.lower() not in _BASE_NOISE and not cand.startswith("feature/"):
|
|
1324
|
+
return cand
|
|
1325
|
+
return None
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
def _extract_base_branches(text: str | None) -> tuple[dict[str, str], str | None]:
|
|
1329
|
+
"""Parse base-branch directives from humanInstructions.
|
|
1330
|
+
|
|
1331
|
+
Returns (per_repo_bases, global_base):
|
|
1332
|
+
- per_repo_bases: {repo_token: base} where repo_token is a repo name /
|
|
1333
|
+
path basename / role keyword (fe/be/desktop/…) that appeared on the SAME
|
|
1334
|
+
line as a base directive — supports "FE from develop, BE from main".
|
|
1335
|
+
- global_base: a base directive found anywhere (applies to any repo with no
|
|
1336
|
+
more-specific per-repo base). None when no base is mentioned at all.
|
|
1337
|
+
"""
|
|
1338
|
+
if not text:
|
|
1339
|
+
return {}, None
|
|
1340
|
+
from pathlib import Path
|
|
1341
|
+
|
|
1342
|
+
def _repo_name(u: str) -> str:
|
|
1343
|
+
n = u.rstrip("/").rsplit("/", 1)[-1]
|
|
1344
|
+
return (n[:-4] if n.lower().endswith(".git") else n).lower()
|
|
1345
|
+
|
|
1346
|
+
per_repo: dict[str, str] = {}
|
|
1347
|
+
for line in text.splitlines():
|
|
1348
|
+
b = _find_base_in(line)
|
|
1349
|
+
if not b:
|
|
1350
|
+
continue
|
|
1351
|
+
tokens: set[str] = set()
|
|
1352
|
+
for u in _GITHUB_URL_RE.findall(line):
|
|
1353
|
+
tokens.add(_repo_name(u))
|
|
1354
|
+
for p in _extract_all_paths(line):
|
|
1355
|
+
tokens.add(Path(p).name.lower())
|
|
1356
|
+
low = line.lower()
|
|
1357
|
+
for kw in _REPO_ROLE_KEYWORDS:
|
|
1358
|
+
if _re.search(rf"\b{_re.escape(kw)}\b", low):
|
|
1359
|
+
tokens.add(kw)
|
|
1360
|
+
for t in tokens:
|
|
1361
|
+
per_repo.setdefault(t, b)
|
|
1362
|
+
return per_repo, _find_base_in(text)
|
|
1363
|
+
|
|
1364
|
+
|
|
1365
|
+
def _resolve_base_for(name: str, url: str | None,
|
|
1366
|
+
per_repo: dict[str, str], global_base: str | None) -> str | None:
|
|
1367
|
+
"""Pick the base for one repo: per-repo match (name / url / role keyword) → global."""
|
|
1368
|
+
candidates: list[str] = [name.lower()]
|
|
1369
|
+
if url:
|
|
1370
|
+
n = url.rstrip("/").rsplit("/", 1)[-1]
|
|
1371
|
+
candidates.append((n[:-4] if n.lower().endswith(".git") else n).lower())
|
|
1372
|
+
low = name.lower()
|
|
1373
|
+
for kw in _REPO_ROLE_KEYWORDS:
|
|
1374
|
+
if kw in low:
|
|
1375
|
+
candidates.append(kw)
|
|
1376
|
+
for c in candidates:
|
|
1377
|
+
if c in per_repo:
|
|
1378
|
+
return per_repo[c]
|
|
1379
|
+
return global_base
|
|
1380
|
+
|
|
1381
|
+
|
|
1174
1382
|
def _extract_repo_targets(text: str | None) -> list[dict]:
|
|
1175
|
-
"""All repo work-targets named in `text`: ``[{"path", "url"}]``.
|
|
1383
|
+
"""All repo work-targets named in `text`: ``[{"path", "url", "base"}]``.
|
|
1176
1384
|
|
|
1177
1385
|
A single ticket may name several repos (e.g. FE + BE). Each local path is
|
|
1178
1386
|
paired with a GitHub URL whose repo name matches the path's last segment
|
|
1179
1387
|
(FE path ↔ FE url, BE path ↔ BE url) so a missing folder can be cloned. A
|
|
1180
1388
|
path with no matching URL still appears (url=None) — it must already exist.
|
|
1389
|
+
Each target also carries its resolved `base` branch (per-repo override from
|
|
1390
|
+
the instructions, else the global mentioned base, else None → the SDK uses
|
|
1391
|
+
the repo's current checked-out branch).
|
|
1181
1392
|
"""
|
|
1182
1393
|
if not text:
|
|
1183
1394
|
return []
|
|
@@ -1187,10 +1398,17 @@ def _extract_repo_targets(text: str | None) -> list[dict]:
|
|
|
1187
1398
|
n = u.rstrip("/").rsplit("/", 1)[-1]
|
|
1188
1399
|
return (n[:-4] if n.lower().endswith(".git") else n).lower()
|
|
1189
1400
|
|
|
1401
|
+
per_repo_bases, global_base = _extract_base_branches(text)
|
|
1190
1402
|
by_name = {_repo_name(u): u for u in _GITHUB_URL_RE.findall(text)}
|
|
1191
1403
|
targets: list[dict] = []
|
|
1192
1404
|
for p in _extract_all_paths(text):
|
|
1193
|
-
|
|
1405
|
+
name = Path(p).name
|
|
1406
|
+
url = by_name.get(name.lower())
|
|
1407
|
+
targets.append({
|
|
1408
|
+
"path": p,
|
|
1409
|
+
"url": url,
|
|
1410
|
+
"base": _resolve_base_for(name, url, per_repo_bases, global_base),
|
|
1411
|
+
})
|
|
1194
1412
|
return targets
|
|
1195
1413
|
|
|
1196
1414
|
|
|
@@ -1235,6 +1453,40 @@ _APPROVAL_POLL_TIMEOUT_S = 1200.0
|
|
|
1235
1453
|
_MAX_CONTEXT_RESUMES = 3
|
|
1236
1454
|
# Max mid-run crucial-decision approval gates per ticket (safety bound).
|
|
1237
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
|
|
1238
1490
|
|
|
1239
1491
|
|
|
1240
1492
|
async def _poll_approval(
|
|
@@ -1364,52 +1616,86 @@ async def handle_orchestrate_auto_local_run(
|
|
|
1364
1616
|
# Per-ticket isolation: a failure here (network, git, a raising
|
|
1365
1617
|
# repo) must NOT abort the remaining tickets — log it and move on.
|
|
1366
1618
|
try:
|
|
1367
|
-
#
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
)
|
|
1379
|
-
|
|
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
|
|
1380
1638
|
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
await send(Envelope(
|
|
1385
|
-
type=Envelope.TYPE_AWAITING_APPROVAL, thread_id=thread_id,
|
|
1386
|
-
data={"jira_key": jira_key, "session_id": session_id, "task_id": tid,
|
|
1387
|
-
"current_step": f"{jira_key}: awaiting approval"},
|
|
1388
|
-
).model_dump())
|
|
1389
|
-
decision = await _poll_approval(client, tid, should_stop)
|
|
1390
|
-
if decision == "stopped":
|
|
1391
|
-
_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:
|
|
1392
1642
|
await send(Envelope(
|
|
1393
|
-
type=Envelope.
|
|
1394
|
-
data={"
|
|
1395
|
-
"
|
|
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"},
|
|
1396
1646
|
).model_dump())
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
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.
|
|
1400
1690
|
await send(Envelope(
|
|
1401
1691
|
type="step_update", thread_id=thread_id,
|
|
1402
|
-
data={"current_step": f"{jira_key}:
|
|
1692
|
+
data={"current_step": f"{jira_key}: usage limit — max retries reached, "
|
|
1693
|
+
f"left in Backlog"},
|
|
1403
1694
|
).model_dump())
|
|
1404
|
-
|
|
1695
|
+
break
|
|
1405
1696
|
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
client,
|
|
1409
|
-
{"session_id": session_id, "decision": "approved", "thread_id": thread_id},
|
|
1410
|
-
send,
|
|
1411
|
-
)
|
|
1412
|
-
processed += 1
|
|
1697
|
+
processed += 1
|
|
1698
|
+
break
|
|
1413
1699
|
except Exception as exc: # noqa: BLE001 — isolate per-ticket failures
|
|
1414
1700
|
logger.warning("orchestrate_auto.task_failed", extra={"task_id": tid, "error": str(exc)})
|
|
1415
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
|