2026-10-05 17:29:16 +00:00
|
|
|
#!/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()
|
2026-10-05 19:21:10 +00:00
|
|
|
if status in ("done", "failed", "killed"):
|
|
|
|
|
continue
|
2026-10-05 17:29:16 +00:00
|
|
|
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)
|