feat(swarm): launch live swarm worker daemon under tmux supervisor
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Slot poller for the swarm worker daemon.
|
||||
|
||||
READ-ONLY component: finds swarm slots waiting for a worker. It never
|
||||
claims slots, never modifies box state, never posts anything.
|
||||
|
||||
Usage:
|
||||
python3 poller.py # prints pending slots as JSON to stdout
|
||||
from poller import find_pending_slots
|
||||
slots = find_pending_slots()
|
||||
|
||||
Each returned dict has keys:
|
||||
swarm_id, slot_index, agent_label, task_text, sidechat_id, created_ts
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# How old (minutes) a running, result-less, unattached slot must be
|
||||
# before we consider it abandoned and re-workable.
|
||||
STALE_MINUTES = 5
|
||||
|
||||
# Resolved once at import: bl-native (`box swarm list`) vs legacy (`box swarm-list`).
|
||||
_BOX_FORM = None # "native" | "legacy"
|
||||
|
||||
|
||||
def _run_box(*args, timeout=60):
|
||||
"""Run a box swarm subcommand, return parsed JSON or None on any failure."""
|
||||
global _BOX_FORM
|
||||
forms = []
|
||||
if _BOX_FORM == "native" or _BOX_FORM is None:
|
||||
forms.append(["box", "swarm", *args, "--json"])
|
||||
if _BOX_FORM == "legacy" or _BOX_FORM is None:
|
||||
# legacy relay style: `box swarm-list`, `box swarm-status <id>`
|
||||
legacy = "-".join(["swarm"] + list(args[:1]))
|
||||
forms.append(["box", legacy, *args[1:]])
|
||||
last_err = None
|
||||
for cmd in forms:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=timeout
|
||||
)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired) as e:
|
||||
last_err = f"{e}"
|
||||
continue
|
||||
if proc.returncode != 0:
|
||||
last_err = f"rc={proc.returncode}: {proc.stderr.strip()[:200]}"
|
||||
# "invalid choice" means wrong form; try the next one.
|
||||
if "invalid choice" in (proc.stderr + proc.stdout):
|
||||
continue
|
||||
print(f"[poller] {' '.join(cmd)} failed: {last_err}",
|
||||
file=sys.stderr)
|
||||
return None
|
||||
try:
|
||||
data = json.loads(proc.stdout)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"[poller] bad JSON from {' '.join(cmd)}: {e}",
|
||||
file=sys.stderr)
|
||||
return None
|
||||
_BOX_FORM = "native" if cmd[1] == "swarm" else "legacy"
|
||||
return data
|
||||
print(f"[poller] all box forms failed: {last_err}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def _age_minutes(ts):
|
||||
"""Minutes since an ISO-8601 timestamp; inf when unparseable/missing."""
|
||||
if not ts:
|
||||
return float("inf")
|
||||
try:
|
||||
dt = datetime.fromisoformat(str(ts).replace("Z", "+00:00"))
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return (datetime.now(timezone.utc) - dt).total_seconds() / 60.0
|
||||
except Exception:
|
||||
return float("inf")
|
||||
|
||||
|
||||
def _slot_attached(slot):
|
||||
"""True when some worker identity is recorded on the slot."""
|
||||
return bool(slot.get("subagent_session_id") or slot.get("claimed_by"))
|
||||
|
||||
|
||||
def find_pending_slots(stale_minutes=STALE_MINUTES):
|
||||
"""Return slots waiting for a worker. Read-only; claims nothing.
|
||||
|
||||
A slot qualifies when:
|
||||
- its status is "pending", or it has no agent assigned, OR
|
||||
- it is "running" with result null, older than `stale_minutes`,
|
||||
and no worker identity is attached.
|
||||
"""
|
||||
found = []
|
||||
data = _run_box("list")
|
||||
if not data:
|
||||
return found
|
||||
swarms = data.get("swarms", []) if isinstance(data, dict) else []
|
||||
for summary in swarms:
|
||||
if (summary.get("status") or "").lower() != "running":
|
||||
continue
|
||||
swarm_id = summary.get("swarm_id")
|
||||
if not swarm_id:
|
||||
continue
|
||||
detail = _run_box("status", swarm_id)
|
||||
if not detail:
|
||||
continue
|
||||
swarm = detail.get("swarm", {}) if isinstance(detail, dict) else {}
|
||||
task_text = swarm.get("task") or summary.get("task_preview") or ""
|
||||
created_ts = swarm.get("created_ts") or summary.get("created_ts")
|
||||
for slot in swarm.get("slots", []):
|
||||
status = (slot.get("status") or "").lower()
|
||||
result = slot.get("result")
|
||||
agent = slot.get("agent_id")
|
||||
pending = (status == "pending") or (not agent)
|
||||
stale_running = (
|
||||
status == "running"
|
||||
and result is None
|
||||
and _age_minutes(slot.get("updated_ts")) > stale_minutes
|
||||
and not _slot_attached(slot)
|
||||
)
|
||||
if pending or stale_running:
|
||||
found.append({
|
||||
"swarm_id": swarm_id,
|
||||
"slot_index": slot.get("slot"),
|
||||
"agent_label": agent,
|
||||
"task_text": task_text,
|
||||
"sidechat_id": slot.get("sidechat_id")
|
||||
or slot.get("thread_uuid"),
|
||||
"created_ts": created_ts,
|
||||
})
|
||||
return found
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
slots = find_pending_slots()
|
||||
print(json.dumps(slots, indent=1))
|
||||
print(f"[poller] {len(slots)} pending slot(s) found", file=sys.stderr)
|
||||
Reference in New Issue
Block a user