devops-bot-sdk 1.4.60__tar.gz → 1.4.65__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. {devops_bot_sdk-1.4.60 → devops_bot_sdk-1.4.65}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.4.60 → devops_bot_sdk-1.4.65}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.4.60 → devops_bot_sdk-1.4.65}/pyproject.toml +1 -1
  4. {devops_bot_sdk-1.4.60 → devops_bot_sdk-1.4.65}/sdk/__init__.py +2 -2
  5. {devops_bot_sdk-1.4.60 → devops_bot_sdk-1.4.65}/sdk/client.py +3 -3
  6. {devops_bot_sdk-1.4.60 → devops_bot_sdk-1.4.65}/sdk/ipc/handlers.py +195 -63
  7. {devops_bot_sdk-1.4.60 → devops_bot_sdk-1.4.65}/README.md +0 -0
  8. {devops_bot_sdk-1.4.60 → devops_bot_sdk-1.4.65}/devops_bot_sdk.egg-info/SOURCES.txt +0 -0
  9. {devops_bot_sdk-1.4.60 → devops_bot_sdk-1.4.65}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  10. {devops_bot_sdk-1.4.60 → devops_bot_sdk-1.4.65}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
  11. {devops_bot_sdk-1.4.60 → devops_bot_sdk-1.4.65}/devops_bot_sdk.egg-info/requires.txt +0 -0
  12. {devops_bot_sdk-1.4.60 → devops_bot_sdk-1.4.65}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  13. {devops_bot_sdk-1.4.60 → devops_bot_sdk-1.4.65}/sdk/collectors/__init__.py +0 -0
  14. {devops_bot_sdk-1.4.60 → devops_bot_sdk-1.4.65}/sdk/collectors/files.py +0 -0
  15. {devops_bot_sdk-1.4.60 → devops_bot_sdk-1.4.65}/sdk/collectors/process.py +0 -0
  16. {devops_bot_sdk-1.4.60 → devops_bot_sdk-1.4.65}/sdk/collectors/screenshot.py +0 -0
  17. {devops_bot_sdk-1.4.60 → devops_bot_sdk-1.4.65}/sdk/config.py +0 -0
  18. {devops_bot_sdk-1.4.60 → devops_bot_sdk-1.4.65}/sdk/crucial.py +0 -0
  19. {devops_bot_sdk-1.4.60 → devops_bot_sdk-1.4.65}/sdk/exceptions.py +0 -0
  20. {devops_bot_sdk-1.4.60 → devops_bot_sdk-1.4.65}/sdk/git_ops.py +0 -0
  21. {devops_bot_sdk-1.4.60 → devops_bot_sdk-1.4.65}/sdk/graphify.py +0 -0
  22. {devops_bot_sdk-1.4.60 → devops_bot_sdk-1.4.65}/sdk/hooks/__init__.py +0 -0
  23. {devops_bot_sdk-1.4.60 → devops_bot_sdk-1.4.65}/sdk/hooks/crucial_guard.py +0 -0
  24. {devops_bot_sdk-1.4.60 → devops_bot_sdk-1.4.65}/sdk/ipc/__init__.py +0 -0
  25. {devops_bot_sdk-1.4.60 → devops_bot_sdk-1.4.65}/sdk/ipc/electron_bridge.py +0 -0
  26. {devops_bot_sdk-1.4.60 → devops_bot_sdk-1.4.65}/sdk/local_exec.py +0 -0
  27. {devops_bot_sdk-1.4.60 → devops_bot_sdk-1.4.65}/sdk/models/__init__.py +0 -0
  28. {devops_bot_sdk-1.4.60 → devops_bot_sdk-1.4.65}/sdk/models/envelope.py +0 -0
  29. {devops_bot_sdk-1.4.60 → devops_bot_sdk-1.4.65}/sdk/models/requests.py +0 -0
  30. {devops_bot_sdk-1.4.60 → devops_bot_sdk-1.4.65}/sdk/models/responses.py +0 -0
  31. {devops_bot_sdk-1.4.60 → devops_bot_sdk-1.4.65}/sdk/models/snapshots.py +0 -0
  32. {devops_bot_sdk-1.4.60 → devops_bot_sdk-1.4.65}/sdk/py.typed +0 -0
  33. {devops_bot_sdk-1.4.60 → devops_bot_sdk-1.4.65}/sdk/run_auto.py +0 -0
  34. {devops_bot_sdk-1.4.60 → devops_bot_sdk-1.4.65}/sdk/sse.py +0 -0
  35. {devops_bot_sdk-1.4.60 → devops_bot_sdk-1.4.65}/sdk/test.py +0 -0
  36. {devops_bot_sdk-1.4.60 → devops_bot_sdk-1.4.65}/sdk/test_pipeline.py +0 -0
  37. {devops_bot_sdk-1.4.60 → devops_bot_sdk-1.4.65}/sdk/updater.py +0 -0
  38. {devops_bot_sdk-1.4.60 → devops_bot_sdk-1.4.65}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.4.60
3
+ Version: 1.4.65
4
4
  Summary: DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app
5
5
  Author: noumanaziz2128
6
6
  License-Expression: LicenseRef-Proprietary
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.4.60
3
+ Version: 1.4.65
4
4
  Summary: DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app
5
5
  Author: noumanaziz2128
6
6
  License-Expression: LicenseRef-Proprietary
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devops-bot-sdk"
7
- version = "1.4.60"
7
+ version = "1.4.65"
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.60
3
+ Version: 1.4.65
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.60"
33
+ __version__ = "1.4.65"
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.60"
61
+ SDK_VERSION = "1.4.65"
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
@@ -821,7 +821,7 @@ class BackendClient:
821
821
  # field like `modelUsed` can repeat.
822
822
  files = [(k, (None, str(v))) for k, v in fields.items() if v is not None]
823
823
  for m in (model_used or []):
824
- files.append(("modelUsed", (None, str(m))))
824
+ files.append(("modelUsed[]", (None, str(m)))) # [] → backend parses as array
825
825
  try:
826
826
  async with httpx.AsyncClient(timeout=30.0) as client:
827
827
  resp = await client.put(url, files=files, headers=headers)
@@ -850,7 +850,7 @@ class BackendClient:
850
850
  headers.update({"accept": "*/*", "developer-name": "null", "product": ""})
851
851
  files = [(k, (None, str(v))) for k, v in fields.items() if v is not None]
852
852
  for m in (model_used or []):
853
- files.append(("modelUsed", (None, str(m))))
853
+ files.append(("modelUsed[]", (None, str(m)))) # [] → backend parses as array
854
854
  try:
855
855
  async with httpx.AsyncClient(timeout=30.0) as client:
856
856
  resp = await client.post(url, files=files, headers=headers)
@@ -371,6 +371,50 @@ def _parse_scope(recon_text: str | None) -> str:
371
371
  return "in"
372
372
 
373
373
 
374
+ # Heartbeat cadence for long, quiet local runs (PRD generation, validation) so the
375
+ # log shows liveness even when the agent is generating text with no tool calls.
376
+ _HEARTBEAT_S = float(os.getenv("AGENTOS_HEARTBEAT_S", "") or 20.0)
377
+
378
+
379
+ async def _run_with_heartbeat(coro, *, send: Callable, thread_id: str, label: str,
380
+ name: str | None = None, interval: float = _HEARTBEAT_S):
381
+ """Await `coro` while emitting a periodic 'still working (Ns)' step_update, so a
382
+ long-but-quiet local run (e.g. PRD generation) never looks frozen in the log."""
383
+ import time as _t
384
+
385
+ prefix = f"[{name}] " if name else ""
386
+ stop = asyncio.Event()
387
+ started = _t.monotonic()
388
+
389
+ async def _beat() -> None:
390
+ try:
391
+ while not stop.is_set():
392
+ try:
393
+ await asyncio.wait_for(stop.wait(), timeout=interval)
394
+ return
395
+ except asyncio.TimeoutError:
396
+ pass
397
+ await send(Envelope(
398
+ type="step_update", thread_id=thread_id,
399
+ data={"status": "running",
400
+ "current_step": f"{prefix}{label} … still working "
401
+ f"({int(_t.monotonic() - started)}s)"},
402
+ ).model_dump())
403
+ except asyncio.CancelledError:
404
+ return
405
+
406
+ beat = asyncio.create_task(_beat())
407
+ try:
408
+ return await coro
409
+ finally:
410
+ stop.set()
411
+ beat.cancel()
412
+ try:
413
+ await beat
414
+ except Exception: # noqa: BLE001
415
+ pass
416
+
417
+
374
418
  # ── Epic decomposition ────────────────────────────────────────────────────
375
419
  # An EPIC is not coded directly. It is analysed (grounded in the codebase when a
376
420
  # project path exists) into a detailed user-story description + an implementation
@@ -624,9 +668,14 @@ async def _validate_ticket_or_reject(
624
668
  we never block a ticket on our own infrastructure error."""
625
669
  from sdk import local_exec
626
670
 
671
+ from pathlib import Path
672
+
627
673
  tid = str(task.get("id", ""))
628
674
  jira_key = task.get("jiraIssueKey") or task.get("jiraKey", "") or kind.upper()
629
- project_path = _resolve_local_project_path(task, payload)
675
+ # Validation is a pure text judgment — run it in a guaranteed-writable scratch
676
+ # dir, never the resolved project path (which may not exist yet or be unwritable,
677
+ # e.g. a code-reference like /components). This decouples the gate from the repo.
678
+ scratch = str(Path.home() / ".agentos" / "validate")
630
679
 
631
680
  await send(Envelope(
632
681
  type="step_update", thread_id=thread_id,
@@ -634,11 +683,14 @@ async def _validate_ticket_or_reject(
634
683
  "current_step": f"{kind.title()} {jira_key}: validating ticket against the standard"},
635
684
  ).model_dump())
636
685
 
637
- res = await local_exec.run_claude_local(
638
- _build_validation_prompt(kind, jira_key, task.get("summary", "") or "",
639
- task.get("description"), task.get("humanInstructions")),
640
- project_path, allowed_tools=_RECON_ALLOWED_TOOLS, # read-only judgment
641
- github_token=await client.github_token(),
686
+ res = await _run_with_heartbeat(
687
+ local_exec.run_claude_local(
688
+ _build_validation_prompt(kind, jira_key, task.get("summary", "") or "",
689
+ task.get("description"), task.get("humanInstructions")),
690
+ scratch, allowed_tools=_RECON_ALLOWED_TOOLS, # read-only judgment, scratch dir
691
+ github_token=await client.github_token(),
692
+ ),
693
+ send=send, thread_id=thread_id, label=f"{kind.title()} {jira_key}: validating",
642
694
  )
643
695
  if not res.get("ok"):
644
696
  await client.log_activity(
@@ -854,10 +906,14 @@ def _build_prd_prompt(jira_key: str, summary: str, user_story: str | None,
854
906
 
855
907
  async def _handle_story(
856
908
  client: BackendClient, task: dict, payload: dict, send: Callable, thread_id: str,
857
- ) -> None:
909
+ ) -> str | None:
858
910
  """Run a Story: optionally generate a PRD (when instructed) into the description,
859
911
  then convert the ticket to a Task (To Do) for the coding run.
860
912
 
913
+ Returns the effective (PRD-enriched) description on success, or None on failure.
914
+ The caller uses the returned description to code the now-Task in the SAME run
915
+ (default `agentos-auto`); the `story` command discards it (convert-only).
916
+
861
917
  Best-effort. If a PRD was explicitly instructed but generation fails, the ticket
862
918
  goes to Backlog for retry (so the PRD requirement isn't silently dropped); a
863
919
  conversion failure likewise backlogs it.
@@ -955,9 +1011,11 @@ async def _handle_story(
955
1011
  type="step_update", thread_id=thread_id,
956
1012
  data={"jira_task_id": tid,
957
1013
  "current_step": f"Story {jira_key} → Task (To Do)"
958
- + (" + PRD in description" if prd_in_desc else "")
959
- + " — run again to implement"},
1014
+ + (" + PRD in description" if prd_in_desc else "")},
960
1015
  ).model_dump())
1016
+ # The effective description (PRD-enriched when generated/present) so the caller
1017
+ # can code the now-Task in the same run.
1018
+ return new_description or user_story
961
1019
 
962
1020
 
963
1021
  async def _run_local_pipeline(
@@ -1231,13 +1289,16 @@ async def _code_one_repo(
1231
1289
  data={"status": "running",
1232
1290
  "current_step": f"[{name}] Recon — exploring the repo to ground the plan"},
1233
1291
  ).model_dump())
1234
- recon = await local_exec.run_claude_local(
1235
- _build_recon_prompt(
1236
- name, pending["user_input"], pending.get("structured_plan"),
1237
- pending.get("human_instructions"),
1292
+ recon = await _run_with_heartbeat(
1293
+ local_exec.run_claude_local(
1294
+ _build_recon_prompt(
1295
+ name, pending["user_input"], pending.get("structured_plan"),
1296
+ pending.get("human_instructions"),
1297
+ ),
1298
+ repo_path, on_event=_on_event, github_token=gh_token,
1299
+ allowed_tools=_RECON_ALLOWED_TOOLS, # read-only: no Write/Edit/Bash
1238
1300
  ),
1239
- repo_path, on_event=_on_event, github_token=gh_token,
1240
- allowed_tools=_RECON_ALLOWED_TOOLS, # read-only: no Write/Edit/Bash
1301
+ send=send, thread_id=thread_id, label="Exploring the repo (recon)", name=name,
1241
1302
  )
1242
1303
  recon_session = recon.get("session_id")
1243
1304
  # A usage-limit hit during recon must be surfaced so the loop waits/retries
@@ -1298,35 +1359,59 @@ async def _code_one_repo(
1298
1359
  except Exception: # noqa: BLE001
1299
1360
  branch_info = None
1300
1361
 
1301
- # 3a. Technical PRD for a CODEBASE task that doesn't have one yet. A Task created
1302
- # directly (not via a Story) may carry only a user-story/feature description
1303
- # for codebase work we first turn the recon understanding into a technical PRD
1304
- # and build to it, exactly like a Story-derived Task. Resumes the recon session
1305
- # (already understands the codebase) so it's cheap; persists the PRD to the
1306
- # ticket and to pending (shared → other repos reuse it and skip regeneration).
1307
- # Skipped when a PRD is already present, on a resume, or for non-git targets.
1308
- if (is_git and recon_session and not resume_session_id
1309
- and _PRD_MARKER not in (pending.get("user_input") or "")):
1310
- await send(Envelope(
1311
- type="step_update", thread_id=thread_id,
1312
- data={"status": "running",
1313
- "current_step": f"[{name}] Codebase task — writing a technical PRD from the codebase"},
1314
- ).model_dump())
1315
- prd_res = await local_exec.run_claude_local(
1316
- "Using your exploration above, write a TECHNICAL PRD for this task that fully "
1317
- "specifies the mechanism (how it works end-to-end) against the REAL code you "
1318
- "found. Output ONLY the PRD as Markdown, following this format exactly:\n\n"
1319
- + _PRD_FORMAT,
1320
- repo_path, allowed_tools=_RECON_ALLOWED_TOOLS, # read-only
1321
- github_token=gh_token, resume_session_id=recon_session,
1362
+ # 3a. Technical PRD grounding (codebase tasks). The BACKEND converts a Story to a
1363
+ # Task and adds a FIRST-PASS PRD to the description; here we GROUND that PRD
1364
+ # against the REAL codebase (correcting file paths, contracts, data models to
1365
+ # what actually exists) before building to it. If no PRD is present at all
1366
+ # (edge case), we generate one. Reuses the recon session (already understands
1367
+ # the codebase), persists the result to the ticket + pending (shared → other
1368
+ # repos reuse it), and runs once per ticket. Skipped on a resume / non-git.
1369
+ if is_git and recon_session and not resume_session_id and not pending.get("_prd_grounded"):
1370
+ current = pending.get("user_input") or ""
1371
+ has_prd = _PRD_MARKER in current
1372
+ if has_prd:
1373
+ before, _, existing_prd = current.partition(_PRD_MARKER)
1374
+ await send(Envelope(
1375
+ type="step_update", thread_id=thread_id,
1376
+ data={"status": "running",
1377
+ "current_step": f"[{name}] Grounding the PRD against the current codebase"},
1378
+ ).model_dump())
1379
+ instruction = (
1380
+ "The task description contains a PRD (below), drafted by the planner and "
1381
+ "NOT necessarily written against THIS exact codebase. Using your "
1382
+ "exploration above, REFINE it so every file path, module, API contract, "
1383
+ "data model and integration matches what ACTUALLY exists in this repo. "
1384
+ "Keep the same PRD sections/format; correct anything inaccurate; do NOT "
1385
+ "invent. Output ONLY the refined PRD as Markdown.\n\n"
1386
+ "=== Current PRD ===\n" + existing_prd.strip()
1387
+ )
1388
+ else:
1389
+ before = current.rstrip() + "\n\n---\n\n"
1390
+ await send(Envelope(
1391
+ type="step_update", thread_id=thread_id,
1392
+ data={"status": "running",
1393
+ "current_step": f"[{name}] Codebase task — writing a technical PRD from the codebase"},
1394
+ ).model_dump())
1395
+ instruction = (
1396
+ "Using your exploration above, write a TECHNICAL PRD for this task that "
1397
+ "fully specifies the mechanism (how it works end-to-end) against the REAL "
1398
+ "code you found. Output ONLY the PRD as Markdown, following this format "
1399
+ "exactly:\n\n" + _PRD_FORMAT
1400
+ )
1401
+ prd_res = await _run_with_heartbeat(
1402
+ local_exec.run_claude_local(
1403
+ instruction, repo_path, on_event=_on_event, # stream tool activity
1404
+ allowed_tools=_RECON_ALLOWED_TOOLS, # read-only
1405
+ github_token=gh_token, resume_session_id=recon_session,
1406
+ ),
1407
+ send=send, thread_id=thread_id, label="Working on the PRD", name=name,
1322
1408
  )
1323
1409
  recon_session = prd_res.get("session_id") or recon_session
1324
1410
  prd = (prd_res.get("result") or "").strip() if prd_res.get("ok") else ""
1325
1411
  if prd:
1326
- enriched = (
1327
- f"{pending.get('user_input') or ''}\n\n---\n\n{_PRD_MARKER}\n\n{prd}"
1328
- )[:20000]
1329
- pending["user_input"] = enriched # shared across repos → reused, not regenerated
1412
+ enriched = f"{before}{_PRD_MARKER}\n\n{prd}"[:20000]
1413
+ pending["user_input"] = enriched # shared across repos → reused
1414
+ pending["_prd_grounded"] = True # ground once per ticket
1330
1415
  try:
1331
1416
  await client.update_task_ml(task_id, {"description": enriched}, model_used=_EPIC_MODELS)
1332
1417
  except Exception: # noqa: BLE001 — persisting the PRD is best-effort
@@ -2562,6 +2647,16 @@ def _extract_repo_targets(text: str | None) -> list[dict]:
2562
2647
  for p in _extract_all_paths(text):
2563
2648
  name = Path(p).name
2564
2649
  url = by_name.get(name.lower())
2650
+ # Ignore bare code-reference paths mentioned in the instructions (e.g.
2651
+ # "use shared components in `/components`") that are neither a real repo
2652
+ # (paired with a GitHub URL) nor an existing local directory. Without this
2653
+ # a path like /components gets picked as the work dir → Permission denied.
2654
+ if not url:
2655
+ try:
2656
+ if not Path(p).expanduser().is_dir():
2657
+ continue
2658
+ except OSError:
2659
+ continue
2565
2660
  targets.append({
2566
2661
  "path": p,
2567
2662
  "url": url,
@@ -2600,13 +2695,25 @@ def _resolve_local_project_path(task: dict, payload: dict) -> str:
2600
2695
  if overrides.get(tid):
2601
2696
  return str(Path(overrides[tid]).expanduser())
2602
2697
 
2603
- # PRIMARY — honour the location the human wrote in the Jira task.
2698
+ # PRIMARY — honour the location the human wrote in the Jira task. Prefer a REAL
2699
+ # repo (a path paired with a GitHub URL), then any surviving repo target
2700
+ # (existing local dir), then a bare path that actually exists — never a
2701
+ # code-reference path like `/components` that doesn't exist on disk.
2604
2702
  for field in ("humanInstructions", "description"):
2605
- found = _extract_path_from_text(task.get(field))
2606
- if found:
2703
+ text = task.get(field)
2704
+ if not text:
2705
+ continue
2706
+ targets = _extract_repo_targets(text) # spurious/non-existent paths filtered out
2707
+ with_url = next((t["path"] for t in targets if t.get("url")), None)
2708
+ if with_url:
2709
+ return with_url
2710
+ if targets:
2711
+ return targets[0]["path"]
2712
+ found = _extract_path_from_text(text)
2713
+ if found and Path(found).expanduser().exists():
2607
2714
  return found
2608
2715
 
2609
- # FALLBACK — only when no location was instructed anywhere.
2716
+ # FALLBACK — no usable location instructed anywhere.
2610
2717
  root = payload.get("workspace_root") or str(Path.home() / "AgentOS")
2611
2718
  key = (
2612
2719
  task.get("projectKey")
@@ -2846,38 +2953,63 @@ async def handle_orchestrate_auto_local_run(
2846
2953
  # never marked In Progress/Done — only ever converted
2847
2954
  # Task → implemented by the normal pipeline below (In Progress → Done)
2848
2955
  # Each is isolated: a failure must not abort the remaining queue.
2849
- if ttype in ("epic", "story"):
2850
- handler = _handle_epic if ttype == "epic" else _handle_story
2851
- code = "EPIC_DECOMPOSE_FAILED" if ttype == "epic" else "STORY_HANDLE_FAILED"
2852
- # Validation gate: Epics are always validated; a Story only when it
2853
- # is USER-driven (a SYSTEM/bot-created Story is trusted). A rejected
2854
- # ticket is commented + moved to Backlog inside the gate — skip it.
2855
- driven_by = str(task.get("drivenBy") or "").strip().upper()
2856
- needs_validation = (ttype == "epic") or (ttype == "story" and driven_by != "SYSTEM")
2956
+ # Validation gate: Epics always; a Story only when USER-driven (a
2957
+ # SYSTEM/bot-created Story is trusted). A rejected ticket is commented +
2958
+ # moved to Backlog inside the gate skip it here.
2959
+ driven_by = str(task.get("drivenBy") or "").strip().upper()
2960
+
2961
+ if ttype == "epic":
2857
2962
  try:
2858
- if needs_validation and not await _validate_ticket_or_reject(
2859
- client, task, ttype, payload, send, thread_id
2860
- ):
2963
+ if not await _validate_ticket_or_reject(client, task, "epic", payload, send, thread_id):
2861
2964
  processed += 1
2862
2965
  continue
2863
- await handler(client, task, payload, send, thread_id)
2966
+ await _handle_epic(client, task, payload, send, thread_id)
2864
2967
  except Exception as exc: # noqa: BLE001
2865
- logger.warning("auto_local.%s_failed task=%s err=%s", ttype, tid, exc)
2968
+ logger.warning("auto_local.epic_failed task=%s err=%s", tid, exc)
2866
2969
  try:
2867
- await client.log_activity(f"Task {tid}: {ttype} handling errored — {str(exc)[:200]}")
2868
- # Backlog only Epics for retry; a Story is left To Do (never
2869
- # marked In Progress/Done/Backlog) so developers keep it.
2870
- if ttype == "epic":
2871
- await client.move_to_backlog(tid)
2970
+ await client.log_activity(f"Task {tid}: epic handling errored — {str(exc)[:200]}")
2971
+ await client.move_to_backlog(tid)
2872
2972
  except Exception: # noqa: BLE001
2873
2973
  pass
2874
2974
  await send(Envelope(
2875
2975
  type=Envelope.TYPE_ERROR, thread_id=thread_id,
2876
- data={"code": code, "task_id": tid, "message": str(exc)[:200]},
2976
+ data={"code": "EPIC_DECOMPOSE_FAILED", "task_id": tid, "message": str(exc)[:200]},
2877
2977
  ).model_dump())
2878
2978
  processed += 1
2879
2979
  continue
2880
2980
 
2981
+ if ttype == "story":
2982
+ try:
2983
+ if driven_by != "SYSTEM" and not await _validate_ticket_or_reject(
2984
+ client, task, "story", payload, send, thread_id
2985
+ ):
2986
+ processed += 1
2987
+ continue
2988
+ new_desc = await _handle_story(client, task, payload, send, thread_id)
2989
+ except Exception as exc: # noqa: BLE001
2990
+ logger.warning("auto_local.story_failed task=%s err=%s", tid, exc)
2991
+ await client.log_activity(f"Task {tid}: story handling errored — {str(exc)[:200]}")
2992
+ await send(Envelope(
2993
+ type=Envelope.TYPE_ERROR, thread_id=thread_id,
2994
+ data={"code": "STORY_HANDLE_FAILED", "task_id": tid, "message": str(exc)[:200]},
2995
+ ).model_dump())
2996
+ processed += 1
2997
+ continue
2998
+ # `agentos-auto story` (stories_only) = convert-only, stop here.
2999
+ # Conversion failure (new_desc None) also stops. Otherwise fall
3000
+ # through and code the now-Task IN THE SAME RUN (default agentos-auto).
3001
+ if stories_only or not new_desc:
3002
+ processed += 1
3003
+ continue
3004
+ task = {**task, "type": "Task", "description": new_desc}
3005
+ ttype = "task"
3006
+ await send(Envelope(
3007
+ type="step_update", thread_id=thread_id,
3008
+ data={"jira_task_id": tid,
3009
+ "current_step": f"{jira_key}: implementing the converted Task now (In Progress → Done)"},
3010
+ ).model_dump())
3011
+ # ↓ falls through to the normal Task pipeline below
3012
+
2881
3013
  task_payload = {
2882
3014
  "thread_id": thread_id,
2883
3015
  "jira_task_id": tid,