devops-bot-sdk 1.4.31__tar.gz → 1.4.35__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.31 → devops_bot_sdk-1.4.35}/PKG-INFO +1 -1
- {devops_bot_sdk-1.4.31 → devops_bot_sdk-1.4.35}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
- {devops_bot_sdk-1.4.31 → devops_bot_sdk-1.4.35}/pyproject.toml +1 -1
- {devops_bot_sdk-1.4.31 → devops_bot_sdk-1.4.35}/sdk/__init__.py +2 -2
- {devops_bot_sdk-1.4.31 → devops_bot_sdk-1.4.35}/sdk/client.py +26 -1
- {devops_bot_sdk-1.4.31 → devops_bot_sdk-1.4.35}/sdk/config.py +16 -0
- {devops_bot_sdk-1.4.31 → devops_bot_sdk-1.4.35}/sdk/git_ops.py +27 -7
- {devops_bot_sdk-1.4.31 → devops_bot_sdk-1.4.35}/sdk/ipc/handlers.py +413 -15
- {devops_bot_sdk-1.4.31 → devops_bot_sdk-1.4.35}/sdk/run_auto.py +90 -18
- {devops_bot_sdk-1.4.31 → devops_bot_sdk-1.4.35}/README.md +0 -0
- {devops_bot_sdk-1.4.31 → devops_bot_sdk-1.4.35}/devops_bot_sdk.egg-info/SOURCES.txt +0 -0
- {devops_bot_sdk-1.4.31 → devops_bot_sdk-1.4.35}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
- {devops_bot_sdk-1.4.31 → devops_bot_sdk-1.4.35}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
- {devops_bot_sdk-1.4.31 → devops_bot_sdk-1.4.35}/devops_bot_sdk.egg-info/requires.txt +0 -0
- {devops_bot_sdk-1.4.31 → devops_bot_sdk-1.4.35}/devops_bot_sdk.egg-info/top_level.txt +0 -0
- {devops_bot_sdk-1.4.31 → devops_bot_sdk-1.4.35}/sdk/collectors/__init__.py +0 -0
- {devops_bot_sdk-1.4.31 → devops_bot_sdk-1.4.35}/sdk/collectors/files.py +0 -0
- {devops_bot_sdk-1.4.31 → devops_bot_sdk-1.4.35}/sdk/collectors/process.py +0 -0
- {devops_bot_sdk-1.4.31 → devops_bot_sdk-1.4.35}/sdk/collectors/screenshot.py +0 -0
- {devops_bot_sdk-1.4.31 → devops_bot_sdk-1.4.35}/sdk/crucial.py +0 -0
- {devops_bot_sdk-1.4.31 → devops_bot_sdk-1.4.35}/sdk/exceptions.py +0 -0
- {devops_bot_sdk-1.4.31 → devops_bot_sdk-1.4.35}/sdk/graphify.py +0 -0
- {devops_bot_sdk-1.4.31 → devops_bot_sdk-1.4.35}/sdk/hooks/__init__.py +0 -0
- {devops_bot_sdk-1.4.31 → devops_bot_sdk-1.4.35}/sdk/hooks/crucial_guard.py +0 -0
- {devops_bot_sdk-1.4.31 → devops_bot_sdk-1.4.35}/sdk/ipc/__init__.py +0 -0
- {devops_bot_sdk-1.4.31 → devops_bot_sdk-1.4.35}/sdk/ipc/electron_bridge.py +0 -0
- {devops_bot_sdk-1.4.31 → devops_bot_sdk-1.4.35}/sdk/local_exec.py +0 -0
- {devops_bot_sdk-1.4.31 → devops_bot_sdk-1.4.35}/sdk/models/__init__.py +0 -0
- {devops_bot_sdk-1.4.31 → devops_bot_sdk-1.4.35}/sdk/models/envelope.py +0 -0
- {devops_bot_sdk-1.4.31 → devops_bot_sdk-1.4.35}/sdk/models/requests.py +0 -0
- {devops_bot_sdk-1.4.31 → devops_bot_sdk-1.4.35}/sdk/models/responses.py +0 -0
- {devops_bot_sdk-1.4.31 → devops_bot_sdk-1.4.35}/sdk/models/snapshots.py +0 -0
- {devops_bot_sdk-1.4.31 → devops_bot_sdk-1.4.35}/sdk/py.typed +0 -0
- {devops_bot_sdk-1.4.31 → devops_bot_sdk-1.4.35}/sdk/sse.py +0 -0
- {devops_bot_sdk-1.4.31 → devops_bot_sdk-1.4.35}/sdk/test.py +0 -0
- {devops_bot_sdk-1.4.31 → devops_bot_sdk-1.4.35}/sdk/test_pipeline.py +0 -0
- {devops_bot_sdk-1.4.31 → devops_bot_sdk-1.4.35}/sdk/updater.py +0 -0
- {devops_bot_sdk-1.4.31 → devops_bot_sdk-1.4.35}/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.35"
|
|
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.35
|
|
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.35"
|
|
34
34
|
__author__ = "AgentOS"
|
|
35
35
|
|
|
36
36
|
from sdk.client import BackendClient
|
|
@@ -36,7 +36,29 @@ from sdk.sse import _check_status, stream_with_reconnect
|
|
|
36
36
|
|
|
37
37
|
logger = logging.getLogger(__name__)
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
# ── Ticket status-change observers ────────────────────────────────────────
|
|
40
|
+
# Best-effort, synchronous hooks fired when the client changes a ticket's backend
|
|
41
|
+
# status (In Progress / Backlog / To Do). The CLI loop registers one to track
|
|
42
|
+
# which tickets the run has parked in Backlog, so `agentos-auto stop` can restore
|
|
43
|
+
# exactly those to To Do. Kept generic here — the client never touches CLI files.
|
|
44
|
+
_status_observers: list = []
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def register_status_observer(cb) -> None:
|
|
48
|
+
"""Register cb(task_id: str, status: str) — status ∈ {in_progress, backlog, to_do}."""
|
|
49
|
+
if cb not in _status_observers:
|
|
50
|
+
_status_observers.append(cb)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _notify_status(task_id: str, status: str) -> None:
|
|
54
|
+
for cb in list(_status_observers):
|
|
55
|
+
try:
|
|
56
|
+
cb(task_id, status)
|
|
57
|
+
except Exception: # noqa: BLE001 — observers must never break a request
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
SDK_VERSION = "1.4.35"
|
|
40
62
|
_POLL_INTERVAL = 3.0
|
|
41
63
|
_POLL_TIMEOUT = 600.0
|
|
42
64
|
_ORCHESTRATE_TIMEOUT = 2700.0 # 45 min — covers approval wait + VPS execution time
|
|
@@ -686,6 +708,7 @@ class BackendClient:
|
|
|
686
708
|
task_id, resp.status_code, resp.text[:200])
|
|
687
709
|
return False
|
|
688
710
|
await self.log_activity(f"Task {task_id}: status → Backlog")
|
|
711
|
+
_notify_status(task_id, "backlog")
|
|
689
712
|
return True
|
|
690
713
|
except Exception as exc: # noqa: BLE001
|
|
691
714
|
logger.warning("client.move_to_backlog error task=%s err=%s", task_id, exc)
|
|
@@ -713,6 +736,7 @@ class BackendClient:
|
|
|
713
736
|
task_id, resp.status_code, resp.text[:200])
|
|
714
737
|
return False
|
|
715
738
|
await self.log_activity(f"Task {task_id}: status → To Do")
|
|
739
|
+
_notify_status(task_id, "to_do")
|
|
716
740
|
return True
|
|
717
741
|
except Exception as exc: # noqa: BLE001
|
|
718
742
|
logger.warning("client.move_to_todo error task=%s err=%s", task_id, exc)
|
|
@@ -739,6 +763,7 @@ class BackendClient:
|
|
|
739
763
|
task_id, resp.status_code, resp.text[:200])
|
|
740
764
|
return False
|
|
741
765
|
await self.log_activity(f"Task {task_id}: status → In Progress")
|
|
766
|
+
_notify_status(task_id, "in_progress")
|
|
742
767
|
return True
|
|
743
768
|
except Exception as exc: # noqa: BLE001
|
|
744
769
|
logger.warning("client.mark_in_progress error task=%s err=%s", task_id, exc)
|
|
@@ -122,6 +122,22 @@ def get_base_url() -> str:
|
|
|
122
122
|
return load().get("base_url", AI_DEFAULT_URL)
|
|
123
123
|
|
|
124
124
|
|
|
125
|
+
def get_branch_name_template() -> str | None:
|
|
126
|
+
"""Org-wide branch-naming convention, if configured in config.toml.
|
|
127
|
+
|
|
128
|
+
Lets teams enforce a house convention without repeating it in every ticket's
|
|
129
|
+
humanInstructions. Supports the ``{jira_key}`` placeholder, e.g.::
|
|
130
|
+
|
|
131
|
+
branch_name_template = "{jira_key}" # branch named exactly the ticket id
|
|
132
|
+
branch_name_template = "feature/{jira_key}"
|
|
133
|
+
|
|
134
|
+
A per-ticket directive parsed from humanInstructions always wins over this.
|
|
135
|
+
Returns None when unset (SDK falls back to feature/<key>-<slug>).
|
|
136
|
+
"""
|
|
137
|
+
val = _load_raw().get("branch_name_template")
|
|
138
|
+
return val.strip() if val and val.strip() else None
|
|
139
|
+
|
|
140
|
+
|
|
125
141
|
def get_token() -> str:
|
|
126
142
|
"""Return the stored apiTokenHash. Raises TokenNotConfigured if missing."""
|
|
127
143
|
cfg = load()
|
|
@@ -148,8 +148,17 @@ async def start_branch(
|
|
|
148
148
|
summary: str | None,
|
|
149
149
|
github_token: str | None,
|
|
150
150
|
base: str | None = None,
|
|
151
|
+
branch_name: str | None = None,
|
|
151
152
|
) -> dict | None:
|
|
152
|
-
"""Create + check out
|
|
153
|
+
"""Create + check out a feature branch off the resolved base.
|
|
154
|
+
|
|
155
|
+
Branch name:
|
|
156
|
+
- `branch_name` given (an explicit name parsed from the Jira
|
|
157
|
+
humanInstructions or a configured template, e.g. the exact ticket id
|
|
158
|
+
"CD-72") → used VERBATIM. This is how "name the branch exactly
|
|
159
|
+
<TICKET-ID>" is honoured — the SDK owns branching for the managed repo,
|
|
160
|
+
so the convention has to be applied here, not by the agent.
|
|
161
|
+
- omitted → the historical default `feature/<jira_key>-<slug>`.
|
|
153
162
|
|
|
154
163
|
Base resolution (per repo):
|
|
155
164
|
- `base` given (named in the Jira humanInstructions, possibly per-repo) →
|
|
@@ -171,8 +180,13 @@ async def start_branch(
|
|
|
171
180
|
if not await _is_git_repo(path, env):
|
|
172
181
|
return None
|
|
173
182
|
|
|
174
|
-
|
|
175
|
-
|
|
183
|
+
explicit_name = (branch_name or "").strip().lstrip("/")
|
|
184
|
+
if explicit_name:
|
|
185
|
+
# Honour the exact name the human/convention asked for (e.g. "CD-72").
|
|
186
|
+
branch = explicit_name
|
|
187
|
+
else:
|
|
188
|
+
key = slugify(jira_key or "task", max_len=24)
|
|
189
|
+
branch = f"feature/{key}-{slugify(summary)}"
|
|
176
190
|
# Fetch so any remote base ref (origin/HEAD, origin/<base>) is present/fresh.
|
|
177
191
|
await _run(["git", "fetch", "origin"], path, env, timeout=180.0)
|
|
178
192
|
|
|
@@ -292,8 +306,13 @@ async def finish_pr(
|
|
|
292
306
|
env = _git_env(github_token)
|
|
293
307
|
|
|
294
308
|
await _run(["git", "add", "-A"], path, env)
|
|
295
|
-
# commit —
|
|
296
|
-
|
|
309
|
+
# commit — returns non-zero when there's nothing staged. Capture that: a
|
|
310
|
+
# zero exit means a real commit was created (the agent actually changed
|
|
311
|
+
# something), which the caller uses as the authoritative "did work happen?"
|
|
312
|
+
# signal — far more reliable than files_changed, which also counts files the
|
|
313
|
+
# agent merely READ.
|
|
314
|
+
commit_rc, _, _ = await _run(["git", "commit", "-m", title], path, env)
|
|
315
|
+
committed = commit_rc == 0
|
|
297
316
|
|
|
298
317
|
rc, _, push_err = await _run(
|
|
299
318
|
["git", "push", "-u", "origin", branch, "--force-with-lease"],
|
|
@@ -302,7 +321,8 @@ async def finish_pr(
|
|
|
302
321
|
pushed = rc == 0
|
|
303
322
|
if not pushed:
|
|
304
323
|
logger.warning("git_ops.push_failed branch=%s error=%s", branch, push_err[:300])
|
|
305
|
-
return {"pushed": False, "pr_url": None, "
|
|
324
|
+
return {"pushed": False, "pr_url": None, "committed": committed,
|
|
325
|
+
"pr_error": f"push failed: {push_err[:200]}"}
|
|
306
326
|
|
|
307
327
|
pr_url: str | None = None
|
|
308
328
|
pr_error: str | None = None
|
|
@@ -347,4 +367,4 @@ async def finish_pr(
|
|
|
347
367
|
pr_error = "no PR created"
|
|
348
368
|
logger.warning("git_ops.no_pr branch=%s reason=%s", branch, pr_error)
|
|
349
369
|
|
|
350
|
-
return {"pushed": pushed, "pr_url": pr_url, "pr_error": pr_error}
|
|
370
|
+
return {"pushed": pushed, "pr_url": pr_url, "pr_error": pr_error, "committed": committed}
|
|
@@ -154,6 +154,9 @@ def _build_local_prompt(
|
|
|
154
154
|
human_instructions: str | None = None,
|
|
155
155
|
agent_mode: bool = True,
|
|
156
156
|
git_managed: bool = True,
|
|
157
|
+
repo_name: str | None = None,
|
|
158
|
+
grounded: bool = False,
|
|
159
|
+
branch_convention: str | None = None,
|
|
157
160
|
) -> str:
|
|
158
161
|
"""Compose the coding prompt for the local claude CLI from the server plan.
|
|
159
162
|
|
|
@@ -173,6 +176,25 @@ def _build_local_prompt(
|
|
|
173
176
|
locally itself.
|
|
174
177
|
"""
|
|
175
178
|
parts: list[str] = []
|
|
179
|
+
# 0. Per-repo scope + grounding framing — keeps a multi-repo ticket from
|
|
180
|
+
# bleeding one repo's work into another, and points the agent at the
|
|
181
|
+
# grounded plan it just produced during the read-only recon pass.
|
|
182
|
+
if repo_name:
|
|
183
|
+
scope_line = (
|
|
184
|
+
f"You are working ONLY in the `{repo_name}` repository. Make changes "
|
|
185
|
+
f"here that belong to THIS repo; do not attempt work that belongs to a "
|
|
186
|
+
f"different repository."
|
|
187
|
+
)
|
|
188
|
+
if grounded:
|
|
189
|
+
scope_line += (
|
|
190
|
+
" You have just completed a READ-ONLY exploration of this repo in "
|
|
191
|
+
"this same session and produced a SCOPE / FILES / PATTERNS / PLAN "
|
|
192
|
+
"report. Implement THAT grounded plan now — edit the real files you "
|
|
193
|
+
"identified and follow the existing patterns you found. The server "
|
|
194
|
+
"plan below is secondary reference; if it conflicts with what the "
|
|
195
|
+
"code actually is, trust the code and the operator instructions."
|
|
196
|
+
)
|
|
197
|
+
parts.append(scope_line)
|
|
176
198
|
# 1. Operator instructions — FIRST, verbatim, authoritative.
|
|
177
199
|
if human_instructions and human_instructions.strip():
|
|
178
200
|
parts.append(
|
|
@@ -219,6 +241,12 @@ def _build_local_prompt(
|
|
|
219
241
|
"out, and the system will commit, push, and open the PR after you finish — "
|
|
220
242
|
"so do NOT create branches, commit, push, or open a PR for the current repo. "
|
|
221
243
|
"(You may still clone and open PRs for OTHER repositories the instructions name.)"
|
|
244
|
+
+ (
|
|
245
|
+
f"\nFor any OTHER repository you clone yourself, follow the branch-naming "
|
|
246
|
+
f"convention exactly: name the branch `{branch_convention}` "
|
|
247
|
+
f"(or the equivalent per that repo's ticket id)."
|
|
248
|
+
if branch_convention else ""
|
|
249
|
+
)
|
|
222
250
|
)
|
|
223
251
|
else:
|
|
224
252
|
# Plain local working folder (no GitHub repo / not under our git control) —
|
|
@@ -239,6 +267,90 @@ def _build_local_prompt(
|
|
|
239
267
|
return "\n\n".join(p for p in parts if p)
|
|
240
268
|
|
|
241
269
|
|
|
270
|
+
# Read-only tool set for the recon pass — no Write/Edit/Bash, so it physically
|
|
271
|
+
# cannot modify the repo or run git while it explores and plans.
|
|
272
|
+
_RECON_ALLOWED_TOOLS = ["Read", "Glob", "Grep"]
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _build_recon_prompt(
|
|
276
|
+
repo_name: str,
|
|
277
|
+
user_input: str | None,
|
|
278
|
+
structured_plan: dict | None,
|
|
279
|
+
human_instructions: str | None,
|
|
280
|
+
) -> str:
|
|
281
|
+
"""Prompt for the read-only recon pass: explore THIS repo, decide scope, and
|
|
282
|
+
produce a codebase-grounded plan. The server plan is passed as HINTS only —
|
|
283
|
+
the whole point is to replace guessed files/contracts with real ones.
|
|
284
|
+
"""
|
|
285
|
+
parts: list[str] = [
|
|
286
|
+
f"READ-ONLY RECON PASS for the `{repo_name}` repository. Do NOT create, "
|
|
287
|
+
"edit, or delete ANY file, and do NOT run git — this pass ONLY explores "
|
|
288
|
+
"and produces a grounded plan. Implementation happens in the next step.",
|
|
289
|
+
]
|
|
290
|
+
if human_instructions and human_instructions.strip():
|
|
291
|
+
parts.append(
|
|
292
|
+
"=== Operator instructions (Jira humanInstructions) — AUTHORITATIVE ===\n"
|
|
293
|
+
+ human_instructions.strip()
|
|
294
|
+
)
|
|
295
|
+
sp = structured_plan or {}
|
|
296
|
+
criteria = sp.get("acceptance_criteria") or []
|
|
297
|
+
steps = sp.get("task_steps") or []
|
|
298
|
+
if criteria:
|
|
299
|
+
parts.append(
|
|
300
|
+
"Acceptance criteria (server plan — HINTS to verify against the real "
|
|
301
|
+
"code, not ground truth):\n" + "\n".join(f"- {c}" for c in criteria)
|
|
302
|
+
)
|
|
303
|
+
if steps:
|
|
304
|
+
lines = [
|
|
305
|
+
f"- {s.get('title', '')}: {s.get('description', '')}".rstrip(": ")
|
|
306
|
+
for s in steps if isinstance(s, dict)
|
|
307
|
+
]
|
|
308
|
+
if lines:
|
|
309
|
+
parts.append("Proposed steps (hints):\n" + "\n".join(lines))
|
|
310
|
+
if user_input and user_input.strip():
|
|
311
|
+
parts.append(
|
|
312
|
+
"=== Task description (reference) ===\n" + _clip_text(user_input.strip())
|
|
313
|
+
)
|
|
314
|
+
parts.append(
|
|
315
|
+
"Explore THIS repository to ground the work in what actually exists:\n"
|
|
316
|
+
"- If a CLAUDE.md is present, read it FIRST and follow its documented "
|
|
317
|
+
"structure, conventions and commands.\n"
|
|
318
|
+
"- Prefer graphify (the /graphify skill / graph queries) over reading whole "
|
|
319
|
+
"files, so this stays fast and cheap on large repos.\n"
|
|
320
|
+
"- Use Read/Glob/Grep to confirm the REAL files, modules, routes/endpoints, "
|
|
321
|
+
"request/response shapes, naming and error/test patterns involved.\n\n"
|
|
322
|
+
"Then output a report with these EXACT sections (and nothing that edits code):\n"
|
|
323
|
+
"SCOPE: IN — if this repo genuinely needs changes for this ticket\n"
|
|
324
|
+
"SCOPE: OUT — if NOTHING in this repo is in scope (the work belongs to a "
|
|
325
|
+
"different repo). Be honest — guessing IN and then changing nothing is a failure.\n"
|
|
326
|
+
"FILES: the actual files you will change (existing paths), or new files with "
|
|
327
|
+
"their correct location following this repo's real layout\n"
|
|
328
|
+
"PATTERNS: the existing conventions/contracts to follow (API shape, naming, "
|
|
329
|
+
"error handling, tests) — cite real examples you found\n"
|
|
330
|
+
"PLAN: a concrete, ordered implementation plan for THIS repo that references "
|
|
331
|
+
"the real files and patterns above"
|
|
332
|
+
)
|
|
333
|
+
return "\n\n".join(p for p in parts if p)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _parse_scope(recon_text: str | None) -> str:
|
|
337
|
+
"""'out' when recon explicitly declared the repo out of scope, else 'in'.
|
|
338
|
+
|
|
339
|
+
Defaults to 'in' on any ambiguity or recon failure so we never REGRESS to the
|
|
340
|
+
old silent-skip behaviour — a wrongly-included repo is still caught later by
|
|
341
|
+
the 'in-scope but 0 changes' compliance check.
|
|
342
|
+
"""
|
|
343
|
+
if not recon_text:
|
|
344
|
+
return "in"
|
|
345
|
+
low = recon_text.lower()
|
|
346
|
+
verdicts = re.findall(r"scope\s*[:=]\s*(in|out)\b", low)
|
|
347
|
+
if verdicts:
|
|
348
|
+
return "out" if verdicts[-1] == "out" else "in"
|
|
349
|
+
if "out-of-scope" in low or "out of scope" in low:
|
|
350
|
+
return "out"
|
|
351
|
+
return "in"
|
|
352
|
+
|
|
353
|
+
|
|
242
354
|
async def _run_local_pipeline(
|
|
243
355
|
client: BackendClient, payload: dict, send: Callable
|
|
244
356
|
) -> None:
|
|
@@ -448,7 +560,10 @@ async def _code_one_repo(
|
|
|
448
560
|
name = Path(repo_path).name
|
|
449
561
|
|
|
450
562
|
# 0. Make sure the repo is present (clone from repo_url only if missing/empty).
|
|
451
|
-
|
|
563
|
+
# `is_git` tells coding targets (real git repos → recon + branch + PR) apart
|
|
564
|
+
# from plain working folders (documentation / BA deliverables → no recon-
|
|
565
|
+
# scope gate, no branch/PR), so non-coding tasks aren't wrongly skipped.
|
|
566
|
+
is_git = await git_ops.ensure_repo(repo_path, repo_url, gh_token)
|
|
452
567
|
|
|
453
568
|
_write_count = 0
|
|
454
569
|
|
|
@@ -490,13 +605,80 @@ async def _code_one_repo(
|
|
|
490
605
|
except Exception: # noqa: BLE001
|
|
491
606
|
pass
|
|
492
607
|
|
|
493
|
-
# 2.
|
|
494
|
-
#
|
|
608
|
+
# 2. RECON (read-only): before touching anything, explore THIS repo and
|
|
609
|
+
# decide scope + a grounded plan. Skipped on a resume (rate-limit/context
|
|
610
|
+
# retry) — the session already holds that context, so we go straight back
|
|
611
|
+
# to implementing instead of re-exploring and re-spending tokens.
|
|
612
|
+
# Recon is for CODE repos (there's a codebase to ground against + a scope
|
|
613
|
+
# decision to make). A plain working folder (documentation / analyst
|
|
614
|
+
# deliverables) has no codebase to explore and is never "out of scope", so
|
|
615
|
+
# it skips recon and goes straight to producing its deliverables.
|
|
616
|
+
recon_session: str | None = None
|
|
617
|
+
scope = "in"
|
|
618
|
+
recon: dict = {}
|
|
619
|
+
if not resume_session_id and is_git:
|
|
620
|
+
await send(Envelope(
|
|
621
|
+
type="step_update", thread_id=thread_id,
|
|
622
|
+
data={"status": "running",
|
|
623
|
+
"current_step": f"[{name}] Recon — exploring the repo to ground the plan"},
|
|
624
|
+
).model_dump())
|
|
625
|
+
recon = await local_exec.run_claude_local(
|
|
626
|
+
_build_recon_prompt(
|
|
627
|
+
name, pending["user_input"], pending.get("structured_plan"),
|
|
628
|
+
pending.get("human_instructions"),
|
|
629
|
+
),
|
|
630
|
+
repo_path, on_event=_on_event, github_token=gh_token,
|
|
631
|
+
allowed_tools=_RECON_ALLOWED_TOOLS, # read-only: no Write/Edit/Bash
|
|
632
|
+
)
|
|
633
|
+
recon_session = recon.get("session_id")
|
|
634
|
+
# A usage-limit hit during recon must be surfaced so the loop waits/retries
|
|
635
|
+
# rather than treating the repo as done — return it straight up.
|
|
636
|
+
if recon.get("limit_exceeded"):
|
|
637
|
+
return {"repo": repo_path, "result": recon, "pr": None,
|
|
638
|
+
"branch": None, "scope": "in", "compliance": {"issues": []}}
|
|
639
|
+
if recon.get("ok"):
|
|
640
|
+
scope = _parse_scope(recon.get("result"))
|
|
641
|
+
else:
|
|
642
|
+
# Recon errored (not a limit) → don't sink the ticket: fall back to the
|
|
643
|
+
# (ungrounded) server plan and let implementation proceed.
|
|
644
|
+
await send(Envelope(
|
|
645
|
+
type="step_update", thread_id=thread_id,
|
|
646
|
+
data={"status": "running",
|
|
647
|
+
"current_step": f"[{name}] Recon unavailable "
|
|
648
|
+
f"({(recon.get('error') or '')[:80]}) — proceeding on the server plan"},
|
|
649
|
+
).model_dump())
|
|
650
|
+
|
|
651
|
+
if scope == "out":
|
|
652
|
+
# Honest "nothing here" — no branch, no PR, no empty commit. Reported as a
|
|
653
|
+
# deliberate no-op (NOT a silent skip, NOT a failure).
|
|
654
|
+
await send(Envelope(
|
|
655
|
+
type="step_update", thread_id=thread_id,
|
|
656
|
+
data={"status": "running",
|
|
657
|
+
"current_step": f"[{name}] Out of scope — recon found no work for this ticket here"},
|
|
658
|
+
).model_dump())
|
|
659
|
+
return {
|
|
660
|
+
"repo": repo_path,
|
|
661
|
+
"result": {
|
|
662
|
+
"ok": True,
|
|
663
|
+
"result": f"OUT_OF_SCOPE — recon found no work for this ticket in {name}.",
|
|
664
|
+
"files_changed": [],
|
|
665
|
+
"input_tokens": recon.get("input_tokens", 0),
|
|
666
|
+
"output_tokens": recon.get("output_tokens", 0),
|
|
667
|
+
"total_tokens": recon.get("total_tokens", 0),
|
|
668
|
+
"cost_usd": recon.get("cost_usd", 0.0),
|
|
669
|
+
"session_id": recon_session,
|
|
670
|
+
},
|
|
671
|
+
"pr": None, "branch": None, "scope": "out", "compliance": {"issues": []},
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
# 3. feature branch off the resolved base — named per the human/convention
|
|
675
|
+
# directive when given (e.g. exactly "CD-72"), else feature/<key>-<slug>.
|
|
676
|
+
branch_name = _resolve_branch_name(pending.get("human_instructions"), pending.get("jira_key"))
|
|
495
677
|
branch_info = None
|
|
496
678
|
try:
|
|
497
679
|
branch_info = await git_ops.start_branch(
|
|
498
680
|
repo_path, pending.get("jira_key"), pending.get("summary"), gh_token,
|
|
499
|
-
base=base,
|
|
681
|
+
base=base, branch_name=branch_name,
|
|
500
682
|
)
|
|
501
683
|
if branch_info:
|
|
502
684
|
await send(Envelope(
|
|
@@ -514,18 +696,22 @@ async def _code_one_repo(
|
|
|
514
696
|
|
|
515
697
|
# Build the prompt with framing that matches the target: a git repo we manage
|
|
516
698
|
# (branch + PR) vs a plain local folder (BA/analyst deliverables, no git/PR).
|
|
699
|
+
# When recon ran, the implementation resumes that session so its grounded plan
|
|
700
|
+
# stays in context; the prompt tells the agent to implement THAT plan.
|
|
517
701
|
prompt = _build_local_prompt(
|
|
518
702
|
pending["user_input"], pending.get("structured_plan"),
|
|
519
703
|
pending.get("human_instructions"),
|
|
520
704
|
agent_mode=agent_mode, git_managed=bool(branch_info),
|
|
705
|
+
repo_name=name, grounded=bool(recon_session), branch_convention=branch_name,
|
|
521
706
|
)
|
|
522
707
|
|
|
523
|
-
#
|
|
524
|
-
#
|
|
708
|
+
# 3b. initial claude run. resume_session_id (rate-limit/context retry) wins;
|
|
709
|
+
# otherwise resume the recon session so implementation builds on the recon
|
|
710
|
+
# exploration instead of starting cold.
|
|
525
711
|
result = await local_exec.run_claude_local(
|
|
526
712
|
prompt, repo_path, on_event=_on_event,
|
|
527
713
|
github_token=gh_token, approval_dir=approval_dir,
|
|
528
|
-
resume_session_id=resume_session_id or None,
|
|
714
|
+
resume_session_id=resume_session_id or recon_session or None,
|
|
529
715
|
)
|
|
530
716
|
|
|
531
717
|
# 4. context-window recovery
|
|
@@ -645,6 +831,71 @@ async def _code_one_repo(
|
|
|
645
831
|
)
|
|
646
832
|
session_id = result.get("session_id") or session_id
|
|
647
833
|
|
|
834
|
+
# 5b. SELF-REPAIR loop (diagnose → resume → retry). The recovery loops above
|
|
835
|
+
# handle the KNOWN, self-describing failures (context overflow, output
|
|
836
|
+
# cap, usage limit). This loop handles everything else — a build that
|
|
837
|
+
# won't compile, a failing test, a tool error, or a run that finished OK
|
|
838
|
+
# but produced NOTHING. Instead of dropping the ticket to Backlog, we
|
|
839
|
+
# resume the SAME session and ask the agent to first DIAGNOSE the root
|
|
840
|
+
# cause, then fix and finish. Bounded by _MAX_REPAIR_ATTEMPTS so it can
|
|
841
|
+
# never loop forever / burn tokens indefinitely; limit/context/cap errors
|
|
842
|
+
# are excluded so their dedicated handlers own them.
|
|
843
|
+
repair = 0
|
|
844
|
+
while session_id and repair < _MAX_REPAIR_ATTEMPTS \
|
|
845
|
+
and not result.get("limit_exceeded") \
|
|
846
|
+
and not result.get("context_exceeded") \
|
|
847
|
+
and not result.get("output_cap_exceeded"):
|
|
848
|
+
errored = not result.get("ok")
|
|
849
|
+
# "did real work happen?" — actual Write/Edit count, not files merely read.
|
|
850
|
+
empty = result.get("ok") and _write_count == 0
|
|
851
|
+
if not (errored or empty):
|
|
852
|
+
break
|
|
853
|
+
repair += 1
|
|
854
|
+
if errored:
|
|
855
|
+
await send(Envelope(
|
|
856
|
+
type="step_update", thread_id=thread_id,
|
|
857
|
+
data={"status": "running",
|
|
858
|
+
"current_step": f"[{name}] Error — diagnosing & repairing "
|
|
859
|
+
f"(attempt {repair}/{_MAX_REPAIR_ATTEMPTS})"},
|
|
860
|
+
).model_dump())
|
|
861
|
+
try:
|
|
862
|
+
await client.log_activity(
|
|
863
|
+
f"Task {task_id} [{name}]: run errored — auto-diagnosing "
|
|
864
|
+
f"(attempt {repair}): {(result.get('error') or '')[:160]}"
|
|
865
|
+
)
|
|
866
|
+
except Exception: # noqa: BLE001
|
|
867
|
+
pass
|
|
868
|
+
repair_prompt = (
|
|
869
|
+
"Your previous attempt ended with an ERROR:\n\n"
|
|
870
|
+
f"{(result.get('error') or result.get('result') or 'unknown error')[:1500]}\n\n"
|
|
871
|
+
"Do NOT start over. First DIAGNOSE the root cause: re-read the relevant "
|
|
872
|
+
"files, re-run the failing command to see its ACTUAL output, and check "
|
|
873
|
+
"any logs. State the cause in one line, then FIX it and continue until "
|
|
874
|
+
"the task is complete and verified. If the same command keeps failing, "
|
|
875
|
+
"try a different approach rather than repeating it."
|
|
876
|
+
)
|
|
877
|
+
else: # empty — ran clean but wrote nothing
|
|
878
|
+
await send(Envelope(
|
|
879
|
+
type="step_update", thread_id=thread_id,
|
|
880
|
+
data={"status": "running",
|
|
881
|
+
"current_step": f"[{name}] Finished with no changes — re-checking scope "
|
|
882
|
+
f"(attempt {repair}/{_MAX_REPAIR_ATTEMPTS})"},
|
|
883
|
+
).model_dump())
|
|
884
|
+
repair_prompt = (
|
|
885
|
+
"You finished WITHOUT creating or editing any files, but this task is "
|
|
886
|
+
"expected to produce concrete changes/deliverables. Re-read the operator "
|
|
887
|
+
"instructions and the plan you produced, and implement the actual work now "
|
|
888
|
+
"(edit or create the real files). If — and only if — you are certain "
|
|
889
|
+
"nothing belongs in this location, reply with a single line explaining "
|
|
890
|
+
"precisely why, and make no changes."
|
|
891
|
+
)
|
|
892
|
+
result = await local_exec.run_claude_local(
|
|
893
|
+
repair_prompt, repo_path, on_event=_on_event,
|
|
894
|
+
github_token=gh_token, resume_session_id=session_id,
|
|
895
|
+
approval_dir=approval_dir,
|
|
896
|
+
)
|
|
897
|
+
session_id = result.get("session_id") or session_id
|
|
898
|
+
|
|
648
899
|
# 6. commit + push + PR for THIS repo
|
|
649
900
|
pr = None
|
|
650
901
|
if branch_info and result.get("ok"):
|
|
@@ -671,8 +922,39 @@ async def _code_one_repo(
|
|
|
671
922
|
except Exception as exc: # noqa: BLE001
|
|
672
923
|
logger.warning("local_run.finish_pr_failed", error=str(exc))
|
|
673
924
|
|
|
925
|
+
# 7. Compliance verification (enforce + verify). An in-scope repo that ran OK
|
|
926
|
+
# but changed nothing, or that failed to push / open a PR, is NOT "done" —
|
|
927
|
+
# these become issues the caller turns into a loud failure, never a silent
|
|
928
|
+
# success. (Branch NAME is compliant by construction: the SDK created it
|
|
929
|
+
# from the resolved convention above.)
|
|
930
|
+
issues: list[str] = []
|
|
931
|
+
if result.get("ok"):
|
|
932
|
+
if branch_info:
|
|
933
|
+
# CODE repo: `committed` from finish_pr is authoritative (true only when
|
|
934
|
+
# a real commit was created); files_changed is a weak fallback.
|
|
935
|
+
made_output = pr.get("committed") if pr else bool(result.get("files_changed"))
|
|
936
|
+
if not made_output:
|
|
937
|
+
issues.append("in scope but produced 0 committed changes")
|
|
938
|
+
elif not pr.get("pushed"):
|
|
939
|
+
issues.append("branch was not pushed")
|
|
940
|
+
elif not pr.get("pr_url"):
|
|
941
|
+
issues.append(f"no PR opened ({(pr or {}).get('pr_error') or 'unknown'})")
|
|
942
|
+
else:
|
|
943
|
+
# Plain working folder (documentation / analyst deliverables, non-git):
|
|
944
|
+
# no branch/PR to verify, but it must still have PRODUCED something.
|
|
945
|
+
if _write_count == 0:
|
|
946
|
+
issues.append("task produced no files/deliverables")
|
|
947
|
+
if issues:
|
|
948
|
+
await send(Envelope(
|
|
949
|
+
type="step_update", thread_id=thread_id,
|
|
950
|
+
data={"status": "running",
|
|
951
|
+
"current_step": f"[{name}] Compliance issue(s): " + "; ".join(issues)},
|
|
952
|
+
).model_dump())
|
|
953
|
+
|
|
674
954
|
return {"repo": repo_path, "result": result, "pr": pr,
|
|
675
|
-
"branch": (branch_info or {}).get("branch")
|
|
955
|
+
"branch": (branch_info or {}).get("branch"), "scope": scope,
|
|
956
|
+
"compliance": {"issues": issues, "expected_branch": branch_name,
|
|
957
|
+
"branch": (branch_info or {}).get("branch")}}
|
|
676
958
|
|
|
677
959
|
|
|
678
960
|
async def handle_pipeline_approve(
|
|
@@ -776,7 +1058,17 @@ async def handle_pipeline_approve(
|
|
|
776
1058
|
_shutil.rmtree(approval_dir, ignore_errors=True)
|
|
777
1059
|
|
|
778
1060
|
# ── Aggregate the per-repo runs for the server finalize + done payload ──
|
|
779
|
-
|
|
1061
|
+
# Scope-aware: OUT-OF-SCOPE repos (recon found no work there) are deliberate
|
|
1062
|
+
# no-ops, not successes to brag about nor failures to punish. Only IN-SCOPE
|
|
1063
|
+
# repos decide whether the ticket is actually done, and each must be both
|
|
1064
|
+
# ok AND compliant (pushed, PR opened, non-empty) — enforce + verify.
|
|
1065
|
+
scoped = [r for r in runs if r.get("scope", "in") != "out"]
|
|
1066
|
+
out_of_scope = [r for r in runs if r.get("scope") == "out"]
|
|
1067
|
+
noncompliant = [
|
|
1068
|
+
r for r in scoped
|
|
1069
|
+
if (not r["result"].get("ok")) or r.get("compliance", {}).get("issues")
|
|
1070
|
+
]
|
|
1071
|
+
ok_any = any(r["result"].get("ok") for r in scoped)
|
|
780
1072
|
parts: list[str] = []
|
|
781
1073
|
pr_urls: list[str] = []
|
|
782
1074
|
files_changed: list[str] = []
|
|
@@ -828,24 +1120,48 @@ async def handle_pipeline_approve(
|
|
|
828
1120
|
"repo_resume_sessions": repo_resume_sessions,
|
|
829
1121
|
}
|
|
830
1122
|
|
|
831
|
-
# ──
|
|
832
|
-
|
|
833
|
-
|
|
1123
|
+
# ── Fail loud unless EVERY in-scope repo succeeded AND is compliant ──────
|
|
1124
|
+
# This is the enforce-side of the fix: a repo that ran but changed nothing,
|
|
1125
|
+
# didn't push, or skipped its PR no longer slips through as "done"; and a
|
|
1126
|
+
# ticket whose named repos ALL came back out of scope is escalated for human
|
|
1127
|
+
# review rather than silently closed.
|
|
1128
|
+
if not scoped or noncompliant:
|
|
1129
|
+
if not scoped:
|
|
1130
|
+
named = ", ".join(Path(r["repo"]).name for r in runs) or "the named repo(s)"
|
|
1131
|
+
err = (f"recon found no in-scope work in {named} — nothing was implemented; "
|
|
1132
|
+
"needs human review (wrong repo/path, or the work belongs elsewhere)")
|
|
1133
|
+
else:
|
|
1134
|
+
def _why(r: dict) -> str:
|
|
1135
|
+
res = r["result"]
|
|
1136
|
+
reason = (res.get("error")
|
|
1137
|
+
or "; ".join(r.get("compliance", {}).get("issues", []))
|
|
1138
|
+
or "failed")
|
|
1139
|
+
return f"{Path(r['repo']).name}: {reason}"
|
|
1140
|
+
err = "incomplete / non-compliant — " + " | ".join(_why(r) for r in noncompliant)
|
|
834
1141
|
note = (f"⚠ Task {task_id}: context window exceeded — split the ticket or narrow scope."
|
|
835
1142
|
if _is_context_window_error(err)
|
|
836
|
-
else f"⚠ Task {task_id}:
|
|
1143
|
+
else f"⚠ Task {task_id}: {err[:220]}")
|
|
837
1144
|
try:
|
|
838
1145
|
await client.notify_whatsapp(task_id, note, summary="AgentOS local run alert")
|
|
839
1146
|
except Exception: # noqa: BLE001
|
|
840
1147
|
pass
|
|
841
|
-
await client.log_activity(f"Task {task_id}:
|
|
1148
|
+
await client.log_activity(f"Task {task_id}: {err[:300]}")
|
|
842
1149
|
await client.move_to_backlog(task_id)
|
|
843
1150
|
await send(Envelope(
|
|
844
1151
|
type=Envelope.TYPE_ERROR, thread_id=thread_id,
|
|
845
|
-
data={"code": "
|
|
1152
|
+
data={"code": "LOCAL_EXEC_INCOMPLETE", "message": err[:300], "task_id": task_id},
|
|
846
1153
|
).model_dump())
|
|
847
1154
|
return
|
|
848
1155
|
|
|
1156
|
+
# Surface deliberate no-ops so a skipped repo is transparent, not invisible.
|
|
1157
|
+
if out_of_scope:
|
|
1158
|
+
skipped = ", ".join(Path(r["repo"]).name for r in out_of_scope)
|
|
1159
|
+
await send(Envelope(
|
|
1160
|
+
type="step_update", thread_id=thread_id,
|
|
1161
|
+
data={"status": "running",
|
|
1162
|
+
"current_step": f"Out of scope (no changes): {skipped}"},
|
|
1163
|
+
).model_dump())
|
|
1164
|
+
|
|
849
1165
|
# ── Finalize on the server once per task (validation + Jira/cost) ─────
|
|
850
1166
|
await send(Envelope(
|
|
851
1167
|
type="step_update", thread_id=thread_id,
|
|
@@ -1499,6 +1815,84 @@ def _resolve_base_for(name: str, url: str | None,
|
|
|
1499
1815
|
return global_base
|
|
1500
1816
|
|
|
1501
1817
|
|
|
1818
|
+
# Explicit branch-NAME directives (distinct from the base branch you branch
|
|
1819
|
+
# FROM). The SDK — not the agent — owns branching for the managed repo, so a
|
|
1820
|
+
# convention like "name the branch exactly the ticket id" MUST be applied here.
|
|
1821
|
+
_BRANCH_TICKET_RE = _re.compile(
|
|
1822
|
+
r"branch\b[^.\n]{0,60}?\b(?:jira[\s\-]?)?(?:ticket|jira|story|issue)"
|
|
1823
|
+
r"[\s\-]?(?:id|key|number|name|no\.?)\b",
|
|
1824
|
+
_re.I,
|
|
1825
|
+
)
|
|
1826
|
+
_BRANCH_LITERAL_RES = [
|
|
1827
|
+
_re.compile(
|
|
1828
|
+
r"(?:name|call|create|use)\s+(?:the\s+)?branch\s+"
|
|
1829
|
+
r"(?:named\s+|called\s+|exactly\s+|as\s+)?[\"'`]?([A-Za-z0-9][\w.\-/]*)[\"'`]?",
|
|
1830
|
+
_re.I,
|
|
1831
|
+
),
|
|
1832
|
+
_re.compile(
|
|
1833
|
+
r"branch\s*(?:name)?\s*(?:should\s+be|must\s+be|has\s+to\s+be|is|=|:)\s*"
|
|
1834
|
+
r"[\"'`]?([A-Za-z0-9][\w.\-/]*)[\"'`]?",
|
|
1835
|
+
_re.I,
|
|
1836
|
+
),
|
|
1837
|
+
]
|
|
1838
|
+
# Never a real branch name — filters false positives from the loose patterns.
|
|
1839
|
+
_BRANCH_NAME_NOISE = {
|
|
1840
|
+
"the", "a", "an", "exactly", "named", "called", "from", "off", "of",
|
|
1841
|
+
"your", "this", "that", "new", "same", "current", "default", "please",
|
|
1842
|
+
"name", "id", "key", "number", "ticket", "jira", "feature", "should", "must",
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
|
|
1846
|
+
def _extract_branch_name(text: str | None, jira_key: str | None) -> str | None:
|
|
1847
|
+
"""An explicit branch NAME directive from humanInstructions, or None.
|
|
1848
|
+
|
|
1849
|
+
Two shapes are recognised:
|
|
1850
|
+
- a convention referring to the ticket id ("name the branch exactly the
|
|
1851
|
+
JIRA ticket id") → resolves to ``jira_key``.
|
|
1852
|
+
- a literal name ("branch: CD-72", "name the branch release-1").
|
|
1853
|
+
Base-branch directives ("branch from main") are deliberately NOT matched —
|
|
1854
|
+
_extract_base_branches owns those.
|
|
1855
|
+
"""
|
|
1856
|
+
if not text:
|
|
1857
|
+
return None
|
|
1858
|
+
if jira_key and _BRANCH_TICKET_RE.search(text):
|
|
1859
|
+
return jira_key
|
|
1860
|
+
for rx in _BRANCH_LITERAL_RES:
|
|
1861
|
+
for m in rx.finditer(text):
|
|
1862
|
+
cand = (m.group(1) or "").strip().strip("./")
|
|
1863
|
+
low = cand.lower()
|
|
1864
|
+
if not cand or low in _BRANCH_NAME_NOISE:
|
|
1865
|
+
continue
|
|
1866
|
+
# "branch: main" alongside "branch from main" is a base, not a name.
|
|
1867
|
+
if low in ("main", "master", "develop", "dev") and "branch from" in text.lower():
|
|
1868
|
+
continue
|
|
1869
|
+
return cand
|
|
1870
|
+
return None
|
|
1871
|
+
|
|
1872
|
+
|
|
1873
|
+
def _resolve_branch_name(text: str | None, jira_key: str | None) -> str | None:
|
|
1874
|
+
"""Final branch name: per-ticket directive → configured template → None.
|
|
1875
|
+
|
|
1876
|
+
None means "use the SDK default" (feature/<key>-<slug>). A per-ticket
|
|
1877
|
+
directive always beats the org-wide ``branch_name_template`` in config.
|
|
1878
|
+
"""
|
|
1879
|
+
explicit = _extract_branch_name(text, jira_key)
|
|
1880
|
+
if explicit:
|
|
1881
|
+
return explicit
|
|
1882
|
+
try:
|
|
1883
|
+
from sdk import config
|
|
1884
|
+
tmpl = config.get_branch_name_template()
|
|
1885
|
+
except Exception: # noqa: BLE001
|
|
1886
|
+
tmpl = None
|
|
1887
|
+
if tmpl:
|
|
1888
|
+
try:
|
|
1889
|
+
resolved = tmpl.format(jira_key=jira_key or "", key=jira_key or "").strip()
|
|
1890
|
+
return resolved or None
|
|
1891
|
+
except Exception: # noqa: BLE001
|
|
1892
|
+
return tmpl
|
|
1893
|
+
return None
|
|
1894
|
+
|
|
1895
|
+
|
|
1502
1896
|
def _extract_repo_targets(text: str | None) -> list[dict]:
|
|
1503
1897
|
"""All repo work-targets named in `text`: ``[{"path", "url", "base"}]``.
|
|
1504
1898
|
|
|
@@ -1607,6 +2001,10 @@ _MAX_CONTEXT_RESUMES = 3
|
|
|
1607
2001
|
_MAX_OUTPUT_CAP_RESUMES = 3
|
|
1608
2002
|
# Max mid-run crucial-decision approval gates per ticket (safety bound).
|
|
1609
2003
|
_MAX_CRUCIAL_GATES = 10
|
|
2004
|
+
# Max diagnose-and-retry (self-repair) passes per repo for a generic error or an
|
|
2005
|
+
# empty (ran-clean-but-changed-nothing) result — distinct from context/cap/limit,
|
|
2006
|
+
# which have their own handlers. Bounded so a stuck task can't burn tokens forever.
|
|
2007
|
+
_MAX_REPAIR_ATTEMPTS = int(os.getenv("AGENTOS_MAX_REPAIR_ATTEMPTS", "") or 2)
|
|
1610
2008
|
# Max times a ticket is retried after hitting the Claude usage limit.
|
|
1611
2009
|
_MAX_LIMIT_RETRIES = 3
|
|
1612
2010
|
# Cap on how long we'll sleep waiting for a usage-limit window to reset.
|
|
@@ -29,10 +29,12 @@ itself (then re-runs on the new code) BEFORE detaching, so non-technical users
|
|
|
29
29
|
never reinstall.
|
|
30
30
|
|
|
31
31
|
Stop model: `stop` is immediate — it kills the loop's whole process group (the
|
|
32
|
-
daemon and any child `claude`)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
daemon and any child `claude`), then returns to "To Do" both the in-flight ticket
|
|
33
|
+
AND every ticket this run parked in Backlog (e.g. 15-min approval timeouts), so a
|
|
34
|
+
later run re-attempts them; human-parked Backlog tickets are left untouched. The
|
|
35
|
+
in-flight id is kept in ~/.agentos/auto.current and the run's Backlog set in
|
|
36
|
+
~/.agentos/auto.backlogged (maintained via a client status observer). `resume`
|
|
37
|
+
(or just re-running) starts fresh over whatever is still To Do.
|
|
36
38
|
"""
|
|
37
39
|
from __future__ import annotations
|
|
38
40
|
|
|
@@ -49,6 +51,10 @@ _LOG_FILE = _AGENTOS_DIR / "auto.log"
|
|
|
49
51
|
# Backend id (UUID) of the ticket currently being processed. `stop` reads it to
|
|
50
52
|
# return the in-flight ticket to "To Do" before killing the loop.
|
|
51
53
|
_CURRENT_FILE = _AGENTOS_DIR / "auto.current"
|
|
54
|
+
# Backend ids the CURRENT run has moved to Backlog (e.g. 15-min approval
|
|
55
|
+
# timeouts). `stop` restores exactly these to "To Do" too — not human-parked
|
|
56
|
+
# Backlog tickets. Maintained live via a client status observer.
|
|
57
|
+
_BACKLOGGED_FILE = _AGENTOS_DIR / "auto.backlogged"
|
|
52
58
|
|
|
53
59
|
|
|
54
60
|
def _stop_requested() -> bool:
|
|
@@ -90,6 +96,41 @@ def _clear_current() -> None:
|
|
|
90
96
|
pass
|
|
91
97
|
|
|
92
98
|
|
|
99
|
+
def _read_backlogged() -> set[str]:
|
|
100
|
+
try:
|
|
101
|
+
return set(json.loads(_BACKLOGGED_FILE.read_text()))
|
|
102
|
+
except (FileNotFoundError, ValueError, TypeError):
|
|
103
|
+
return set()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _write_backlogged(ids: set[str]) -> None:
|
|
107
|
+
try:
|
|
108
|
+
_AGENTOS_DIR.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
_BACKLOGGED_FILE.write_text(json.dumps(sorted(ids)))
|
|
110
|
+
except Exception: # noqa: BLE001
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _clear_backlogged() -> None:
|
|
115
|
+
try:
|
|
116
|
+
_BACKLOGGED_FILE.unlink()
|
|
117
|
+
except FileNotFoundError:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _track_status(task_id: str, status: str) -> None:
|
|
122
|
+
"""Client status observer (runs in the daemon): keep the set of tickets THIS
|
|
123
|
+
run has parked in Backlog current, so `stop` can restore them to To Do."""
|
|
124
|
+
if not task_id:
|
|
125
|
+
return
|
|
126
|
+
ids = _read_backlogged()
|
|
127
|
+
if status == "backlog":
|
|
128
|
+
ids.add(task_id)
|
|
129
|
+
else: # in_progress / to_do → no longer backlogged by us
|
|
130
|
+
ids.discard(task_id)
|
|
131
|
+
_write_backlogged(ids)
|
|
132
|
+
|
|
133
|
+
|
|
93
134
|
# ── Background-daemon support ─────────────────────────────────────────────
|
|
94
135
|
# `agentos-auto` must keep running after the terminal is closed and stop ONLY on
|
|
95
136
|
# `agentos-auto stop`. We fully detach from the controlling terminal (double-fork
|
|
@@ -224,7 +265,7 @@ async def _send(env: dict) -> None:
|
|
|
224
265
|
|
|
225
266
|
def _run_loop(workspace_root: str | None) -> None:
|
|
226
267
|
from sdk import local_exec
|
|
227
|
-
from sdk.client import BackendClient
|
|
268
|
+
from sdk.client import BackendClient, register_status_observer
|
|
228
269
|
from sdk.ipc import handlers
|
|
229
270
|
|
|
230
271
|
if not local_exec.is_available():
|
|
@@ -243,6 +284,8 @@ def _run_loop(workspace_root: str | None) -> None:
|
|
|
243
284
|
payload["workspace_root"] = workspace_root
|
|
244
285
|
|
|
245
286
|
_clear_stop() # a fresh run/resume clears any stale stop flag
|
|
287
|
+
_clear_backlogged() # session-scoped: only track Backlog moves from THIS run
|
|
288
|
+
register_status_observer(_track_status) # keep the run's Backlog set current
|
|
246
289
|
try:
|
|
247
290
|
asyncio.run(
|
|
248
291
|
handlers.handle_orchestrate_auto_local_run(
|
|
@@ -254,14 +297,15 @@ def _run_loop(workspace_root: str | None) -> None:
|
|
|
254
297
|
print("\n⏸ interrupted — stop flag set. run `agentos-auto resume` to continue.")
|
|
255
298
|
finally:
|
|
256
299
|
# Whether the loop finished, paused, or crashed, drop our PID file so
|
|
257
|
-
# `status` doesn't report a dead daemon as running, and clear the
|
|
258
|
-
#
|
|
300
|
+
# `status` doesn't report a dead daemon as running, and clear the in-flight
|
|
301
|
+
# + backlog markers (a clean finish leaves nothing for `stop` to restore).
|
|
259
302
|
if _read_pid() == os.getpid():
|
|
260
303
|
try:
|
|
261
304
|
_PID_FILE.unlink()
|
|
262
305
|
except FileNotFoundError:
|
|
263
306
|
pass
|
|
264
307
|
_clear_current()
|
|
308
|
+
_clear_backlogged()
|
|
265
309
|
|
|
266
310
|
|
|
267
311
|
def main() -> None:
|
|
@@ -269,10 +313,23 @@ def main() -> None:
|
|
|
269
313
|
cmd = argv[0] if argv and not argv[0].startswith("-") else "run"
|
|
270
314
|
|
|
271
315
|
if cmd == "update":
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
316
|
+
# Update-only: upgrade the installed SDK package to the latest version and
|
|
317
|
+
# report before → after. Does NOT start, stop, or touch the running loop —
|
|
318
|
+
# a running daemon keeps its current code until it is restarted. Honours
|
|
319
|
+
# AGENTOS_NO_UPDATE=1 (auto_update no-ops).
|
|
320
|
+
from sdk.updater import auto_update, current_version
|
|
321
|
+
before = current_version()
|
|
322
|
+
print("checking for SDK updates …")
|
|
323
|
+
auto_update(quiet=True)
|
|
324
|
+
after = current_version()
|
|
325
|
+
if after != before:
|
|
326
|
+
print(f"✓ updated {before} → {after}")
|
|
327
|
+
pid = _daemon_running()
|
|
328
|
+
if pid:
|
|
329
|
+
print("note: a background loop is running the OLD version — "
|
|
330
|
+
"`agentos-auto stop` then `agentos-auto` to run the new one.")
|
|
331
|
+
else:
|
|
332
|
+
print(f"already up to date (v{after})")
|
|
276
333
|
return
|
|
277
334
|
|
|
278
335
|
if cmd == "stop":
|
|
@@ -282,6 +339,7 @@ def main() -> None:
|
|
|
282
339
|
_set_stop()
|
|
283
340
|
pid = _daemon_running()
|
|
284
341
|
cur = _read_current()
|
|
342
|
+
backlogged = _read_backlogged() # tickets THIS run parked in Backlog
|
|
285
343
|
|
|
286
344
|
# 1. Kill the loop's ENTIRE process group first (daemon + any child
|
|
287
345
|
# `claude` it spawned) so nothing keeps mutating the ticket. The daemon
|
|
@@ -318,17 +376,31 @@ def main() -> None:
|
|
|
318
376
|
else:
|
|
319
377
|
print("no background loop running — stop flag set for any foreground run.")
|
|
320
378
|
|
|
321
|
-
# 2. Now that the loop is dead, move the in-flight ticket
|
|
322
|
-
|
|
323
|
-
|
|
379
|
+
# 2. Now that the loop is dead, move the in-flight ticket AND every ticket
|
|
380
|
+
# this run parked in Backlog (e.g. 15-min approval timeouts) back to
|
|
381
|
+
# To Do, so a later run re-attempts them. Human-parked Backlog tickets
|
|
382
|
+
# are untouched — only ids this run recorded are restored.
|
|
383
|
+
to_restore = [t for t in ({cur} | backlogged) if t]
|
|
384
|
+
if to_restore:
|
|
385
|
+
async def _restore(ids: list[str]) -> int:
|
|
324
386
|
from sdk.client import BackendClient
|
|
325
|
-
|
|
326
|
-
|
|
387
|
+
client = BackendClient.from_config()
|
|
388
|
+
done = 0
|
|
389
|
+
for t in ids:
|
|
390
|
+
try:
|
|
391
|
+
if await client.move_to_todo(t):
|
|
392
|
+
done += 1
|
|
393
|
+
except Exception: # noqa: BLE001
|
|
394
|
+
pass
|
|
395
|
+
return done
|
|
396
|
+
try:
|
|
397
|
+
n = asyncio.run(_restore(to_restore))
|
|
398
|
+
print(f"restored {n}/{len(to_restore)} ticket(s) → To Do")
|
|
327
399
|
except Exception as exc: # noqa: BLE001
|
|
328
|
-
print(f"warning: could not
|
|
400
|
+
print(f"warning: could not restore tickets to To Do ({exc})")
|
|
329
401
|
|
|
330
402
|
# 3. Clean up state files.
|
|
331
|
-
for f in (_PID_FILE, _CURRENT_FILE):
|
|
403
|
+
for f in (_PID_FILE, _CURRENT_FILE, _BACKLOGGED_FILE):
|
|
332
404
|
try:
|
|
333
405
|
f.unlink()
|
|
334
406
|
except FileNotFoundError:
|
|
File without changes
|
|
File without changes
|
{devops_bot_sdk-1.4.31 → devops_bot_sdk-1.4.35}/devops_bot_sdk.egg-info/dependency_links.txt
RENAMED
|
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
|