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.
Files changed (38) hide show
  1. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/pyproject.toml +1 -1
  4. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/__init__.py +2 -2
  5. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/client.py +30 -1
  6. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/git_ops.py +58 -14
  7. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/ipc/handlers.py +333 -47
  8. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/local_exec.py +91 -11
  9. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/README.md +0 -0
  10. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/devops_bot_sdk.egg-info/SOURCES.txt +0 -0
  11. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  12. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
  13. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/devops_bot_sdk.egg-info/requires.txt +0 -0
  14. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  15. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/collectors/__init__.py +0 -0
  16. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/collectors/files.py +0 -0
  17. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/collectors/process.py +0 -0
  18. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/collectors/screenshot.py +0 -0
  19. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/config.py +0 -0
  20. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/crucial.py +0 -0
  21. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/exceptions.py +0 -0
  22. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/graphify.py +0 -0
  23. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/hooks/__init__.py +0 -0
  24. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/hooks/crucial_guard.py +0 -0
  25. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/ipc/__init__.py +0 -0
  26. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/ipc/electron_bridge.py +0 -0
  27. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/models/__init__.py +0 -0
  28. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/models/envelope.py +0 -0
  29. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/models/requests.py +0 -0
  30. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/models/responses.py +0 -0
  31. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/models/snapshots.py +0 -0
  32. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/py.typed +0 -0
  33. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/run_auto.py +0 -0
  34. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/sse.py +0 -0
  35. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/test.py +0 -0
  36. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/test_pipeline.py +0 -0
  37. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/sdk/updater.py +0 -0
  38. {devops_bot_sdk-1.4.6 → devops_bot_sdk-1.4.11}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.4.6
3
+ Version: 1.4.11
4
4
  Summary: DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app
5
5
  Author: noumanaziz2128
6
6
  License-Expression: LicenseRef-Proprietary
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.4.6
3
+ Version: 1.4.11
4
4
  Summary: DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app
5
5
  Author: noumanaziz2128
6
6
  License-Expression: LicenseRef-Proprietary
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devops-bot-sdk"
7
- version = "1.4.6"
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.6
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.6"
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.6"
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, jira_key: str | None, summary: str | None, github_token: str | None,
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 default branch.
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
- # Fetch first so origin/HEAD and origin/main refs are present/fresh before we
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
- if base == branch:
164
- # Defensive: never base a PR on the branch we're about to create.
165
- base = "main"
166
- # Cut the feature branch from the FRESH remote base so it never chains off a
167
- # previous ticket's feature branch left checked out in a shared working tree.
168
- rc, _, _ = await _run(["git", "checkout", "-B", branch, f"origin/{base}"], path, env)
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
- return {"branch": branch, "base": base}
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
- if user_input and user_input.strip():
153
- parts.append(user_input.strip())
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
- fp = (c.get("input", {}) or {}).get("file_path", "")
351
- label = f"Local claude [{name}]: {tname}" + (f" → {fp.split('/')[-1]}" if fp else "")
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 repo's real default
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
- targets = [{"path": pending["project_path"], "url": None}]
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
- targets.append({"path": p, "url": by_name.get(Path(p).name.lower())})
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
- # Phase 1 server plans + gates. NO local code/bash runs yet.
1368
- await _run_local_pipeline(client, task_payload, send)
1369
- session_id = next(
1370
- (sid for sid, p in _PENDING_LOCAL.items() if p.get("task_id") == tid),
1371
- None,
1372
- )
1373
- if not session_id:
1374
- # Planning never reached the approval gate for this task — skip it.
1375
- await send(Envelope(
1376
- type="step_update", thread_id=thread_id,
1377
- data={"current_step": f"{jira_key}: skipped (no approval gate reached)"},
1378
- ).model_dump())
1379
- continue
1619
+ # Retry the ticket if a run hits the Claude usage limit:
1620
+ # handle_pipeline_approve moves it to Backlog and signals here; we
1621
+ # wait for the window to reset and re-run — up to _MAX_LIMIT_RETRIES.
1622
+ attempt = 0
1623
+ while True:
1624
+ attempt += 1
1625
+ # Phase 1 — server plans + gates. NO local code/bash runs yet.
1626
+ await _run_local_pipeline(client, task_payload, send)
1627
+ session_id = next(
1628
+ (sid for sid, p in _PENDING_LOCAL.items() if p.get("task_id") == tid),
1629
+ None,
1630
+ )
1631
+ if not session_id:
1632
+ # Planning never reached the approval gate — skip this ticket.
1633
+ await send(Envelope(
1634
+ type="step_update", thread_id=thread_id,
1635
+ data={"current_step": f"{jira_key}: skipped (no approval gate reached)"},
1636
+ ).model_dump())
1637
+ break
1380
1638
 
1381
- # First-approval gate: when agentMode is ON, WAIT for the human's
1382
- # WhatsApp approval before ANY local code/bash. When OFF, proceed.
1383
- if agent_mode:
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.TYPE_DONE, thread_id=thread_id,
1394
- data={"mode": "orchestrate-auto-local", "status": "paused",
1395
- "processed": processed, "task_count": total},
1643
+ type=Envelope.TYPE_AWAITING_APPROVAL, thread_id=thread_id,
1644
+ data={"jira_key": jira_key, "session_id": session_id, "task_id": tid,
1645
+ "current_step": f"{jira_key}: awaiting approval"},
1396
1646
  ).model_dump())
1397
- return
1398
- if decision != "approved":
1399
- _PENDING_LOCAL.pop(session_id, None)
1647
+ decision = await _poll_approval(client, tid, should_stop)
1648
+ if decision == "stopped":
1649
+ _PENDING_LOCAL.pop(session_id, None)
1650
+ await send(Envelope(
1651
+ type=Envelope.TYPE_DONE, thread_id=thread_id,
1652
+ data={"mode": "orchestrate-auto-local", "status": "paused",
1653
+ "processed": processed, "task_count": total},
1654
+ ).model_dump())
1655
+ return
1656
+ if decision != "approved":
1657
+ _PENDING_LOCAL.pop(session_id, None)
1658
+ await send(Envelope(
1659
+ type="step_update", thread_id=thread_id,
1660
+ data={"current_step": f"{jira_key}: {decision} — skipped"},
1661
+ ).model_dump())
1662
+ break
1663
+
1664
+ # Phase 2 — run the user's local `claude`, report back.
1665
+ outcome = await handle_pipeline_approve(
1666
+ client,
1667
+ {"session_id": session_id, "decision": "approved", "thread_id": thread_id},
1668
+ send,
1669
+ )
1670
+
1671
+ # Usage limit → ticket is now in Backlog. Wait for reset + retry.
1672
+ if isinstance(outcome, dict) and outcome.get("status") == "limit_exceeded":
1673
+ if attempt <= _MAX_LIMIT_RETRIES:
1674
+ wait_s = _limit_wait_seconds(outcome.get("reset_epoch"))
1675
+ await send(Envelope(
1676
+ type="step_update", thread_id=thread_id,
1677
+ data={"current_step": f"{jira_key}: Claude usage limit — waiting "
1678
+ f"{round(wait_s / 60)}m for reset, then retry "
1679
+ f"({attempt}/{_MAX_LIMIT_RETRIES})"},
1680
+ ).model_dump())
1681
+ if not await _cooperative_sleep(wait_s, should_stop):
1682
+ await send(Envelope(
1683
+ type=Envelope.TYPE_DONE, thread_id=thread_id,
1684
+ data={"mode": "orchestrate-auto-local", "status": "paused",
1685
+ "processed": processed, "task_count": total},
1686
+ ).model_dump())
1687
+ return
1688
+ continue # re-run this ticket from phase 1
1689
+ # Out of retries — leave it in Backlog for a later run.
1400
1690
  await send(Envelope(
1401
1691
  type="step_update", thread_id=thread_id,
1402
- data={"current_step": f"{jira_key}: {decision}skipped"},
1692
+ data={"current_step": f"{jira_key}: usage limit max retries reached, "
1693
+ f"left in Backlog"},
1403
1694
  ).model_dump())
1404
- continue
1695
+ break
1405
1696
 
1406
- # Phase 2 — run the user's local `claude`, report back.
1407
- await handle_pipeline_approve(
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
- # 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
  }