devops-bot-sdk 1.4.25__tar.gz → 1.4.26__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 (39) hide show
  1. {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.26}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.26}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.26}/pyproject.toml +1 -1
  4. {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.26}/sdk/__init__.py +2 -2
  5. {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.26}/sdk/client.py +1 -1
  6. {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.26}/sdk/ipc/handlers.py +184 -109
  7. devops_bot_sdk-1.4.26/sdk/run_auto.py +293 -0
  8. devops_bot_sdk-1.4.25/sdk/run_auto.py +0 -155
  9. {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.26}/README.md +0 -0
  10. {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.26}/devops_bot_sdk.egg-info/SOURCES.txt +0 -0
  11. {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.26}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  12. {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.26}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
  13. {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.26}/devops_bot_sdk.egg-info/requires.txt +0 -0
  14. {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.26}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  15. {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.26}/sdk/collectors/__init__.py +0 -0
  16. {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.26}/sdk/collectors/files.py +0 -0
  17. {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.26}/sdk/collectors/process.py +0 -0
  18. {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.26}/sdk/collectors/screenshot.py +0 -0
  19. {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.26}/sdk/config.py +0 -0
  20. {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.26}/sdk/crucial.py +0 -0
  21. {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.26}/sdk/exceptions.py +0 -0
  22. {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.26}/sdk/git_ops.py +0 -0
  23. {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.26}/sdk/graphify.py +0 -0
  24. {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.26}/sdk/hooks/__init__.py +0 -0
  25. {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.26}/sdk/hooks/crucial_guard.py +0 -0
  26. {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.26}/sdk/ipc/__init__.py +0 -0
  27. {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.26}/sdk/ipc/electron_bridge.py +0 -0
  28. {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.26}/sdk/local_exec.py +0 -0
  29. {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.26}/sdk/models/__init__.py +0 -0
  30. {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.26}/sdk/models/envelope.py +0 -0
  31. {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.26}/sdk/models/requests.py +0 -0
  32. {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.26}/sdk/models/responses.py +0 -0
  33. {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.26}/sdk/models/snapshots.py +0 -0
  34. {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.26}/sdk/py.typed +0 -0
  35. {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.26}/sdk/sse.py +0 -0
  36. {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.26}/sdk/test.py +0 -0
  37. {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.26}/sdk/test_pipeline.py +0 -0
  38. {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.26}/sdk/updater.py +0 -0
  39. {devops_bot_sdk-1.4.25 → devops_bot_sdk-1.4.26}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.4.25
3
+ Version: 1.4.26
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.25
3
+ Version: 1.4.26
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.25"
7
+ version = "1.4.26"
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.25
3
+ Version: 1.4.26
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.25"
33
+ __version__ = "1.4.26"
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.25"
39
+ SDK_VERSION = "1.4.26"
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
- _PLAN_DEADLINE_S = 180.0 # planning + approval gate should be quick
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. Move the ticket back to Backlog
788
- # and signal the auto-loop to retry this task after the window resets. ──
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": (("Claude usage limit reached — ticket moved to Backlog"
796
- if moved else "Claude usage limit reached (Backlog move failed)")
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 — moved to Backlog; "
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
- return {"status": "limit_exceeded", "reset_epoch": reset_epoch, "task_id": task_id}
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 so Jira reflects
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, task_payload, send)
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 — skip this ticket.
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}: skipped (no approval gate reached)"},
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: when agentMode is ON, WAIT for the human's
1811
- # WhatsApp approval before ANY local code/bash. When OFF, proceed.
1812
- if agent_mode:
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
- decision = await _poll_approval(client, tid, should_stop)
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 move to Backlog but keep _PENDING_LOCAL
1829
- # alive, then spawn a background watcher that polls until
1830
- # the user approves and resumes execution automatically.
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 Backlog"
1835
- " (background watcher will auto-resume on approval)"},
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
- asyncio.ensure_future(_background_approval_watcher(
1838
- client, session_id, tid, jira_key, thread_id, send, should_stop,
1839
- ))
1840
- break
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
- # Usage limit ticket is now in Backlog. Wait for reset + retry.
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
- if attempt <= _MAX_LIMIT_RETRIES:
1859
- wait_s = _limit_wait_seconds(outcome.get("reset_epoch"))
1860
- await send(Envelope(
1861
- type="step_update", thread_id=thread_id,
1862
- data={"current_step": f"{jira_key}: Claude usage limit — waiting "
1863
- f"{round(wait_s / 60)}m for reset, then retry "
1864
- f"({attempt}/{_MAX_LIMIT_RETRIES})"},
1865
- ).model_dump())
1866
- if not await _cooperative_sleep(wait_s, should_stop):
1867
- await send(Envelope(
1868
- type=Envelope.TYPE_DONE, thread_id=thread_id,
1869
- data={"mode": "orchestrate-auto-local", "status": "paused",
1870
- "processed": processed, "task_count": total},
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 — max retries reached; "
1882
- f"pausing run (resume after the window resets)"},
1883
- ).model_dump())
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
- return
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()