feat(swarm-worker): dispatch prose swarm slots to ephemeral Muse subagents
- daemon: route non-shell slot tasks to a fresh subagent session on dev/def/muse via muse_hybrid; add bin/ to sys.path so muse_hybrid/prompt_envelope import under the tmux supervisor - prompt_envelope: add wrap_subagent_task() - plain task assignment without [TOOL tmux/swarm/cron] meta tags (subagents refused those as relayed test traffic) - box-ctl/reporter: swarm-attach accepts optional session-id, stored as slot.subagent_session_id - executor: _looks_like_shell requires an existing executable (prose like 'verify ...' no longer misread as shell) - poller: pick up pending swarms as well as running - response-harvester: monitor running slot subagent sessions from swarms.json, allow '/' in RESULT/VERB job ids, archive ephemeral threads on any verdict (OK or FAIL), reap slot sessions for terminal swarms
This commit is contained in:
@@ -23,15 +23,33 @@ import sys
|
||||
import time
|
||||
import traceback
|
||||
|
||||
# Sibling modules live in the same directory.
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
# Sibling modules live in swarm_worker, common utilities live in bin/.
|
||||
_SWARM_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
_BIN_DIR = os.path.dirname(_SWARM_DIR)
|
||||
sys.path.insert(0, _SWARM_DIR)
|
||||
sys.path.insert(0, _BIN_DIR)
|
||||
|
||||
from poller import find_pending_slots
|
||||
from executor import execute_task
|
||||
from reporter import post_result
|
||||
from executor import execute_task, _looks_like_shell
|
||||
from reporter import post_result, attach_slot
|
||||
|
||||
# Fast gateway integration
|
||||
try:
|
||||
import muse_hybrid
|
||||
HAS_MUSE_HYBRID = True
|
||||
except ImportError:
|
||||
HAS_MUSE_HYBRID = False
|
||||
|
||||
# Prompt envelope formatting
|
||||
try:
|
||||
import prompt_envelope
|
||||
HAS_PROMPT_ENVELOPE = True
|
||||
except ImportError:
|
||||
HAS_PROMPT_ENVELOPE = False
|
||||
|
||||
POLL_INTERVAL = 60 # seconds between poll cycles
|
||||
STALE_MINUTES = 5 # slots older than this with no attach are workable
|
||||
WORKER_POOL = ["dev", "def", "muse"]
|
||||
|
||||
# === SAFETY SWITCH ===
|
||||
# True -> observe only: log what WOULD be done, execute/post nothing.
|
||||
@@ -46,19 +64,78 @@ logging.basicConfig(
|
||||
log = logging.getLogger("swarm-worker")
|
||||
|
||||
|
||||
def _select_worker(preferred=None):
|
||||
if preferred and HAS_MUSE_HYBRID and muse_hybrid.is_node_configured(preferred):
|
||||
return preferred
|
||||
for candidate in WORKER_POOL:
|
||||
if HAS_MUSE_HYBRID and muse_hybrid.is_node_configured(candidate):
|
||||
return candidate
|
||||
return preferred or "dev"
|
||||
|
||||
|
||||
def process_slot(slot):
|
||||
"""Execute one slot and report the result. Returns True on full success."""
|
||||
swarm_id = slot.get("swarm_id")
|
||||
slot_index = slot.get("slot_index")
|
||||
task_text = slot.get("task_text") or ""
|
||||
agent_label = slot.get("agent_label")
|
||||
sidechat_id = slot.get("sidechat_id")
|
||||
tag = "%s/%s" % (swarm_id, slot_index)
|
||||
|
||||
if DRY_RUN:
|
||||
log.info("[dry-run] would execute slot %s (agent=%s, task %.80r)",
|
||||
tag, slot.get("agent_label"), task_text)
|
||||
tag, agent_label, task_text)
|
||||
return True
|
||||
|
||||
log.info("executing slot %s (agent=%s)", tag, slot.get("agent_label"))
|
||||
# If the task is NOT a shell command, dispatch it to an ephemeral Muse subagent.
|
||||
is_shell = _looks_like_shell(task_text)
|
||||
if not is_shell and HAS_MUSE_HYBRID:
|
||||
worker_agent = _select_worker(agent_label)
|
||||
log.info("dispatching subagent slot %s to %s", tag, worker_agent)
|
||||
try:
|
||||
# 1. Start an ephemeral subagent session
|
||||
title = f"sw-{swarm_id[:16]}-s{slot_index}"
|
||||
sess, err = muse_hybrid.start_session(worker_agent, title=title)
|
||||
if err or not sess or not sess.get("session_id"):
|
||||
log.error("failed to start subagent session for %s on %s: %s", tag, worker_agent, err)
|
||||
return False
|
||||
|
||||
sub_sid = sess["session_id"]
|
||||
log.info("subagent session %s created for slot %s on %s", sub_sid, tag, worker_agent)
|
||||
|
||||
# 2. Attach/claim the slot in box state with subagent session_id
|
||||
attached = attach_slot(swarm_id, slot_index, worker_agent, session_id=sub_sid)
|
||||
if not attached:
|
||||
log.warning("failed to attach slot %s to %s; proceeding with dispatch", tag, worker_agent)
|
||||
|
||||
# 3. Format prompt with authentic Operator Directive and RESULT expectation
|
||||
if HAS_PROMPT_ENVELOPE and hasattr(prompt_envelope, "wrap_subagent_task"):
|
||||
prompt_body = prompt_envelope.wrap_subagent_task(tag, task_text)
|
||||
else:
|
||||
prompt_body = (
|
||||
f"Operator assignment for swarm slot {tag}:\n\n"
|
||||
f"Task:\n{task_text.strip()}\n\n"
|
||||
f"Instructions:\n"
|
||||
f"1. Carry out this task directly using your available tools.\n"
|
||||
f"2. When finished, conclude your final response with your verdict line:\n"
|
||||
f"[RESULT {tag}] OK: <one-line summary of actions and outcome>\n"
|
||||
f"(or [RESULT {tag}] FAIL: <reason> if the task could not be completed)\n"
|
||||
)
|
||||
|
||||
# 4. Asynchronously send message to subagent session
|
||||
res, send_err = muse_hybrid.send_message(worker_agent, prompt_body, thread_id=sub_sid, wait=0)
|
||||
if send_err:
|
||||
log.error("failed to send task to subagent %s on %s: %s", sub_sid, worker_agent, send_err)
|
||||
return False
|
||||
|
||||
log.info("slot %s successfully dispatched to subagent %s (harvester will harvest)", tag, sub_sid)
|
||||
return True
|
||||
except Exception:
|
||||
log.error("subagent dispatch crashed on slot %s:\n%s", tag, traceback.format_exc())
|
||||
return False
|
||||
|
||||
# Otherwise fallback to sandboxed host execution
|
||||
log.info("executing slot %s in sandbox (agent=%s)", tag, agent_label)
|
||||
try:
|
||||
result = execute_task(task_text)
|
||||
except Exception:
|
||||
|
||||
@@ -77,11 +77,19 @@ def _looks_like_shell(task_text):
|
||||
return False
|
||||
if _PROSE_RE.search(t):
|
||||
return False
|
||||
# Must start with a word-ish token (not a quote or sentence).
|
||||
# Must start with a plausible executable command or path
|
||||
first = t.split()[0] if t.split() else ""
|
||||
if not re.match(r"^[a-zA-Z0-9_.\-/]+$", first):
|
||||
return False
|
||||
return True
|
||||
# If it's a relative/absolute path, verify it exists and is executable
|
||||
if "/" in first:
|
||||
return os.path.isfile(first) and os.access(first, os.X_OK)
|
||||
# If it's a bare command name, it must exist in standard system bin paths
|
||||
for p in ("/bin", "/usr/bin", "/usr/local/bin"):
|
||||
candidate = os.path.join(p, first)
|
||||
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _refused(task_text):
|
||||
|
||||
@@ -97,7 +97,8 @@ def find_pending_slots(stale_minutes=STALE_MINUTES):
|
||||
return found
|
||||
swarms = data.get("swarms", []) if isinstance(data, dict) else []
|
||||
for summary in swarms:
|
||||
if (summary.get("status") or "").lower() != "running":
|
||||
summary_status = (summary.get("status") or "").lower()
|
||||
if summary_status not in ("running", "pending"):
|
||||
continue
|
||||
swarm_id = summary.get("swarm_id")
|
||||
if not swarm_id:
|
||||
|
||||
@@ -97,6 +97,60 @@ def post_result(swarm_id, slot_index, result_dict, dry_run=False):
|
||||
swarm_id, slot_index, str(resp)[:500])
|
||||
return False
|
||||
|
||||
def attach_slot(swarm_id, slot_index, agent_id, session_id=None, dry_run=False):
|
||||
"""Claim/attach a swarm slot to an agent in box state.
|
||||
|
||||
Args:
|
||||
swarm_id: e.g. "sw-20261005-151307-a222"
|
||||
slot_index: int slot number
|
||||
agent_id: agent string, e.g. "dev"
|
||||
session_id: optional subagent session ID string
|
||||
dry_run: if True, simulate attach without changing state.
|
||||
|
||||
Returns:
|
||||
True on success, False on failure.
|
||||
"""
|
||||
if dry_run:
|
||||
print("DRY-RUN would run: %s swarm-attach %s %s %s %s"
|
||||
% (BOX_CTL, swarm_id, slot_index, agent_id, session_id or ""))
|
||||
return True
|
||||
|
||||
cmd = [
|
||||
sys.executable, BOX_CTL, "swarm-attach",
|
||||
str(swarm_id), str(slot_index), str(agent_id),
|
||||
]
|
||||
if session_id:
|
||||
cmd.append(str(session_id))
|
||||
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
timeout=30,
|
||||
)
|
||||
except Exception as e:
|
||||
log.error("attach_slot %s/%s failed to invoke box-ctl: %s",
|
||||
swarm_id, slot_index, e)
|
||||
return False
|
||||
|
||||
if proc.returncode != 0:
|
||||
err = proc.stderr.decode("utf-8", "replace")[:500]
|
||||
log.error("attach_slot %s/%s box-ctl rc=%d: %s",
|
||||
swarm_id, slot_index, proc.returncode, err)
|
||||
return False
|
||||
|
||||
try:
|
||||
resp = json.loads(proc.stdout.decode("utf-8", "replace"))
|
||||
except ValueError:
|
||||
log.error("attach_slot %s/%s: box-ctl returned non-JSON output",
|
||||
swarm_id, slot_index)
|
||||
return False
|
||||
|
||||
if not resp.get("ok"):
|
||||
log.error("attach_slot %s/%s: box-ctl ok=false: %s",
|
||||
swarm_id, slot_index, str(resp)[:500])
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user