fix(#56): surface why a swarm run produces nothing (no silent black box)
#56: a created run only seeds the task; if no expert agent connects, the run hangs at `running` forever with no `agent.*` events and an empty /result — and the cause (P-guard already detects NO_AGENTS_CONNECTED) was only in run.metadata["health"] / /diagnostics, never on the surfaces HM/cockpit actually poll (/events, /result). This makes a stuck run explain itself (no new Manager event type, no contract change): - launch_swarm_agents now records run.metadata["agent_launch"] {backend, planned, launched, launched_ids, model_key_resolved, note}. The note pinpoints WHY there are 0 agents — e.g. AGENT_LAUNCH_BACKEND=none (no auto-launch), k8s launch failed (kubectl/RBAC + Pod Workload Identity, #16/#60 A.3), or the model key didn't resolve. No secret recorded — only a model_key_resolved bool. - /result and /diagnostics now carry `health` (P-guard blockers) + `agent_launch`. - assess_swarm_health emits ONE `timeline.updated` per distinct blocker-set (registered event; dedup by summary, reset when healthy) so /events and the cockpit (#39) show "swarm blocked: no_agents_connected" instead of silence. Still NO unregistered swarm.health event. Scope: this surfaces the diagnosis. Actually executing a run still requires the deployment to set AGENT_LAUNCH_BACKEND=kubernetes AND the #16/#60 A.3 Pod Workload Identity / KV grant (infra, cross-team) — called out in the launch note. Tests (scripts/test-swarm-guard.py): blocked run emits timeline.updated exactly once (dedup) + still no swarm.health; /result + /diagnostics carry health w/ no_agents_connected; launch_swarm_agents records the backend=none note. test-contract-freeze + test-agent-launcher still green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
9852df8cb4
commit
9aadc802df
+77
-7
@@ -467,16 +467,27 @@ async def assess_swarm_health(run, *, connected_agent_ids=None):
|
||||
"connected_agent_caps": caps,
|
||||
"budget_state": budget_state,
|
||||
})
|
||||
# INTERNAL state only. We deliberately do NOT route this through swarm_runtime.emit_event:
|
||||
# `swarm.health` is not a registered Manager event, and emit_event can forward to a
|
||||
# subscribe-all Manager callback (agent_callback.go). Stored on the run (+ a bounded log) so an
|
||||
# operator/Manager can pull it; registering a Manager-facing health event is a separate contract
|
||||
# change (event-schema.md).
|
||||
# Stored on the run (+ a bounded log) so an operator/Manager can pull it via /diagnostics and
|
||||
# /result. We deliberately do NOT introduce an unregistered `swarm.health` Manager event; instead
|
||||
# the stall is surfaced ONCE per distinct blocker-set on the registered `timeline.updated` event
|
||||
# (below), so a consumer watching /events and the cockpit (#39) sees *why* a run is stuck instead
|
||||
# of silence — addressing agent_swarm#56.
|
||||
report_dict = report.to_dict()
|
||||
run.metadata["health"] = report_dict
|
||||
if not report.healthy:
|
||||
run.metadata["health_log"] = (run.metadata.get("health_log", [])[-49:] + [report_dict])
|
||||
# Dedup by the report summary: emit on the first tick a (new) blocker-set appears; reset when the
|
||||
# run becomes healthy again so a later re-block re-notifies. No raw content, only blocker reasons.
|
||||
notify_sig = "" if report.healthy else report.summary
|
||||
do_emit = bool(notify_sig) and run.metadata.get("health_notified_sig") != notify_sig
|
||||
run.metadata["health_notified_sig"] = notify_sig
|
||||
await swarm_runtime.save_run(run)
|
||||
if do_emit:
|
||||
await swarm_runtime.emit_event(run, "timeline.updated", payload={
|
||||
"summary": report.summary,
|
||||
"title": report.summary,
|
||||
"blockers": [b["reason"] for b in report.blockers],
|
||||
})
|
||||
return report
|
||||
|
||||
|
||||
@@ -1641,6 +1652,34 @@ async def finalize_parent_after_child(run, child_task, agent_id: str, success: b
|
||||
)
|
||||
|
||||
|
||||
def _launch_note(backend: str, planned: int, launched: int, model_key_resolved: bool) -> str:
|
||||
"""Human-readable explanation of a launch outcome (agent_swarm#56).
|
||||
|
||||
Recorded on the run + surfaced in /result and /diagnostics so an operator immediately sees WHY
|
||||
a run has no expert agents (the #56 black box), instead of a silent run stuck at `running`.
|
||||
"""
|
||||
if backend == "none":
|
||||
return ("AGENT_LAUNCH_BACKEND=none — the runtime did NOT auto-launch agents. Either supply "
|
||||
"agents externally, or set AGENT_LAUNCH_BACKEND=kubernetes (prod) / subprocess (dev). "
|
||||
"With no agent connected the seeded task is never claimed (see health.blockers).")
|
||||
if planned == 0:
|
||||
return ("0 agents planned — the per-user cap (MAX_AGENTS_PER_USER) is already met by connected "
|
||||
"agents, or AGENT_LAUNCH_POOL_SIZE is 0.")
|
||||
if launched == 0:
|
||||
note = (f"backend={backend}: planned {planned} but launched 0 — the launch backend failed "
|
||||
f"(see orchestrator logs; for kubernetes verify kubectl/RBAC + Pod Workload Identity, "
|
||||
f"agent_swarm#16 / #60 A.3).")
|
||||
if not model_key_resolved:
|
||||
note += (" Also: model key did NOT resolve from billing_context.secret_ref — launched "
|
||||
"agents would start keyless (#60 A.3).")
|
||||
return note
|
||||
note = f"backend={backend}: launched {launched}/{planned} agent(s)."
|
||||
if not model_key_resolved:
|
||||
note += (" WARNING: model key did NOT resolve from billing_context.secret_ref — agents start "
|
||||
"keyless and will error on model calls (#60 A.3).")
|
||||
return note
|
||||
|
||||
|
||||
async def launch_swarm_agents(run, body: Dict[str, Any]) -> None:
|
||||
"""Swarm-owned agent launch (agent_swarm#16): launch the per-user expert pool for this run,
|
||||
capped at MAX_AGENTS_PER_USER, with the model key resolved server-side from
|
||||
@@ -1648,27 +1687,49 @@ async def launch_swarm_agents(run, body: Dict[str, Any]) -> None:
|
||||
|
||||
Manager-provided explicit agent breakdowns are honored as-is (those agents are provisioned by
|
||||
the caller), so we only auto-launch the pool for the decentralized seed flow.
|
||||
|
||||
Records the launch outcome on run.metadata["agent_launch"] (backend / planned / launched /
|
||||
model_key_resolved / note) — surfaced in /result + /diagnostics so a run with 0 expert agents
|
||||
explains itself instead of hanging silently (agent_swarm#56). The note carries no secret — only
|
||||
a bool for whether the model key resolved.
|
||||
"""
|
||||
if _manager_provided_agents(body):
|
||||
run.metadata["agent_launch"] = {
|
||||
"backend": "manager_provided", "planned": 0, "launched": 0, "launched_ids": [],
|
||||
"note": "Manager provided explicit agents; the runtime did not auto-launch a pool.",
|
||||
}
|
||||
await swarm_runtime.save_run(run)
|
||||
return
|
||||
user_id = ((body.get("metadata") or {}).get("runtime_headers") or {}).get("x_user_id")
|
||||
connected = manager.user_agent_count(user_id) if user_id else 0
|
||||
backend = agent_launcher.launch_backend()
|
||||
model_key = agent_launcher.resolve_model_key(body)
|
||||
info: Dict[str, Any] = {
|
||||
"backend": backend, "planned": 0, "launched": 0, "launched_ids": [],
|
||||
"model_key_resolved": bool(model_key), "note": "",
|
||||
}
|
||||
try:
|
||||
specs = agent_launcher.plan_launch_specs(
|
||||
run, body,
|
||||
connected_user_agents=connected,
|
||||
limit=max_agents_per_user(),
|
||||
pool_size=agent_launcher.desired_pool_size(),
|
||||
model_key=agent_launcher.resolve_model_key(body),
|
||||
model_key=model_key,
|
||||
orchestrator_url=agent_launcher.orchestrator_ws_url(),
|
||||
user_id=user_id,
|
||||
)
|
||||
info["planned"] = len(specs)
|
||||
launched = await agent_launcher.launch(specs, swarm_id=run.swarm_id)
|
||||
info["launched"] = len(launched)
|
||||
info["launched_ids"] = launched
|
||||
if launched:
|
||||
run.metadata["launched_agents"] = launched
|
||||
await swarm_runtime.save_run(run)
|
||||
info["note"] = _launch_note(backend, len(specs), len(launched), bool(model_key))
|
||||
except Exception as exc: # never fail run creation on launch
|
||||
logger.warning("launch_swarm_agents failed for run %s: %s", run.swarm_id, exc)
|
||||
info["note"] = f"launch raised: {exc}"
|
||||
run.metadata["agent_launch"] = info
|
||||
await swarm_runtime.save_run(run)
|
||||
|
||||
|
||||
async def create_swarm_run_from_request(
|
||||
@@ -2031,6 +2092,10 @@ async def build_runtime_diagnostics(run) -> Dict[str, Any]:
|
||||
"attempts": callback_attempts,
|
||||
},
|
||||
"approvals": list(run.approvals.values()),
|
||||
# P-guard health + agent-launch outcome: why a run can't make progress / has no expert agents
|
||||
# (agent_swarm#56). `agent_launch` carries no secret (only a model_key_resolved bool).
|
||||
"health": run.metadata.get("health"),
|
||||
"agent_launch": run.metadata.get("agent_launch"),
|
||||
"error_context": {
|
||||
"task_failures": failures,
|
||||
"callback_failures": [item for item in callback_attempts if item.get("status") == "failed"],
|
||||
@@ -2326,6 +2391,11 @@ async def build_run_result(run) -> Dict[str, Any]:
|
||||
"termination_reason": termination_reason,
|
||||
"deliverable": build_run_deliverable(run, tasks),
|
||||
"artifacts": collect_run_artifacts(run, tasks),
|
||||
# Why a run produced nothing, surfaced on the result itself (agent_swarm#56): the P-guard
|
||||
# health report (e.g. no_agents_connected) + the agent-launch outcome (backend/launched/note).
|
||||
# Both are non-content descriptors; `agent_launch` carries no secret (only a resolved bool).
|
||||
"health": run.metadata.get("health"),
|
||||
"agent_launch": run.metadata.get("agent_launch"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -134,9 +134,67 @@ async def test_wrapper():
|
||||
sr_mod.SwarmRuntime.emit_event = orig_emit
|
||||
|
||||
|
||||
# ---- #56: a stuck run explains itself (events + /result + launch note) ----
|
||||
async def test_surface_56():
|
||||
await redis_client.connect()
|
||||
|
||||
async def _noop_cb(self, *a, **k):
|
||||
return None
|
||||
sr_mod.SwarmRuntime._post_callback = _noop_cb
|
||||
emitted = []
|
||||
orig_emit = sr_mod.SwarmRuntime.emit_event
|
||||
|
||||
async def spy_emit(self, run, event_type, **k):
|
||||
emitted.append(event_type)
|
||||
return await orig_emit(self, run, event_type, **k)
|
||||
sr_mod.SwarmRuntime.emit_event = spy_emit
|
||||
|
||||
body = {"mode": "swarm", "requirement": {"objective": "surface test"},
|
||||
"callback": {"url": "http://127.0.0.1:9/cb", "subscribed_events": []},
|
||||
"metadata": {"manager_deployment_id": "m-surface"}}
|
||||
run, _ = await swarm_runtime.get_or_create_run(body=body, idempotency_key=None, correlation_id="cs")
|
||||
seed = await task_queue.create_task(task_id=f"{run.swarm_id}-seed", description="x",
|
||||
agent_role="general", required_capabilities=[], enqueue=True)
|
||||
await swarm_runtime.attach_task(run, seed.task_id)
|
||||
|
||||
# pending seed + no agents → NO_AGENTS_CONNECTED; first tick emits timeline.updated, second dedups.
|
||||
await orch.assess_swarm_health(run, connected_agent_ids=[])
|
||||
await orch.assess_swarm_health(run, connected_agent_ids=[])
|
||||
check("#56 blocked run emits timeline.updated exactly once (dedup)",
|
||||
emitted.count("timeline.updated") == 1)
|
||||
check("#56 still no unregistered swarm.health event", "swarm.health" not in emitted)
|
||||
|
||||
refreshed = await swarm_runtime.get_run(run.swarm_id)
|
||||
result = await orch.build_run_result(refreshed)
|
||||
health = result.get("health") or {}
|
||||
check("#56 /result carries health (unhealthy)", health.get("healthy") is False)
|
||||
check("#56 /result health names no_agents_connected",
|
||||
any(b["reason"] == guard.Blocker.NO_AGENTS_CONNECTED.value for b in health.get("blockers", [])))
|
||||
diag = await orch.build_runtime_diagnostics(refreshed)
|
||||
check("#56 /diagnostics carries health", bool(diag.get("health")))
|
||||
|
||||
sr_mod.SwarmRuntime.emit_event = orig_emit
|
||||
|
||||
|
||||
async def test_launch_outcome_56():
|
||||
await redis_client.connect()
|
||||
os.environ.pop("AGENT_LAUNCH_BACKEND", None) # default 'none'
|
||||
body = {"mode": "swarm", "requirement": {"objective": "launch note"},
|
||||
"callback": {"url": "http://127.0.0.1:9/cb", "subscribed_events": []},
|
||||
"metadata": {"manager_deployment_id": "m-launch"}}
|
||||
run, _ = await swarm_runtime.get_or_create_run(body=body, idempotency_key=None, correlation_id="cl")
|
||||
await orch.launch_swarm_agents(run, body)
|
||||
info = (await swarm_runtime.get_run(run.swarm_id)).metadata.get("agent_launch") or {}
|
||||
check("#56 agent_launch recorded (backend none)", info.get("backend") == "none")
|
||||
check("#56 agent_launch launched 0", info.get("launched") == 0)
|
||||
check("#56 agent_launch note explains backend=none", "AGENT_LAUNCH_BACKEND=none" in (info.get("note") or ""))
|
||||
|
||||
|
||||
async def main():
|
||||
test_pure()
|
||||
await test_wrapper()
|
||||
await test_surface_56()
|
||||
await test_launch_outcome_56()
|
||||
print()
|
||||
if failures:
|
||||
print(f"{len(failures)} guard check(s) FAILED: {failures}")
|
||||
|
||||
Reference in New Issue
Block a user