devops-bot-sdk 1.4.33__tar.gz → 1.4.37__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.33 → devops_bot_sdk-1.4.37}/PKG-INFO +1 -1
- {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.37}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
- {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.37}/pyproject.toml +1 -1
- {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.37}/sdk/__init__.py +2 -2
- {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.37}/sdk/client.py +35 -1
- {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.37}/sdk/config.py +16 -0
- {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.37}/sdk/git_ops.py +27 -7
- {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.37}/sdk/ipc/handlers.py +435 -21
- {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.37}/sdk/run_auto.py +58 -8
- {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.37}/README.md +0 -0
- {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.37}/devops_bot_sdk.egg-info/SOURCES.txt +0 -0
- {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.37}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
- {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.37}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
- {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.37}/devops_bot_sdk.egg-info/requires.txt +0 -0
- {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.37}/devops_bot_sdk.egg-info/top_level.txt +0 -0
- {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.37}/sdk/collectors/__init__.py +0 -0
- {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.37}/sdk/collectors/files.py +0 -0
- {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.37}/sdk/collectors/process.py +0 -0
- {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.37}/sdk/collectors/screenshot.py +0 -0
- {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.37}/sdk/crucial.py +0 -0
- {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.37}/sdk/exceptions.py +0 -0
- {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.37}/sdk/graphify.py +0 -0
- {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.37}/sdk/hooks/__init__.py +0 -0
- {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.37}/sdk/hooks/crucial_guard.py +0 -0
- {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.37}/sdk/ipc/__init__.py +0 -0
- {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.37}/sdk/ipc/electron_bridge.py +0 -0
- {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.37}/sdk/local_exec.py +0 -0
- {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.37}/sdk/models/__init__.py +0 -0
- {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.37}/sdk/models/envelope.py +0 -0
- {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.37}/sdk/models/requests.py +0 -0
- {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.37}/sdk/models/responses.py +0 -0
- {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.37}/sdk/models/snapshots.py +0 -0
- {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.37}/sdk/py.typed +0 -0
- {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.37}/sdk/sse.py +0 -0
- {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.37}/sdk/test.py +0 -0
- {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.37}/sdk/test_pipeline.py +0 -0
- {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.37}/sdk/updater.py +0 -0
- {devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.37}/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.37"
|
|
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.37
|
|
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.37"
|
|
34
34
|
__author__ = "AgentOS"
|
|
35
35
|
|
|
36
36
|
from sdk.client import BackendClient
|
|
@@ -58,7 +58,7 @@ def _notify_status(task_id: str, status: str) -> None:
|
|
|
58
58
|
pass
|
|
59
59
|
|
|
60
60
|
|
|
61
|
-
SDK_VERSION = "1.4.
|
|
61
|
+
SDK_VERSION = "1.4.37"
|
|
62
62
|
_POLL_INTERVAL = 3.0
|
|
63
63
|
_POLL_TIMEOUT = 600.0
|
|
64
64
|
_ORCHESTRATE_TIMEOUT = 2700.0 # 45 min — covers approval wait + VPS execution time
|
|
@@ -621,6 +621,40 @@ class BackendClient:
|
|
|
621
621
|
except Exception:
|
|
622
622
|
return []
|
|
623
623
|
|
|
624
|
+
async def list_tasks_by_keys(self, keys: list[str], limit: int = 10000) -> list[dict]:
|
|
625
|
+
"""GET /tasks-ml (ML backend) — RAW records for specific Jira keys.
|
|
626
|
+
|
|
627
|
+
Unlike list_todo_tasks_raw this does NOT filter by board status: a
|
|
628
|
+
targeted run (`agentos-auto ticket BBW-81`) should start the named ticket
|
|
629
|
+
whatever its current status (To Do / In Progress / Done), so a finished
|
|
630
|
+
ticket can be re-run on demand. Matches `jiraIssueKey` (falls back to
|
|
631
|
+
`jiraKey`) case-insensitively. Best-effort: returns [] on error. Preserves
|
|
632
|
+
the order of `keys` (each key's first matching record), so the run follows
|
|
633
|
+
the order the caller asked for.
|
|
634
|
+
"""
|
|
635
|
+
from sdk.config import BE_DEFAULT_URL
|
|
636
|
+
|
|
637
|
+
want = [k.strip().upper() for k in keys if k and k.strip()]
|
|
638
|
+
if not want:
|
|
639
|
+
return []
|
|
640
|
+
url = f"{BE_DEFAULT_URL.rstrip('/')}/tasks-ml"
|
|
641
|
+
headers = {**self._headers, "accept": "*/*", "developer-name": "null", "product": ""}
|
|
642
|
+
try:
|
|
643
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
644
|
+
resp = await client.get(url, params={"limit": limit}, headers=headers)
|
|
645
|
+
if resp.status_code >= 400:
|
|
646
|
+
return []
|
|
647
|
+
records = ((resp.json() or {}).get("data") or {}).get("records") or []
|
|
648
|
+
except Exception:
|
|
649
|
+
return []
|
|
650
|
+
|
|
651
|
+
by_key: dict[str, dict] = {}
|
|
652
|
+
for rec in records:
|
|
653
|
+
k = str(rec.get("jiraIssueKey") or rec.get("jiraKey") or "").strip().upper()
|
|
654
|
+
if k and k not in by_key:
|
|
655
|
+
by_key[k] = rec
|
|
656
|
+
return [by_key[k] for k in want if k in by_key]
|
|
657
|
+
|
|
624
658
|
async def approval_record(self, task_id: str) -> dict | None:
|
|
625
659
|
"""GET /api/v1/mgmt/approve/{task_id} → the full decision record (or None).
|
|
626
660
|
|
|
@@ -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.
|
|
@@ -1731,15 +2129,31 @@ async def handle_orchestrate_auto_local_run(
|
|
|
1731
2129
|
).model_dump())
|
|
1732
2130
|
return
|
|
1733
2131
|
|
|
1734
|
-
#
|
|
1735
|
-
|
|
1736
|
-
#
|
|
1737
|
-
|
|
1738
|
-
|
|
2132
|
+
# Targeted run: `only_keys` names specific Jira tickets to run (via
|
|
2133
|
+
# `agentos-auto ticket BBW-81 …`). Those run regardless of board status and
|
|
2134
|
+
# in the ORDER requested. Otherwise fetch the whole To Do queue (default).
|
|
2135
|
+
only_keys = [str(k).strip().upper() for k in (payload.get("only_keys") or []) if str(k).strip()]
|
|
2136
|
+
if only_keys:
|
|
2137
|
+
tasks = await client.list_tasks_by_keys(only_keys)
|
|
2138
|
+
found = {str(t.get("jiraIssueKey") or t.get("jiraKey") or "").strip().upper() for t in tasks}
|
|
2139
|
+
missing = [k for k in only_keys if k not in found]
|
|
2140
|
+
if missing:
|
|
2141
|
+
await send(Envelope(
|
|
2142
|
+
type="step_update", thread_id=thread_id,
|
|
2143
|
+
data={"current_step": f"⚠ not found (skipped): {', '.join(missing)}"},
|
|
2144
|
+
).model_dump())
|
|
2145
|
+
# Keep the requested order (list_tasks_by_keys already does), no re-sort.
|
|
2146
|
+
else:
|
|
2147
|
+
# RAW /tasks-ml records carry agentMode / jiraIssueKey / humanInstructions.
|
|
2148
|
+
tasks = await client.list_todo_tasks_raw(status="To Do")
|
|
2149
|
+
# Process oldest-created first (FIFO by Jira `createdAt`) so the ticket
|
|
2150
|
+
# raised earliest is started first. Unknown/invalid timestamps sort last.
|
|
2151
|
+
tasks = sorted(tasks, key=_task_created_key)
|
|
1739
2152
|
|
|
1740
2153
|
await send(Envelope(
|
|
1741
2154
|
type="pipeline_started", thread_id=thread_id,
|
|
1742
|
-
data={"mode": "orchestrate-auto-local", "task_count": len(tasks)
|
|
2155
|
+
data={"mode": "orchestrate-auto-local", "task_count": len(tasks),
|
|
2156
|
+
"only_keys": only_keys or None},
|
|
1743
2157
|
).model_dump())
|
|
1744
2158
|
|
|
1745
2159
|
if not tasks:
|
|
@@ -9,6 +9,8 @@ directory, and the result is reported back. The only required input is the
|
|
|
9
9
|
Commands:
|
|
10
10
|
agentos-auto run every To Do task locally, in the BACKGROUND
|
|
11
11
|
agentos-auto resume alias for run — re-fetches remaining To Do tasks
|
|
12
|
+
agentos-auto ticket K [K...] run ONLY the named Jira ticket(s), e.g. BBW-81 —
|
|
13
|
+
any status, in the order given (alias: tickets)
|
|
12
14
|
agentos-auto stop ask the running loop to pause after the current task
|
|
13
15
|
agentos-auto status show whether the background loop is running / stop pending
|
|
14
16
|
agentos-auto update upgrade the SDK to the latest version
|
|
@@ -224,6 +226,39 @@ def _parse_workspace_root(argv: list[str]) -> str | None:
|
|
|
224
226
|
return None
|
|
225
227
|
|
|
226
228
|
|
|
229
|
+
def _parse_ticket_keys(argv: list[str]) -> list[str]:
|
|
230
|
+
"""Jira keys passed to `agentos-auto ticket <KEY> [<KEY> ...]`.
|
|
231
|
+
|
|
232
|
+
Accepts space- and/or comma-separated keys (e.g. `BBW-81 BBW-80` or
|
|
233
|
+
`BBW-81,BBW-80`), skips the leading subcommand and any flags (and the value
|
|
234
|
+
of --workspace-root), upper-cases and de-dupes while preserving order.
|
|
235
|
+
"""
|
|
236
|
+
keys: list[str] = []
|
|
237
|
+
skip_next = False
|
|
238
|
+
for i, tok in enumerate(argv):
|
|
239
|
+
if i == 0:
|
|
240
|
+
continue # the subcommand itself (ticket/tickets)
|
|
241
|
+
if skip_next:
|
|
242
|
+
skip_next = False
|
|
243
|
+
continue
|
|
244
|
+
if tok.startswith("-"):
|
|
245
|
+
if tok == "--workspace-root":
|
|
246
|
+
skip_next = True # its value is a directory, not a ticket key
|
|
247
|
+
continue
|
|
248
|
+
for part in tok.split(","):
|
|
249
|
+
k = part.strip()
|
|
250
|
+
if k:
|
|
251
|
+
keys.append(k)
|
|
252
|
+
seen: set[str] = set()
|
|
253
|
+
out: list[str] = []
|
|
254
|
+
for k in keys:
|
|
255
|
+
ku = k.upper()
|
|
256
|
+
if ku not in seen:
|
|
257
|
+
seen.add(ku)
|
|
258
|
+
out.append(ku)
|
|
259
|
+
return out
|
|
260
|
+
|
|
261
|
+
|
|
227
262
|
_current_task: str | None = None
|
|
228
263
|
|
|
229
264
|
|
|
@@ -263,7 +298,7 @@ async def _send(env: dict) -> None:
|
|
|
263
298
|
print(f" [{etype}] {json.dumps(data)[:200]}")
|
|
264
299
|
|
|
265
300
|
|
|
266
|
-
def _run_loop(workspace_root: str | None) -> None:
|
|
301
|
+
def _run_loop(workspace_root: str | None, only_keys: list[str] | None = None) -> None:
|
|
267
302
|
from sdk import local_exec
|
|
268
303
|
from sdk.client import BackendClient, register_status_observer
|
|
269
304
|
from sdk.ipc import handlers
|
|
@@ -282,6 +317,9 @@ def _run_loop(workspace_root: str | None) -> None:
|
|
|
282
317
|
payload: dict = {"thread_id": "agentos-auto-cli"}
|
|
283
318
|
if workspace_root:
|
|
284
319
|
payload["workspace_root"] = workspace_root
|
|
320
|
+
if only_keys:
|
|
321
|
+
# Targeted run: only these Jira tickets, in this order, regardless of status.
|
|
322
|
+
payload["only_keys"] = only_keys
|
|
285
323
|
|
|
286
324
|
_clear_stop() # a fresh run/resume clears any stale stop flag
|
|
287
325
|
_clear_backlogged() # session-scoped: only track Backlog moves from THIS run
|
|
@@ -420,7 +458,18 @@ def main() -> None:
|
|
|
420
458
|
print("stop pending: yes" if _stop_requested() else "stop pending: no")
|
|
421
459
|
return
|
|
422
460
|
|
|
423
|
-
if cmd in ("run", "resume"):
|
|
461
|
+
if cmd in ("run", "resume", "ticket", "tickets"):
|
|
462
|
+
# Targeted run: `ticket`/`tickets` runs ONLY the named Jira keys (any
|
|
463
|
+
# status, in the order given); `run`/`resume` runs the whole To Do queue.
|
|
464
|
+
only_keys: list[str] | None = None
|
|
465
|
+
if cmd in ("ticket", "tickets"):
|
|
466
|
+
only_keys = _parse_ticket_keys(argv)
|
|
467
|
+
if not only_keys:
|
|
468
|
+
print("usage: agentos-auto ticket <JIRA-KEY> [<JIRA-KEY> ...]")
|
|
469
|
+
print(" e.g. agentos-auto ticket BBW-81")
|
|
470
|
+
print(" agentos-auto ticket BBW-81 BBW-80")
|
|
471
|
+
sys.exit(1)
|
|
472
|
+
|
|
424
473
|
# Self-update FIRST (before the already-running check) so the installed
|
|
425
474
|
# package is refreshed even when a daemon is already running — otherwise a
|
|
426
475
|
# long-lived old daemon would pin `stop`/`status` to a stale version. Runs
|
|
@@ -430,7 +479,7 @@ def main() -> None:
|
|
|
430
479
|
from sdk.updater import auto_update_and_reexec
|
|
431
480
|
auto_update_and_reexec("sdk.run_auto", sys.argv[1:])
|
|
432
481
|
|
|
433
|
-
# Refuse to start a second loop — one background daemon owns the
|
|
482
|
+
# Refuse to start a second loop — one background daemon owns the queue.
|
|
434
483
|
pid = _daemon_running()
|
|
435
484
|
if pid:
|
|
436
485
|
print(f"agentos-auto is already running in the background (pid {pid}).")
|
|
@@ -445,13 +494,14 @@ def main() -> None:
|
|
|
445
494
|
# attached (useful for debugging). On platforms without fork we stay attached.
|
|
446
495
|
foreground = "--foreground" in argv or "-f" in argv
|
|
447
496
|
if not foreground and hasattr(os, "fork"):
|
|
448
|
-
|
|
449
|
-
|
|
497
|
+
target = f" — tickets: {', '.join(only_keys)}" if only_keys else ""
|
|
498
|
+
print(f"▶ agentos-auto started in the background{target} — it keeps running "
|
|
499
|
+
"after you close this terminal.")
|
|
450
500
|
print(f" logs: {_LOG_FILE}")
|
|
451
501
|
print(" status: agentos-auto status")
|
|
452
502
|
print(" stop: agentos-auto stop")
|
|
453
503
|
if not foreground and _daemonize():
|
|
454
|
-
_run_loop(workspace_root) # only the detached daemon child reaches here
|
|
504
|
+
_run_loop(workspace_root, only_keys) # only the detached daemon child reaches here
|
|
455
505
|
return
|
|
456
506
|
if not foreground:
|
|
457
507
|
print("note: background mode unavailable on this platform — running in the "
|
|
@@ -460,10 +510,10 @@ def main() -> None:
|
|
|
460
510
|
# Foreground run still records a PID so stop/status can find it.
|
|
461
511
|
_AGENTOS_DIR.mkdir(parents=True, exist_ok=True)
|
|
462
512
|
_PID_FILE.write_text(str(os.getpid()))
|
|
463
|
-
_run_loop(workspace_root)
|
|
513
|
+
_run_loop(workspace_root, only_keys)
|
|
464
514
|
return
|
|
465
515
|
|
|
466
|
-
print(f"unknown command '{cmd}'. use: run | resume | stop | status | update")
|
|
516
|
+
print(f"unknown command '{cmd}'. use: run | resume | ticket | stop | status | update")
|
|
467
517
|
sys.exit(1)
|
|
468
518
|
|
|
469
519
|
|
|
File without changes
|
|
File without changes
|
{devops_bot_sdk-1.4.33 → devops_bot_sdk-1.4.37}/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
|