devops-bot-sdk 1.4.25__tar.gz → 1.4.27__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.25 → devops_bot_sdk-1.4.27}/PKG-INFO +1 -1
- {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.27}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
- {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.27}/pyproject.toml +1 -1
- {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.27}/sdk/__init__.py +2 -2
- {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.27}/sdk/client.py +1 -1
- {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.27}/sdk/ipc/handlers.py +184 -109
- devops_bot_sdk-1.4.27/sdk/run_auto.py +293 -0
- devops_bot_sdk-1.4.25/sdk/run_auto.py +0 -155
- {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.27}/README.md +0 -0
- {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.27}/devops_bot_sdk.egg-info/SOURCES.txt +0 -0
- {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.27}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
- {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.27}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
- {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.27}/devops_bot_sdk.egg-info/requires.txt +0 -0
- {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.27}/devops_bot_sdk.egg-info/top_level.txt +0 -0
- {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.27}/sdk/collectors/__init__.py +0 -0
- {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.27}/sdk/collectors/files.py +0 -0
- {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.27}/sdk/collectors/process.py +0 -0
- {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.27}/sdk/collectors/screenshot.py +0 -0
- {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.27}/sdk/config.py +0 -0
- {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.27}/sdk/crucial.py +0 -0
- {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.27}/sdk/exceptions.py +0 -0
- {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.27}/sdk/git_ops.py +0 -0
- {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.27}/sdk/graphify.py +0 -0
- {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.27}/sdk/hooks/__init__.py +0 -0
- {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.27}/sdk/hooks/crucial_guard.py +0 -0
- {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.27}/sdk/ipc/__init__.py +0 -0
- {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.27}/sdk/ipc/electron_bridge.py +0 -0
- {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.27}/sdk/local_exec.py +0 -0
- {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.27}/sdk/models/__init__.py +0 -0
- {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.27}/sdk/models/envelope.py +0 -0
- {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.27}/sdk/models/requests.py +0 -0
- {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.27}/sdk/models/responses.py +0 -0
- {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.27}/sdk/models/snapshots.py +0 -0
- {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.27}/sdk/py.typed +0 -0
- {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.27}/sdk/sse.py +0 -0
- {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.27}/sdk/test.py +0 -0
- {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.27}/sdk/test_pipeline.py +0 -0
- {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.27}/sdk/updater.py +0 -0
- {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.27}/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.27"
|
|
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.27
|
|
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.27"
|
|
34
34
|
__author__ = "AgentOS"
|
|
35
35
|
|
|
36
36
|
from sdk.client import BackendClient
|
|
@@ -36,7 +36,7 @@ from sdk.sse import _check_status, stream_with_reconnect
|
|
|
36
36
|
|
|
37
37
|
logger = logging.getLogger(__name__)
|
|
38
38
|
|
|
39
|
-
SDK_VERSION = "1.4.
|
|
39
|
+
SDK_VERSION = "1.4.27"
|
|
40
40
|
_POLL_INTERVAL = 3.0
|
|
41
41
|
_POLL_TIMEOUT = 600.0
|
|
42
42
|
_ORCHESTRATE_TIMEOUT = 2700.0 # 45 min — covers approval wait + VPS execution time
|
|
@@ -115,7 +115,11 @@ async def handle_orchestrator_run(
|
|
|
115
115
|
_PENDING_LOCAL: dict[str, dict] = {}
|
|
116
116
|
|
|
117
117
|
_POLL_INTERVAL_S = 1.5
|
|
118
|
-
|
|
118
|
+
# How long to wait for backend planning to reach the approval gate before giving
|
|
119
|
+
# up. If this fires the ticket never reached the gate: no WhatsApp is composed and
|
|
120
|
+
# (previously) the ticket was orphaned In Progress. Heavy planning under load can
|
|
121
|
+
# exceed the old fixed 180s, so allow ops to raise it without a code change.
|
|
122
|
+
_PLAN_DEADLINE_S = float(os.getenv("AGENTOS_PLAN_DEADLINE_S", "") or 180.0)
|
|
119
123
|
|
|
120
124
|
|
|
121
125
|
# Raw task-description budget (chars). The structured plan already distills the
|
|
@@ -354,6 +358,7 @@ async def _run_local_pipeline(
|
|
|
354
358
|
"jira_key": payload.get("jira_key"),
|
|
355
359
|
"summary": payload.get("summary"),
|
|
356
360
|
"agent_mode": bool(payload.get("agent_mode", True)),
|
|
361
|
+
"_resume_sessions": payload.get("_resume_sessions") or {},
|
|
357
362
|
}
|
|
358
363
|
await send(Envelope(
|
|
359
364
|
type=Envelope.TYPE_AWAITING_APPROVAL, thread_id=thread_id,
|
|
@@ -428,6 +433,7 @@ async def _code_one_repo(
|
|
|
428
433
|
thread_id: str,
|
|
429
434
|
task_id: str,
|
|
430
435
|
base: str | None = None,
|
|
436
|
+
resume_session_id: str | None = None,
|
|
431
437
|
) -> dict:
|
|
432
438
|
"""Clone-if-needed → branch → run claude → open a PR, for ONE repo.
|
|
433
439
|
|
|
@@ -514,10 +520,12 @@ async def _code_one_repo(
|
|
|
514
520
|
agent_mode=agent_mode, git_managed=bool(branch_info),
|
|
515
521
|
)
|
|
516
522
|
|
|
517
|
-
# 3. initial claude run
|
|
523
|
+
# 3. initial claude run (resume_session_id is set on rate-limit retries so
|
|
524
|
+
# the model picks up exactly where it was interrupted, skipping re-work).
|
|
518
525
|
result = await local_exec.run_claude_local(
|
|
519
526
|
prompt, repo_path, on_event=_on_event,
|
|
520
527
|
github_token=gh_token, approval_dir=approval_dir,
|
|
528
|
+
resume_session_id=resume_session_id or None,
|
|
521
529
|
)
|
|
522
530
|
|
|
523
531
|
# 4. context-window recovery
|
|
@@ -714,6 +722,9 @@ async def handle_pipeline_approve(
|
|
|
714
722
|
# The prompt is built per-repo inside _code_one_repo so its framing matches
|
|
715
723
|
# whether that target is a git repo (branch + PR) or a plain local folder.
|
|
716
724
|
agent_mode = bool(pending.get("agent_mode", True))
|
|
725
|
+
# Per-repo claude session_ids saved from a previous rate-limit hit — lets
|
|
726
|
+
# the model resume its interrupted session rather than starting from scratch.
|
|
727
|
+
resume_sessions: dict[str, str] = pending.get("_resume_sessions") or {}
|
|
717
728
|
|
|
718
729
|
# Fetch the user's GitHub token from the backend so the local agent can
|
|
719
730
|
# clone/push/open PRs (best-effort — None if no GitHub account connected).
|
|
@@ -750,6 +761,7 @@ async def handle_pipeline_approve(
|
|
|
750
761
|
pending=pending, gh_token=gh_token,
|
|
751
762
|
agent_mode=agent_mode, approval_dir=approval_dir,
|
|
752
763
|
send=send, thread_id=thread_id, task_id=task_id,
|
|
764
|
+
resume_session_id=resume_sessions.get(tgt["path"]) or None,
|
|
753
765
|
))
|
|
754
766
|
except Exception as exc: # noqa: BLE001
|
|
755
767
|
logger.warning("local_run.repo_failed", error=str(exc))
|
|
@@ -784,28 +796,37 @@ async def handle_pipeline_approve(
|
|
|
784
796
|
if pr_urls:
|
|
785
797
|
agg_result += "\n\nPRs:\n" + "\n".join(pr_urls)
|
|
786
798
|
|
|
787
|
-
# ── Claude usage-limit hit: DON'T mark Done
|
|
788
|
-
# and signal the auto-loop to
|
|
799
|
+
# ── Claude usage-limit hit: DON'T mark Done, DON'T move to Backlog.
|
|
800
|
+
# Keep the ticket In Progress and signal the auto-loop to wait for reset.
|
|
801
|
+
# Moving to Backlog here would cause the approval gate to re-run on retry
|
|
802
|
+
# (15-min timeout → Backlog again → break → next ticket starts). ──
|
|
789
803
|
if any(r["result"].get("limit_exceeded") for r in runs):
|
|
790
804
|
reset_epoch = max((r["result"].get("limit_reset_epoch") or 0) for r in runs) or None
|
|
791
|
-
moved = await client.move_to_backlog(task_id)
|
|
792
805
|
await send(Envelope(
|
|
793
806
|
type="step_update", thread_id=thread_id,
|
|
794
807
|
data={"task_id": task_id,
|
|
795
|
-
"current_step":
|
|
796
|
-
|
|
797
|
-
+ "; will retry after the limit resets")},
|
|
808
|
+
"current_step": "Claude usage limit reached — ticket stays In Progress; "
|
|
809
|
+
"will retry after the limit resets"},
|
|
798
810
|
).model_dump())
|
|
799
811
|
try:
|
|
800
812
|
await client.notify_whatsapp(
|
|
801
813
|
task_id,
|
|
802
|
-
f"⚠ Task {task_id}: Claude usage limit reached —
|
|
814
|
+
f"⚠ Task {task_id}: Claude usage limit reached — staying In Progress; "
|
|
803
815
|
"will auto-retry after the window resets.",
|
|
804
816
|
summary="usage limit",
|
|
805
817
|
)
|
|
806
818
|
except Exception: # noqa: BLE001
|
|
807
819
|
pass
|
|
808
|
-
|
|
820
|
+
repo_resume_sessions = {
|
|
821
|
+
r["repo"]: r["result"]["session_id"]
|
|
822
|
+
for r in runs if r["result"].get("session_id")
|
|
823
|
+
}
|
|
824
|
+
return {
|
|
825
|
+
"status": "limit_exceeded",
|
|
826
|
+
"reset_epoch": reset_epoch,
|
|
827
|
+
"task_id": task_id,
|
|
828
|
+
"repo_resume_sessions": repo_resume_sessions,
|
|
829
|
+
}
|
|
809
830
|
|
|
810
831
|
# ── Notify on WhatsApp if nothing succeeded ──────────────────────────
|
|
811
832
|
if not ok_any:
|
|
@@ -1563,6 +1584,22 @@ def _resolve_local_project_path(task: dict, payload: dict) -> str:
|
|
|
1563
1584
|
_APPROVAL_POLL_INTERVAL_S = 15.0
|
|
1564
1585
|
_FIRST_GATE_TIMEOUT_S = 900.0
|
|
1565
1586
|
_APPROVAL_POLL_TIMEOUT_S = 1200.0
|
|
1587
|
+
# First-gate WhatsApp fallback. The backend normally sends the gate-1 approval
|
|
1588
|
+
# request (WhatsApp) the moment planning reaches awaiting_approval. If that send
|
|
1589
|
+
# fails the approver gets NOTHING and the ticket silently times out to Backlog.
|
|
1590
|
+
# As a safety net the SDK re-sends the gate-1 request itself — but only ONCE, and
|
|
1591
|
+
# only after this grace window, so a reply that arrives normally never triggers it.
|
|
1592
|
+
#
|
|
1593
|
+
# NOTE on duplicates: the /mgmt/approve endpoint reflects gate/decision state, not
|
|
1594
|
+
# WhatsApp *delivery*, so the SDK cannot tell "backend already messaged" from
|
|
1595
|
+
# "backend's message was dropped". A resend therefore risks a duplicate when the
|
|
1596
|
+
# gate is simply slow to be answered. Two mitigations: (a) the window is set well
|
|
1597
|
+
# past a normal reply time, and (b) true dedup should live in the backend by
|
|
1598
|
+
# upserting the (taskId, gateNumber) request rather than sending a fresh WhatsApp.
|
|
1599
|
+
# Firing this does NOT reset the 15-min Backlog timeout. Set very high to disable.
|
|
1600
|
+
_FIRST_GATE_WA_FALLBACK_S = float(
|
|
1601
|
+
os.getenv("AGENTOS_FIRST_GATE_WA_FALLBACK_S", "") or 300.0
|
|
1602
|
+
)
|
|
1566
1603
|
# Max times a single ticket's session is resumed after a context overflow.
|
|
1567
1604
|
_MAX_CONTEXT_RESUMES = 3
|
|
1568
1605
|
# Max times a ticket's session is resumed after hitting the per-response output-token
|
|
@@ -1608,20 +1645,35 @@ async def _cooperative_sleep(seconds: float, should_stop: Callable[[], bool] | N
|
|
|
1608
1645
|
|
|
1609
1646
|
async def _poll_approval(
|
|
1610
1647
|
client: BackendClient, task_id: str, should_stop: Callable[[], bool] | None = None,
|
|
1648
|
+
*, fallback_notify: Callable[[], Any] | None = None,
|
|
1649
|
+
fallback_after_s: float = 0.0,
|
|
1611
1650
|
) -> str:
|
|
1612
1651
|
"""Poll GET /mgmt/approve until approved/rejected, stop, or timeout.
|
|
1613
1652
|
|
|
1614
1653
|
Returns 'approved' | 'rejected' | 'stopped' | 'timeout'. The decision is the
|
|
1615
1654
|
human's WhatsApp reply, recorded backend-side under approval_log:{task_id}.
|
|
1616
1655
|
Times out after _FIRST_GATE_TIMEOUT_S (15 min) so the ticket can move to Backlog.
|
|
1656
|
+
|
|
1657
|
+
`fallback_notify`: optional coroutine-returning callable invoked exactly ONCE,
|
|
1658
|
+
the first time `fallback_after_s` seconds have elapsed with no decision — a
|
|
1659
|
+
safety net that (re)sends the approval request when the backend never
|
|
1660
|
+
delivered it. Firing it does NOT reset or extend the 15-min timeout.
|
|
1617
1661
|
"""
|
|
1618
1662
|
waited = 0.0
|
|
1663
|
+
notified = False
|
|
1619
1664
|
while waited < _FIRST_GATE_TIMEOUT_S:
|
|
1620
1665
|
if should_stop is not None and should_stop():
|
|
1621
1666
|
return "stopped"
|
|
1622
1667
|
status = await client.approval_status(task_id)
|
|
1623
1668
|
if status in ("approved", "rejected"):
|
|
1624
1669
|
return status
|
|
1670
|
+
if (fallback_notify is not None and not notified
|
|
1671
|
+
and waited >= fallback_after_s):
|
|
1672
|
+
notified = True
|
|
1673
|
+
try:
|
|
1674
|
+
await fallback_notify()
|
|
1675
|
+
except Exception: # noqa: BLE001 — a notify failure must not stop polling
|
|
1676
|
+
pass
|
|
1625
1677
|
await asyncio.sleep(_APPROVAL_POLL_INTERVAL_S)
|
|
1626
1678
|
waited += _APPROVAL_POLL_INTERVAL_S
|
|
1627
1679
|
return "timeout"
|
|
@@ -1652,52 +1704,6 @@ async def _poll_fresh_approval(
|
|
|
1652
1704
|
return "timeout"
|
|
1653
1705
|
|
|
1654
1706
|
|
|
1655
|
-
async def _background_approval_watcher(
|
|
1656
|
-
client: BackendClient,
|
|
1657
|
-
session_id: str,
|
|
1658
|
-
task_id: str,
|
|
1659
|
-
jira_key: str,
|
|
1660
|
-
thread_id: str,
|
|
1661
|
-
send: Callable,
|
|
1662
|
-
should_stop: Callable[[], bool] | None,
|
|
1663
|
-
) -> None:
|
|
1664
|
-
"""Poll indefinitely for a late approval on a timed-out gate, then resume.
|
|
1665
|
-
|
|
1666
|
-
Spawned as a fire-and-forget task when the first-gate 15-min timeout fires.
|
|
1667
|
-
When the user approves (via WhatsApp), calls handle_pipeline_approve so the
|
|
1668
|
-
ticket resumes from exactly where it paused. handle_pipeline_approve already
|
|
1669
|
-
calls mark_in_progress, so the Jira status flips In Progress → Backlog →
|
|
1670
|
-
In Progress automatically. Cleans up _PENDING_LOCAL on any terminal outcome.
|
|
1671
|
-
"""
|
|
1672
|
-
try:
|
|
1673
|
-
while True:
|
|
1674
|
-
if should_stop is not None and should_stop():
|
|
1675
|
-
return
|
|
1676
|
-
status = await client.approval_status(task_id)
|
|
1677
|
-
if status == "approved":
|
|
1678
|
-
break
|
|
1679
|
-
if status == "rejected":
|
|
1680
|
-
await send(Envelope(
|
|
1681
|
-
type="step_update", thread_id=thread_id,
|
|
1682
|
-
data={"current_step": f"{jira_key}: rejected (late approval) — skipped"},
|
|
1683
|
-
).model_dump())
|
|
1684
|
-
return
|
|
1685
|
-
await asyncio.sleep(_APPROVAL_POLL_INTERVAL_S)
|
|
1686
|
-
# Approval arrived — move back to In Progress and resume execution.
|
|
1687
|
-
await send(Envelope(
|
|
1688
|
-
type="step_update", thread_id=thread_id,
|
|
1689
|
-
data={"current_step": f"{jira_key}: late approval received — resuming from Backlog"},
|
|
1690
|
-
).model_dump())
|
|
1691
|
-
await handle_pipeline_approve(
|
|
1692
|
-
client,
|
|
1693
|
-
{"session_id": session_id, "decision": "approved", "thread_id": thread_id},
|
|
1694
|
-
send,
|
|
1695
|
-
)
|
|
1696
|
-
except Exception as exc: # noqa: BLE001
|
|
1697
|
-
logger.warning("background_approval_watcher.failed task=%s err=%s", task_id, exc)
|
|
1698
|
-
finally:
|
|
1699
|
-
_PENDING_LOCAL.pop(session_id, None)
|
|
1700
|
-
|
|
1701
1707
|
|
|
1702
1708
|
async def handle_orchestrate_auto_local_run(
|
|
1703
1709
|
client: BackendClient, payload: dict, send: Callable,
|
|
@@ -1784,38 +1790,89 @@ async def handle_orchestrate_auto_local_run(
|
|
|
1784
1790
|
# Per-ticket isolation: a failure here (network, git, a raising
|
|
1785
1791
|
# repo) must NOT abort the remaining tickets — log it and move on.
|
|
1786
1792
|
try:
|
|
1787
|
-
# Retry the ticket if a run hits the Claude usage limit:
|
|
1788
|
-
# handle_pipeline_approve moves it to Backlog and signals here; we
|
|
1789
|
-
# wait for the window to reset and re-run — up to _MAX_LIMIT_RETRIES.
|
|
1790
1793
|
attempt = 0
|
|
1791
1794
|
while True:
|
|
1792
1795
|
attempt += 1
|
|
1793
|
-
# Mark In Progress before any work begins
|
|
1794
|
-
# the real state even if the run fails partway through.
|
|
1796
|
+
# Mark In Progress before any work begins.
|
|
1795
1797
|
await client.mark_in_progress(tid)
|
|
1798
|
+
|
|
1799
|
+
# For retries (attempt > 1): override agent_mode=False so the
|
|
1800
|
+
# backend skips the WhatsApp gate AND the 15-min Backlog timer.
|
|
1801
|
+
# Human already approved on attempt 1; a new timer firing mid-run
|
|
1802
|
+
# would erroneously demote the ticket to Backlog while claude works.
|
|
1803
|
+
run_payload = (
|
|
1804
|
+
task_payload if attempt == 1
|
|
1805
|
+
else {**task_payload, "agent_mode": False}
|
|
1806
|
+
)
|
|
1807
|
+
|
|
1796
1808
|
# Phase 1 — server plans + gates. NO local code/bash runs yet.
|
|
1797
|
-
await _run_local_pipeline(client,
|
|
1809
|
+
await _run_local_pipeline(client, run_payload, send)
|
|
1798
1810
|
session_id = next(
|
|
1799
1811
|
(sid for sid, p in _PENDING_LOCAL.items() if p.get("task_id") == tid),
|
|
1800
1812
|
None,
|
|
1801
1813
|
)
|
|
1802
1814
|
if not session_id:
|
|
1803
|
-
# Planning never reached the approval gate —
|
|
1815
|
+
# Planning never reached the approval gate — e.g. planning
|
|
1816
|
+
# timed out, the /orchestrate kickoff errored, or the
|
|
1817
|
+
# process/network dropped mid-plan. The ticket was marked
|
|
1818
|
+
# In Progress at the top of this loop, so we MUST move it off
|
|
1819
|
+
# In Progress here: the 15-min Backlog timer lives inside the
|
|
1820
|
+
# gate branch below and never runs when the gate isn't
|
|
1821
|
+
# reached, so without this the ticket is orphaned In Progress
|
|
1822
|
+
# forever. Backlog matches the other hard-failure paths in
|
|
1823
|
+
# this loop; note the auto-run only re-fetches "To Do", so a
|
|
1824
|
+
# Backlog ticket needs a manual move back to To Do (or a new
|
|
1825
|
+
# run after it is re-queued) to be retried.
|
|
1826
|
+
await client.log_activity(
|
|
1827
|
+
f"Task {tid}: planning did not reach the approval gate — "
|
|
1828
|
+
"returning to Backlog"
|
|
1829
|
+
)
|
|
1830
|
+
await client.move_to_backlog(tid)
|
|
1804
1831
|
await send(Envelope(
|
|
1805
1832
|
type="step_update", thread_id=thread_id,
|
|
1806
|
-
data={"current_step": f"{jira_key}:
|
|
1833
|
+
data={"current_step": f"{jira_key}: no approval gate reached — "
|
|
1834
|
+
"moved to Backlog (was stuck In Progress)"},
|
|
1807
1835
|
).model_dump())
|
|
1808
1836
|
break
|
|
1809
1837
|
|
|
1810
|
-
# First-approval gate
|
|
1811
|
-
#
|
|
1812
|
-
|
|
1838
|
+
# ── First-approval gate (agentMode=True, attempt 1 only) ──────
|
|
1839
|
+
# Skipped on retries — human already approved; re-running the gate
|
|
1840
|
+
# would time out and advance to the next ticket instead of retrying.
|
|
1841
|
+
if agent_mode and attempt == 1:
|
|
1813
1842
|
await send(Envelope(
|
|
1814
1843
|
type=Envelope.TYPE_AWAITING_APPROVAL, thread_id=thread_id,
|
|
1815
1844
|
data={"jira_key": jira_key, "session_id": session_id, "task_id": tid,
|
|
1816
1845
|
"current_step": f"{jira_key}: awaiting approval"},
|
|
1817
1846
|
).model_dump())
|
|
1818
|
-
|
|
1847
|
+
|
|
1848
|
+
# Safety net for the gate-1 WhatsApp. The backend sends it
|
|
1849
|
+
# when planning opens the gate; if that send failed the
|
|
1850
|
+
# approver would get nothing and the ticket would silently
|
|
1851
|
+
# time out to Backlog. Re-send it ourselves once, only after
|
|
1852
|
+
# the grace window, so a healthy backend doesn't duplicate.
|
|
1853
|
+
_wa_summary = task.get("summary") or jira_key or "task"
|
|
1854
|
+
|
|
1855
|
+
async def _first_gate_wa_fallback(
|
|
1856
|
+
tid=tid, jira_key=jira_key, _wa_summary=_wa_summary,
|
|
1857
|
+
) -> None:
|
|
1858
|
+
await send(Envelope(
|
|
1859
|
+
type="step_update", thread_id=thread_id,
|
|
1860
|
+
data={"current_step": f"{jira_key}: no approval reply yet — "
|
|
1861
|
+
"re-sending WhatsApp approval request"},
|
|
1862
|
+
).model_dump())
|
|
1863
|
+
await client.notify_whatsapp(
|
|
1864
|
+
tid,
|
|
1865
|
+
f"Approval needed for {jira_key or tid}: {_wa_summary} — "
|
|
1866
|
+
"reply on WhatsApp to approve or reject so the work can start.",
|
|
1867
|
+
summary=_wa_summary,
|
|
1868
|
+
)
|
|
1869
|
+
|
|
1870
|
+
decision = await _poll_approval(
|
|
1871
|
+
client, tid, should_stop,
|
|
1872
|
+
fallback_notify=_first_gate_wa_fallback,
|
|
1873
|
+
fallback_after_s=_FIRST_GATE_WA_FALLBACK_S,
|
|
1874
|
+
)
|
|
1875
|
+
|
|
1819
1876
|
if decision == "stopped":
|
|
1820
1877
|
_PENDING_LOCAL.pop(session_id, None)
|
|
1821
1878
|
await send(Envelope(
|
|
@@ -1824,20 +1881,36 @@ async def handle_orchestrate_auto_local_run(
|
|
|
1824
1881
|
"processed": processed, "task_count": total},
|
|
1825
1882
|
).model_dump())
|
|
1826
1883
|
return
|
|
1884
|
+
|
|
1827
1885
|
if decision == "timeout":
|
|
1828
|
-
# 15 min elapsed
|
|
1829
|
-
#
|
|
1830
|
-
#
|
|
1886
|
+
# 15 min elapsed with no WhatsApp reply.
|
|
1887
|
+
# Move to Backlog; keep the terminal ALIVE and poll
|
|
1888
|
+
# indefinitely — no tickets start until approval arrives.
|
|
1831
1889
|
await client.move_to_backlog(tid)
|
|
1832
1890
|
await send(Envelope(
|
|
1833
1891
|
type="step_update", thread_id=thread_id,
|
|
1834
|
-
data={"current_step": f"{jira_key}: no approval in 15m — moved to
|
|
1835
|
-
"
|
|
1892
|
+
data={"current_step": f"{jira_key}: no approval in 15m — moved to"
|
|
1893
|
+
" Backlog; polling for late approval "
|
|
1894
|
+
"(terminal stays alive)"},
|
|
1836
1895
|
).model_dump())
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1896
|
+
while True:
|
|
1897
|
+
if should_stop is not None and should_stop():
|
|
1898
|
+
_PENDING_LOCAL.pop(session_id, None)
|
|
1899
|
+
return
|
|
1900
|
+
late = await client.approval_status(tid)
|
|
1901
|
+
if late in ("approved", "rejected"):
|
|
1902
|
+
decision = late
|
|
1903
|
+
break
|
|
1904
|
+
await asyncio.sleep(_APPROVAL_POLL_INTERVAL_S)
|
|
1905
|
+
if decision == "approved":
|
|
1906
|
+
# Late approval — move back to In Progress before executing.
|
|
1907
|
+
await client.mark_in_progress(tid)
|
|
1908
|
+
await send(Envelope(
|
|
1909
|
+
type="step_update", thread_id=thread_id,
|
|
1910
|
+
data={"current_step": f"{jira_key}: late approval received — "
|
|
1911
|
+
"resuming (In Progress)"},
|
|
1912
|
+
).model_dump())
|
|
1913
|
+
|
|
1841
1914
|
if decision != "approved":
|
|
1842
1915
|
_PENDING_LOCAL.pop(session_id, None)
|
|
1843
1916
|
await send(Envelope(
|
|
@@ -1846,48 +1919,50 @@ async def handle_orchestrate_auto_local_run(
|
|
|
1846
1919
|
).model_dump())
|
|
1847
1920
|
break
|
|
1848
1921
|
|
|
1849
|
-
# Phase 2 — run the user's local `claude`, report back
|
|
1922
|
+
# ── Phase 2 — run the user's local `claude`, report back ───────
|
|
1850
1923
|
outcome = await handle_pipeline_approve(
|
|
1851
1924
|
client,
|
|
1852
1925
|
{"session_id": session_id, "decision": "approved", "thread_id": thread_id},
|
|
1853
1926
|
send,
|
|
1854
1927
|
)
|
|
1855
1928
|
|
|
1856
|
-
#
|
|
1929
|
+
# ── Claude usage-limit hit ─────────────────────────────────────
|
|
1930
|
+
# Move to Backlog (backend 15-min timer fires at that point for
|
|
1931
|
+
# the first attempt; explicit move covers retries which have no
|
|
1932
|
+
# timer). Sleep until the window resets, then move back to In
|
|
1933
|
+
# Progress and resume the same claude session. Terminal stays alive.
|
|
1857
1934
|
if isinstance(outcome, dict) and outcome.get("status") == "limit_exceeded":
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
if
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
).model_dump())
|
|
1872
|
-
return
|
|
1873
|
-
continue # re-run this ticket from phase 1
|
|
1874
|
-
# Out of retries. The usage/session limit is ACCOUNT-WIDE, so
|
|
1875
|
-
# every remaining ticket would hit the same wall and burn its
|
|
1876
|
-
# own retry budget (up to 6h each). Pause the whole run instead
|
|
1877
|
-
# of marching on — this ticket and the rest stay in To Do/
|
|
1878
|
-
# Backlog, and `agentos-auto resume` picks them up after reset.
|
|
1935
|
+
wait_s = _limit_wait_seconds(outcome.get("reset_epoch"))
|
|
1936
|
+
# Show the reset time in UK timezone (the VPS clock).
|
|
1937
|
+
try:
|
|
1938
|
+
from zoneinfo import ZoneInfo as _ZI
|
|
1939
|
+
from datetime import datetime as _dt
|
|
1940
|
+
_uk_tz = _ZI("Europe/London")
|
|
1941
|
+
_reset_str = _dt.fromtimestamp(
|
|
1942
|
+
outcome["reset_epoch"], tz=_uk_tz,
|
|
1943
|
+
).strftime("%H:%M %Z") if outcome.get("reset_epoch") else f"~{round(wait_s/60)}m"
|
|
1944
|
+
except Exception: # noqa: BLE001
|
|
1945
|
+
_reset_str = f"~{round(wait_s/60)}m"
|
|
1946
|
+
# Move to Backlog now so Jira reflects the wait state.
|
|
1947
|
+
await client.move_to_backlog(tid)
|
|
1879
1948
|
await send(Envelope(
|
|
1880
1949
|
type="step_update", thread_id=thread_id,
|
|
1881
|
-
data={"current_step": f"{jira_key}: usage limit —
|
|
1882
|
-
f"
|
|
1883
|
-
|
|
1884
|
-
await send(Envelope(
|
|
1885
|
-
type=Envelope.TYPE_DONE, thread_id=thread_id,
|
|
1886
|
-
data={"mode": "orchestrate-auto-local", "status": "paused",
|
|
1887
|
-
"reason": "usage_limit", "processed": processed,
|
|
1888
|
-
"task_count": total},
|
|
1950
|
+
data={"current_step": f"{jira_key}: Claude usage limit — moved to Backlog;"
|
|
1951
|
+
f" resuming at {_reset_str} (attempt {attempt}/"
|
|
1952
|
+
f"{_MAX_LIMIT_RETRIES}); terminal stays alive"},
|
|
1889
1953
|
).model_dump())
|
|
1890
|
-
|
|
1954
|
+
if not await _cooperative_sleep(wait_s, should_stop):
|
|
1955
|
+
return # only exits on explicit stop request
|
|
1956
|
+
# Limit reset: save per-repo claude session IDs so the model
|
|
1957
|
+
# resumes exactly where it was interrupted, then loop back to
|
|
1958
|
+
# mark_in_progress at the top (moves ticket back to In Progress).
|
|
1959
|
+
task_payload["_resume_sessions"] = (
|
|
1960
|
+
outcome.get("repo_resume_sessions") or {}
|
|
1961
|
+
)
|
|
1962
|
+
if attempt >= _MAX_LIMIT_RETRIES:
|
|
1963
|
+
# Reset counter so the next window gets a fresh set of retries.
|
|
1964
|
+
attempt = 0
|
|
1965
|
+
continue # re-run this ticket (mark_in_progress at top of loop)
|
|
1891
1966
|
|
|
1892
1967
|
processed += 1
|
|
1893
1968
|
break
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""CLI: ``agentos-auto`` — run the full orchestrate-auto pipeline LOCALLY.
|
|
2
|
+
|
|
3
|
+
Mirrors the old one-shot trigger (``POST /api/v1/orchestrate-auto`` with the
|
|
4
|
+
``co_`` token in the header), but executes on THIS machine: the server plans +
|
|
5
|
+
gates each To Do task, the local ``claude`` writes the code in the project
|
|
6
|
+
directory, and the result is reported back. The only required input is the
|
|
7
|
+
``co_`` token saved by ``agentos configure``.
|
|
8
|
+
|
|
9
|
+
Commands:
|
|
10
|
+
agentos-auto run every To Do task locally, in the BACKGROUND
|
|
11
|
+
agentos-auto resume alias for run — re-fetches remaining To Do tasks
|
|
12
|
+
agentos-auto stop ask the running loop to pause after the current task
|
|
13
|
+
agentos-auto status show whether the background loop is running / stop pending
|
|
14
|
+
agentos-auto update upgrade the SDK to the latest version
|
|
15
|
+
|
|
16
|
+
Options:
|
|
17
|
+
--workspace-root DIR base dir for per-task project folders
|
|
18
|
+
--foreground, -f stay attached to the terminal (do not detach)
|
|
19
|
+
--no-update skip the automatic self-update check (or set AGENTOS_NO_UPDATE=1)
|
|
20
|
+
|
|
21
|
+
Background model: `run`/`resume` detach into a background daemon (double-fork +
|
|
22
|
+
setsid), so closing the terminal (SIGHUP) does NOT stop the loop — it keeps
|
|
23
|
+
running until `agentos-auto stop`. Output goes to ~/.agentos/auto.log and the
|
|
24
|
+
PID to ~/.agentos/auto.pid; only one loop runs at a time. Use `--foreground` to
|
|
25
|
+
run attached (for debugging). Platforms without os.fork fall back to foreground.
|
|
26
|
+
|
|
27
|
+
Auto-update: on `run`/`resume`, the SDK checks for a newer version and upgrades
|
|
28
|
+
itself (then re-runs on the new code) BEFORE detaching, so non-technical users
|
|
29
|
+
never reinstall.
|
|
30
|
+
|
|
31
|
+
Stop/resume model: the loop marks each task In Progress/Done on the backend as
|
|
32
|
+
it goes, so the backend task status IS the saved state. `stop` writes a flag the
|
|
33
|
+
running loop checks between tasks (so a stop takes effect after the in-flight
|
|
34
|
+
task finishes); `resume` (or just re-running) clears the flag and continues with
|
|
35
|
+
whatever is still To Do.
|
|
36
|
+
"""
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import asyncio
|
|
40
|
+
import json
|
|
41
|
+
import os
|
|
42
|
+
import sys
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
|
|
45
|
+
_AGENTOS_DIR = Path.home() / ".agentos"
|
|
46
|
+
_STOP_FILE = _AGENTOS_DIR / "auto.stop"
|
|
47
|
+
_PID_FILE = _AGENTOS_DIR / "auto.pid"
|
|
48
|
+
_LOG_FILE = _AGENTOS_DIR / "auto.log"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _stop_requested() -> bool:
|
|
52
|
+
return _STOP_FILE.exists()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _set_stop() -> None:
|
|
56
|
+
_STOP_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
_STOP_FILE.write_text("stop")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _clear_stop() -> None:
|
|
61
|
+
try:
|
|
62
|
+
_STOP_FILE.unlink()
|
|
63
|
+
except FileNotFoundError:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ── Background-daemon support ─────────────────────────────────────────────
|
|
68
|
+
# `agentos-auto` must keep running after the terminal is closed and stop ONLY on
|
|
69
|
+
# `agentos-auto stop`. We fully detach from the controlling terminal (double-fork
|
|
70
|
+
# + setsid) so SIGHUP on terminal close no longer reaches the loop.
|
|
71
|
+
|
|
72
|
+
def _read_pid() -> int | None:
|
|
73
|
+
try:
|
|
74
|
+
pid = int(_PID_FILE.read_text().strip())
|
|
75
|
+
except (FileNotFoundError, ValueError):
|
|
76
|
+
return None
|
|
77
|
+
return pid
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _pid_alive(pid: int) -> bool:
|
|
81
|
+
try:
|
|
82
|
+
os.kill(pid, 0) # signal 0 → existence/permission check, no actual signal
|
|
83
|
+
except ProcessLookupError:
|
|
84
|
+
return False
|
|
85
|
+
except PermissionError:
|
|
86
|
+
return True # exists but owned by another user
|
|
87
|
+
return True
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _daemon_running() -> int | None:
|
|
91
|
+
"""Return the PID of a live background loop, else None (clearing a stale file)."""
|
|
92
|
+
pid = _read_pid()
|
|
93
|
+
if pid is None:
|
|
94
|
+
return None
|
|
95
|
+
if _pid_alive(pid):
|
|
96
|
+
return pid
|
|
97
|
+
# Stale PID file left by a crashed/killed daemon — clean it up.
|
|
98
|
+
try:
|
|
99
|
+
_PID_FILE.unlink()
|
|
100
|
+
except FileNotFoundError:
|
|
101
|
+
pass
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _daemonize() -> bool:
|
|
106
|
+
"""Detach the current process from the terminal (POSIX double-fork).
|
|
107
|
+
|
|
108
|
+
Returns True in the surviving daemon child; the two intermediate parents call
|
|
109
|
+
os._exit() and never return. stdout/stderr are redirected to _LOG_FILE and a
|
|
110
|
+
PID file is written so `stop`/`status` can find the loop. Returns False (stays
|
|
111
|
+
in the foreground) on platforms without os.fork (e.g. Windows).
|
|
112
|
+
"""
|
|
113
|
+
if not hasattr(os, "fork"):
|
|
114
|
+
return False # caller falls back to foreground
|
|
115
|
+
|
|
116
|
+
_AGENTOS_DIR.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
|
|
118
|
+
# First fork: let the shell reap the parent and return the prompt.
|
|
119
|
+
if os.fork() > 0:
|
|
120
|
+
os._exit(0)
|
|
121
|
+
os.setsid() # new session leader → drop the controlling terminal
|
|
122
|
+
# Second fork: guarantee the daemon can never reacquire a controlling terminal.
|
|
123
|
+
if os.fork() > 0:
|
|
124
|
+
os._exit(0)
|
|
125
|
+
|
|
126
|
+
# Redirect stdio: stdin ← /dev/null, stdout/stderr → append to the log file.
|
|
127
|
+
sys.stdout.flush()
|
|
128
|
+
sys.stderr.flush()
|
|
129
|
+
devnull = os.open(os.devnull, os.O_RDONLY)
|
|
130
|
+
logfd = os.open(_LOG_FILE, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
|
|
131
|
+
os.dup2(devnull, sys.stdin.fileno())
|
|
132
|
+
os.dup2(logfd, sys.stdout.fileno())
|
|
133
|
+
os.dup2(logfd, sys.stderr.fileno())
|
|
134
|
+
os.close(devnull)
|
|
135
|
+
os.close(logfd)
|
|
136
|
+
|
|
137
|
+
_PID_FILE.write_text(str(os.getpid()))
|
|
138
|
+
return True
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _parse_workspace_root(argv: list[str]) -> str | None:
|
|
142
|
+
if "--workspace-root" in argv:
|
|
143
|
+
i = argv.index("--workspace-root")
|
|
144
|
+
try:
|
|
145
|
+
return argv[i + 1]
|
|
146
|
+
except IndexError:
|
|
147
|
+
print("error: --workspace-root needs a directory argument")
|
|
148
|
+
sys.exit(1)
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
async def _send(env: dict) -> None:
|
|
153
|
+
etype = env.get("type", "")
|
|
154
|
+
data = env.get("data", {}) or {}
|
|
155
|
+
if etype == "pipeline_started":
|
|
156
|
+
print(f"\n▶ orchestrate-auto (local) — {data.get('task_count', 0)} task(s)\n")
|
|
157
|
+
elif etype == "step_update":
|
|
158
|
+
step = data.get("current_step", "")
|
|
159
|
+
pp = data.get("project_path")
|
|
160
|
+
print(f" • {step}" + (f" [{pp}]" if pp else ""))
|
|
161
|
+
elif etype == "awaiting_approval":
|
|
162
|
+
print(f" ⏸ approval gate: {data.get('summary', data.get('jira_key', ''))}")
|
|
163
|
+
elif etype == "error":
|
|
164
|
+
print(f" ✗ {data.get('message') or data.get('code') or data}")
|
|
165
|
+
elif etype == "done":
|
|
166
|
+
status = data.get("status", "")
|
|
167
|
+
if status == "paused":
|
|
168
|
+
print(f"\n⏸ paused after {data.get('processed', 0)}/{data.get('task_count', 0)} "
|
|
169
|
+
f"task(s). run `agentos-auto resume` to continue.\n")
|
|
170
|
+
else:
|
|
171
|
+
print(f"\n✓ done — {json.dumps(data)[:400]}\n")
|
|
172
|
+
else:
|
|
173
|
+
print(f" [{etype}] {json.dumps(data)[:200]}")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _run_loop(workspace_root: str | None) -> None:
|
|
177
|
+
from sdk import local_exec
|
|
178
|
+
from sdk.client import BackendClient
|
|
179
|
+
from sdk.ipc import handlers
|
|
180
|
+
|
|
181
|
+
if not local_exec.is_available():
|
|
182
|
+
print("error: the `claude` CLI is not installed/authenticated on this machine.")
|
|
183
|
+
print(" install it and run `claude setup-token`, then retry.")
|
|
184
|
+
sys.exit(1)
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
client = BackendClient.from_config() # co_ token + server URL from ~/.agentos/config.toml
|
|
188
|
+
except Exception as exc: # noqa: BLE001
|
|
189
|
+
print(f"error: SDK not configured ({exc}). run `agentos configure` first.")
|
|
190
|
+
sys.exit(1)
|
|
191
|
+
|
|
192
|
+
payload: dict = {"thread_id": "agentos-auto-cli"}
|
|
193
|
+
if workspace_root:
|
|
194
|
+
payload["workspace_root"] = workspace_root
|
|
195
|
+
|
|
196
|
+
_clear_stop() # a fresh run/resume clears any stale stop flag
|
|
197
|
+
try:
|
|
198
|
+
asyncio.run(
|
|
199
|
+
handlers.handle_orchestrate_auto_local_run(
|
|
200
|
+
client, payload, _send, should_stop=_stop_requested,
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
except KeyboardInterrupt:
|
|
204
|
+
_set_stop()
|
|
205
|
+
print("\n⏸ interrupted — stop flag set. run `agentos-auto resume` to continue.")
|
|
206
|
+
finally:
|
|
207
|
+
# Whether the loop finished, paused, or crashed, drop our PID file so
|
|
208
|
+
# `status` doesn't report a dead daemon as running.
|
|
209
|
+
if _read_pid() == os.getpid():
|
|
210
|
+
try:
|
|
211
|
+
_PID_FILE.unlink()
|
|
212
|
+
except FileNotFoundError:
|
|
213
|
+
pass
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def main() -> None:
|
|
217
|
+
argv = sys.argv[1:]
|
|
218
|
+
cmd = argv[0] if argv and not argv[0].startswith("-") else "run"
|
|
219
|
+
|
|
220
|
+
if cmd == "update":
|
|
221
|
+
from sdk.updater import auto_update
|
|
222
|
+
updated, cur, latest = auto_update(quiet=False)
|
|
223
|
+
if not updated:
|
|
224
|
+
print(f"already up to date (v{cur})" if latest else f"v{cur} (could not reach PyPI)")
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
if cmd == "stop":
|
|
228
|
+
_set_stop()
|
|
229
|
+
pid = _daemon_running()
|
|
230
|
+
if pid:
|
|
231
|
+
print(f"stop requested — the background loop (pid {pid}) will pause after "
|
|
232
|
+
"the current task, then exit.")
|
|
233
|
+
else:
|
|
234
|
+
print("stop requested — a running `agentos-auto` will pause after the current task.")
|
|
235
|
+
return
|
|
236
|
+
|
|
237
|
+
if cmd == "status":
|
|
238
|
+
pid = _daemon_running()
|
|
239
|
+
if pid:
|
|
240
|
+
print(f"background loop: running (pid {pid})")
|
|
241
|
+
print(f"logs: {_LOG_FILE}")
|
|
242
|
+
else:
|
|
243
|
+
print("background loop: not running")
|
|
244
|
+
print("stop pending: yes" if _stop_requested() else "stop pending: no")
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
if cmd in ("run", "resume"):
|
|
248
|
+
# Refuse to start a second loop — one background daemon owns the To Do queue.
|
|
249
|
+
pid = _daemon_running()
|
|
250
|
+
if pid:
|
|
251
|
+
print(f"agentos-auto is already running in the background (pid {pid}).")
|
|
252
|
+
print(f" logs: {_LOG_FILE}")
|
|
253
|
+
print(" stop: agentos-auto stop")
|
|
254
|
+
return
|
|
255
|
+
|
|
256
|
+
# Keep non-technical users current: self-update, then re-run on the new
|
|
257
|
+
# code. Runs in the FOREGROUND (before detaching) so the user sees update
|
|
258
|
+
# output. Skipped with --no-update or AGENTOS_NO_UPDATE=1.
|
|
259
|
+
if "--no-update" not in argv:
|
|
260
|
+
from sdk.updater import auto_update_and_reexec
|
|
261
|
+
auto_update_and_reexec("sdk.run_auto", sys.argv[1:])
|
|
262
|
+
|
|
263
|
+
workspace_root = _parse_workspace_root(argv)
|
|
264
|
+
|
|
265
|
+
# Background by default: detach so closing the terminal (SIGHUP) can't kill
|
|
266
|
+
# the loop — it runs until `agentos-auto stop`. `--foreground`/`-f` keeps it
|
|
267
|
+
# attached (useful for debugging). On platforms without fork we stay attached.
|
|
268
|
+
foreground = "--foreground" in argv or "-f" in argv
|
|
269
|
+
if not foreground and hasattr(os, "fork"):
|
|
270
|
+
print("▶ agentos-auto started in the background — it keeps running after you "
|
|
271
|
+
"close this terminal.")
|
|
272
|
+
print(f" logs: {_LOG_FILE}")
|
|
273
|
+
print(" status: agentos-auto status")
|
|
274
|
+
print(" stop: agentos-auto stop")
|
|
275
|
+
if not foreground and _daemonize():
|
|
276
|
+
_run_loop(workspace_root) # only the detached daemon child reaches here
|
|
277
|
+
return
|
|
278
|
+
if not foreground:
|
|
279
|
+
print("note: background mode unavailable on this platform — running in the "
|
|
280
|
+
"foreground. Keep this terminal open.")
|
|
281
|
+
else:
|
|
282
|
+
# Foreground run still records a PID so stop/status can find it.
|
|
283
|
+
_AGENTOS_DIR.mkdir(parents=True, exist_ok=True)
|
|
284
|
+
_PID_FILE.write_text(str(os.getpid()))
|
|
285
|
+
_run_loop(workspace_root)
|
|
286
|
+
return
|
|
287
|
+
|
|
288
|
+
print(f"unknown command '{cmd}'. use: run | resume | stop | status | update")
|
|
289
|
+
sys.exit(1)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
if __name__ == "__main__":
|
|
293
|
+
main()
|
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
"""CLI: ``agentos-auto`` — run the full orchestrate-auto pipeline LOCALLY.
|
|
2
|
-
|
|
3
|
-
Mirrors the old one-shot trigger (``POST /api/v1/orchestrate-auto`` with the
|
|
4
|
-
``co_`` token in the header), but executes on THIS machine: the server plans +
|
|
5
|
-
gates each To Do task, the local ``claude`` writes the code in the project
|
|
6
|
-
directory, and the result is reported back. The only required input is the
|
|
7
|
-
``co_`` token saved by ``agentos configure``.
|
|
8
|
-
|
|
9
|
-
Commands:
|
|
10
|
-
agentos-auto run every To Do task locally (start / resume)
|
|
11
|
-
agentos-auto resume alias for run — re-fetches remaining To Do tasks
|
|
12
|
-
agentos-auto stop ask a running loop to pause after the current task
|
|
13
|
-
agentos-auto status show whether a stop is pending
|
|
14
|
-
agentos-auto update upgrade the SDK to the latest version
|
|
15
|
-
|
|
16
|
-
Options:
|
|
17
|
-
--workspace-root DIR base dir for per-task project folders
|
|
18
|
-
--no-update skip the automatic self-update check (or set AGENTOS_NO_UPDATE=1)
|
|
19
|
-
|
|
20
|
-
Auto-update: on `run`/`resume`, the SDK checks for a newer version and upgrades
|
|
21
|
-
itself (then re-runs on the new code), so non-technical users never reinstall.
|
|
22
|
-
|
|
23
|
-
Stop/resume model: the loop marks each task In Progress/Done on the backend as
|
|
24
|
-
it goes, so the backend task status IS the saved state. `stop` writes a flag the
|
|
25
|
-
running loop checks between tasks; `resume` (or just re-running) clears the flag
|
|
26
|
-
and continues with whatever is still To Do.
|
|
27
|
-
"""
|
|
28
|
-
from __future__ import annotations
|
|
29
|
-
|
|
30
|
-
import asyncio
|
|
31
|
-
import json
|
|
32
|
-
import sys
|
|
33
|
-
from pathlib import Path
|
|
34
|
-
|
|
35
|
-
_STOP_FILE = Path.home() / ".agentos" / "auto.stop"
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def _stop_requested() -> bool:
|
|
39
|
-
return _STOP_FILE.exists()
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def _set_stop() -> None:
|
|
43
|
-
_STOP_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
-
_STOP_FILE.write_text("stop")
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def _clear_stop() -> None:
|
|
48
|
-
try:
|
|
49
|
-
_STOP_FILE.unlink()
|
|
50
|
-
except FileNotFoundError:
|
|
51
|
-
pass
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def _parse_workspace_root(argv: list[str]) -> str | None:
|
|
55
|
-
if "--workspace-root" in argv:
|
|
56
|
-
i = argv.index("--workspace-root")
|
|
57
|
-
try:
|
|
58
|
-
return argv[i + 1]
|
|
59
|
-
except IndexError:
|
|
60
|
-
print("error: --workspace-root needs a directory argument")
|
|
61
|
-
sys.exit(1)
|
|
62
|
-
return None
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
async def _send(env: dict) -> None:
|
|
66
|
-
etype = env.get("type", "")
|
|
67
|
-
data = env.get("data", {}) or {}
|
|
68
|
-
if etype == "pipeline_started":
|
|
69
|
-
print(f"\n▶ orchestrate-auto (local) — {data.get('task_count', 0)} task(s)\n")
|
|
70
|
-
elif etype == "step_update":
|
|
71
|
-
step = data.get("current_step", "")
|
|
72
|
-
pp = data.get("project_path")
|
|
73
|
-
print(f" • {step}" + (f" [{pp}]" if pp else ""))
|
|
74
|
-
elif etype == "awaiting_approval":
|
|
75
|
-
print(f" ⏸ approval gate: {data.get('summary', data.get('jira_key', ''))}")
|
|
76
|
-
elif etype == "error":
|
|
77
|
-
print(f" ✗ {data.get('message') or data.get('code') or data}")
|
|
78
|
-
elif etype == "done":
|
|
79
|
-
status = data.get("status", "")
|
|
80
|
-
if status == "paused":
|
|
81
|
-
print(f"\n⏸ paused after {data.get('processed', 0)}/{data.get('task_count', 0)} "
|
|
82
|
-
f"task(s). run `agentos-auto resume` to continue.\n")
|
|
83
|
-
else:
|
|
84
|
-
print(f"\n✓ done — {json.dumps(data)[:400]}\n")
|
|
85
|
-
else:
|
|
86
|
-
print(f" [{etype}] {json.dumps(data)[:200]}")
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def _run_loop(workspace_root: str | None) -> None:
|
|
90
|
-
from sdk import local_exec
|
|
91
|
-
from sdk.client import BackendClient
|
|
92
|
-
from sdk.ipc import handlers
|
|
93
|
-
|
|
94
|
-
if not local_exec.is_available():
|
|
95
|
-
print("error: the `claude` CLI is not installed/authenticated on this machine.")
|
|
96
|
-
print(" install it and run `claude setup-token`, then retry.")
|
|
97
|
-
sys.exit(1)
|
|
98
|
-
|
|
99
|
-
try:
|
|
100
|
-
client = BackendClient.from_config() # co_ token + server URL from ~/.agentos/config.toml
|
|
101
|
-
except Exception as exc: # noqa: BLE001
|
|
102
|
-
print(f"error: SDK not configured ({exc}). run `agentos configure` first.")
|
|
103
|
-
sys.exit(1)
|
|
104
|
-
|
|
105
|
-
payload: dict = {"thread_id": "agentos-auto-cli"}
|
|
106
|
-
if workspace_root:
|
|
107
|
-
payload["workspace_root"] = workspace_root
|
|
108
|
-
|
|
109
|
-
_clear_stop() # a fresh run/resume clears any stale stop flag
|
|
110
|
-
try:
|
|
111
|
-
asyncio.run(
|
|
112
|
-
handlers.handle_orchestrate_auto_local_run(
|
|
113
|
-
client, payload, _send, should_stop=_stop_requested,
|
|
114
|
-
)
|
|
115
|
-
)
|
|
116
|
-
except KeyboardInterrupt:
|
|
117
|
-
_set_stop()
|
|
118
|
-
print("\n⏸ interrupted — stop flag set. run `agentos-auto resume` to continue.")
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
def main() -> None:
|
|
122
|
-
argv = sys.argv[1:]
|
|
123
|
-
cmd = argv[0] if argv and not argv[0].startswith("-") else "run"
|
|
124
|
-
|
|
125
|
-
if cmd == "update":
|
|
126
|
-
from sdk.updater import auto_update
|
|
127
|
-
updated, cur, latest = auto_update(quiet=False)
|
|
128
|
-
if not updated:
|
|
129
|
-
print(f"already up to date (v{cur})" if latest else f"v{cur} (could not reach PyPI)")
|
|
130
|
-
return
|
|
131
|
-
|
|
132
|
-
if cmd == "stop":
|
|
133
|
-
_set_stop()
|
|
134
|
-
print("stop requested — a running `agentos-auto` will pause after the current task.")
|
|
135
|
-
return
|
|
136
|
-
|
|
137
|
-
if cmd == "status":
|
|
138
|
-
print("stop pending: yes" if _stop_requested() else "stop pending: no")
|
|
139
|
-
return
|
|
140
|
-
|
|
141
|
-
if cmd in ("run", "resume"):
|
|
142
|
-
# Keep non-technical users current: self-update, then re-run on the new
|
|
143
|
-
# code. Skipped with --no-update or AGENTOS_NO_UPDATE=1.
|
|
144
|
-
if "--no-update" not in argv:
|
|
145
|
-
from sdk.updater import auto_update_and_reexec
|
|
146
|
-
auto_update_and_reexec("sdk.run_auto", sys.argv[1:])
|
|
147
|
-
_run_loop(_parse_workspace_root(argv))
|
|
148
|
-
return
|
|
149
|
-
|
|
150
|
-
print(f"unknown command '{cmd}'. use: run | resume | stop | status | update")
|
|
151
|
-
sys.exit(1)
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
if __name__ == "__main__":
|
|
155
|
-
main()
|
|
File without changes
|
|
File without changes
|
{devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.27}/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
|
|
File without changes
|
|
File without changes
|