feat(subagent): implement subagent lifecycle tracking and core loop reactive wakeup
- Implement bin/subagent_tracker.py with session registry in subagent-sessions.json - Register spawned sessions automatically in super-cli.py cmd_deploy - Add check_subagents() to bin/self_main_loop.py with thread updated watermark comparison - Add 15-minute stall watchdog with automated parent alerting - Support 'box subagent spawn' top-level command alias in super-cli.py - Add symmetrical pip-opm sidechat routing in job-sidechats.json
This commit is contained in:
+89
-2
@@ -378,6 +378,85 @@ def compose_dual_digest(agent, main_new, sc_activity):
|
||||
return "\n".join(lines)[:DIGEST_MAX]
|
||||
|
||||
|
||||
def check_subagents(agent, cfg, threads_meta):
|
||||
"""Inspect active child subagents spawned by agent.
|
||||
|
||||
Returns list of alert strings to deliver to the parent's prompting sidechat:
|
||||
- New output from subagent: prompts parent with snippet and thread view command.
|
||||
- 15-minute stall: warns parent that subagent has had no activity.
|
||||
"""
|
||||
alerts = []
|
||||
try:
|
||||
import subagent_tracker
|
||||
import muse_hybrid
|
||||
active = subagent_tracker.get_active_sessions(parent=agent)
|
||||
if not active or not threads_meta:
|
||||
return alerts
|
||||
|
||||
meta_by_id = {t.get("session_id"): t for t in threads_meta if isinstance(t, dict)}
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
for s in active:
|
||||
sid = s.get("session_id")
|
||||
title = s.get("title") or "subagent"
|
||||
last_wm = s.get("last_watermark")
|
||||
t_meta = meta_by_id.get(sid)
|
||||
|
||||
if not t_meta:
|
||||
continue
|
||||
|
||||
current_updated = t_meta.get("updated")
|
||||
# 1. New output from subagent
|
||||
if current_updated and current_updated != last_wm:
|
||||
if last_wm is None:
|
||||
# Seed initial watermark so we only trigger on subsequent turns
|
||||
subagent_tracker.update_session(sid, last_watermark=current_updated, last_activity_at=utcnow())
|
||||
continue
|
||||
|
||||
history, _ = muse_hybrid.get_history(agent, thread_id=sid, limit=2)
|
||||
snippet = ""
|
||||
if history:
|
||||
for m in reversed(history):
|
||||
if m.get("role") == "assistant":
|
||||
snippet = m.get("text", "").strip()[:180]
|
||||
break
|
||||
|
||||
subagent_tracker.update_session(
|
||||
sid,
|
||||
last_watermark=current_updated,
|
||||
last_activity_at=utcnow(),
|
||||
timeout_warned=False
|
||||
)
|
||||
|
||||
short_id = sid[:8]
|
||||
alert_text = f"[SUBAGENT-UPDATE] Subagent '{title}' ({short_id}) has posted new output."
|
||||
if snippet:
|
||||
alert_text += f"\nSnippet: {snippet}..."
|
||||
alert_text += f"\nRun `box thread view {sid}` to review and aggregate deliverables."
|
||||
alerts.append(alert_text)
|
||||
|
||||
# 2. 15-minute stall watchdog
|
||||
elif not s.get("timeout_warned"):
|
||||
last_act = s.get("last_activity_at") or s.get("spawned_at")
|
||||
if last_act:
|
||||
try:
|
||||
dt = datetime.fromisoformat(last_act.replace("Z", "+00:00"))
|
||||
idle_sec = (now - dt).total_seconds()
|
||||
if idle_sec >= 900: # 15 minutes
|
||||
subagent_tracker.update_session(sid, timeout_warned=True)
|
||||
short_id = sid[:8]
|
||||
alerts.append(
|
||||
f"[WARN] Subagent '{title}' ({short_id}) has had no activity for 15+ minutes. "
|
||||
f"Check status with `box thread view {sid}` or consider respawning."
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
log("%s: subagent check error: %s" % (agent, e))
|
||||
|
||||
return alerts
|
||||
|
||||
|
||||
def do_check(only_agent=None):
|
||||
st = load_state()
|
||||
cfg = get_config(st)
|
||||
@@ -417,14 +496,22 @@ def do_check(only_agent=None):
|
||||
threads_meta, _ = muse_hybrid.get_threads(agent)
|
||||
sc_activity = check_sidechats(agent, cfg, wm, threads_meta)
|
||||
|
||||
# 3. Subagent reactive monitoring check
|
||||
subagent_alerts = check_subagents(agent, cfg, threads_meta)
|
||||
|
||||
agents_state[agent] = wm
|
||||
|
||||
total_new = len(main_new) + sum(len(a["messages"]) for a in sc_activity)
|
||||
total_new = len(main_new) + sum(len(a["messages"]) for a in sc_activity) + len(subagent_alerts)
|
||||
if total_new == 0:
|
||||
results[agent] = {"ok": True, "new": 0}
|
||||
continue
|
||||
|
||||
digest = compose_dual_digest(agent, main_new, sc_activity)
|
||||
parts = []
|
||||
if len(main_new) > 0 or len(sc_activity) > 0:
|
||||
parts.append(compose_dual_digest(agent, main_new, sc_activity))
|
||||
if subagent_alerts:
|
||||
parts.extend(subagent_alerts)
|
||||
digest = "\n\n".join(parts)[:DIGEST_MAX * 2]
|
||||
sidechat = cfg["prompt_sidechat"].get(agent)
|
||||
if not sidechat:
|
||||
log("%s: no prompting sidechat configured, skipping" % agent)
|
||||
|
||||
Reference in New Issue
Block a user