Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion docs/reference/protocols/agent-management-projection-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,8 @@ Required fields:
- `agent_id`;
- `agent_model`: `peer_v1`;
- `state`: one of `running`, `waiting`, `blocked`, `monitoring`,
`scope_wait`, `stale`, or `unknown`;
`scope_wait`, `stale`, `unknown`, `registered`, `addressable`, `bound`,
`launchable`, `executing`;
- `current_todo`: a `todo_row_v0` object or `null`;
- `next_action`: compact local-control next action text. Private project refs
are allowed; inline credentials are not. Shareable sinks must redact private
Expand All @@ -111,6 +112,9 @@ Optional fields:
- `handoff_refs`;
- `handoff_note`;
- `material_frontier`;
- `session_binding`: optional `{thread_id, host_surface}` from existing
`run_history.goals[].coordination.thread_agent_bindings`; absence is not
evidence of a stopped host, and a binding does not prove executable capacity;
- `stale_claim_hint`;
- `blocked_on`;
- `recent_events`;
Expand All @@ -122,6 +126,42 @@ highest-priority blocked maintenance todo as a separate `blocked_on`
`todo_row_v0`. The blocker remains visible without changing todo ownership or
making the whole peer appear blocked.

### Worker state refinement and compatibility

The current producer emits `registered`, `addressable`, `bound`, `launchable`,
`executing`, `blocked`, `monitoring`, and `waiting`.
`running`, `unknown`, `scope_wait`, and `stale` remain
accepted legacy vocabulary; this producer does not emit them. There is no
parallel `lifecycle_state` field.

This changes the default read projection: registered idle rows previously
reported `unknown` or `waiting`, and open advancement work reported `running`.
The new states refine those observations without adding dispatch authority:

| State | Observed facts |
| --- | --- |
| `registered` | Registered row without current work or session binding |
| `addressable` | Session binding exists, with no current work |
| `bound` | Current open work and a session binding; no recent work update |
| `launchable` | Current open work without a binding or recent work update |
| `executing` | Current open work updated between zero and eight hours ago |
| `blocked` | Current work is blocked or has the blocker task class |
| `monitoring` | Current work is a monitor, regardless of activity age |
| `waiting` | Other non-open current work, such as deferred work |

Blocked, monitor and waiting classification precedes activity/binding refinement.
The eight-hour activity window is inclusive, rejects future timestamps, and
uses the current Todo only; it is independent of the 36-hour stale-claim
warning. `last_activity_at` still summarizes the displayed Todos. Neither an
`executing` observation nor `launchable` proves a live process, configured
runtime, available capacity, lease, or permission to start a worker.

Registered-peer orchestration accepts `executing`, `bound`, and `launchable`
where it accepted legacy `running`, and continues to accept `monitoring` and
cached `running` rows. Its existing stale-claim, observed activation-capability,
and dependency-readiness gates still apply. Idle, blocked, waiting and unknown
rows do not gain admission. Other consumers must tolerate the added strings.

## Todo Row

`todo_row_v0` is the dashboard/review-packet representation of an existing
Expand Down
12 changes: 6 additions & 6 deletions examples/control_plane/agent-management-live-status-smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,11 +254,11 @@ def assert_event_only_todo_receipts_are_not_runtime_work() -> None:
if isinstance(row, dict)
}
assert by_agent["agent-reviewer"]["current_todo"]["todo_id"] == "todo_live_handoff", by_agent
assert by_agent["agent-event-only"]["state"] == "unknown", by_agent
assert by_agent["agent-event-only"]["state"] == "registered", by_agent
assert "current_todo" not in by_agent["agent-event-only"], by_agent


def assert_advancement_current_todo_beats_standing_monitor() -> None:
def assert_launchable_advancement_beats_standing_monitor() -> None:
payload = fixture_status_payload()
payload["run_history"]["goals"][0]["coordination"]["registered_agents"].append("agent-side")
payload["attention_queue"]["items"].append(
Expand Down Expand Up @@ -342,7 +342,7 @@ def assert_advancement_current_todo_beats_standing_monitor() -> None:
if isinstance(row, dict)
}
side_row = by_agent["agent-side"]
assert side_row["state"] == "running", side_row
assert side_row["state"] == "launchable", side_row
assert side_row["current_todo"]["todo_id"] == "todo_side_advancement", side_row
assert side_row["next_action"] == "Continue projected todo todo_side_advancement.", side_row

Expand All @@ -369,7 +369,7 @@ def assert_advancement_current_todo_beats_standing_monitor() -> None:
for row in stale_blocker.get("agents", [])
if isinstance(row, dict) and row.get("agent_id") == "agent-side"
)
assert stale_blocker_row["state"] == "running", stale_blocker_row
assert stale_blocker_row["state"] == "launchable", stale_blocker_row
assert stale_blocker_row["current_todo"]["todo_id"] == "todo_side_advancement", stale_blocker_row
assert stale_blocker_row["blocked_on"]["todo_id"] == "todo_stale_maintenance_blocker", stale_blocker_row

Expand All @@ -380,7 +380,7 @@ def assert_advancement_current_todo_beats_standing_monitor() -> None:
for row in lane_only.get("agents", [])
if isinstance(row, dict) and row.get("agent_id") == "agent-side"
)
assert lane_only_row["state"] == "running", lane_only_row
assert lane_only_row["state"] == "launchable", lane_only_row
assert lane_only_row["current_todo"]["todo_id"] == "todo_side_advancement", lane_only_row

payload["attention_queue"]["items"][-1]["project_asset"]["agent_todos"][
Expand Down Expand Up @@ -508,7 +508,7 @@ def main() -> int:
args = parse_args()
assert_synthetic_projection()
assert_event_only_todo_receipts_are_not_runtime_work()
assert_advancement_current_todo_beats_standing_monitor()
assert_launchable_advancement_beats_standing_monitor()
result: dict[str, Any] = {
"synthetic": "ok",
"bundled_example": assert_bundled_public_example(),
Expand Down
154 changes: 154 additions & 0 deletions examples/worker-lifecycle-state-smoke.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""Smoke test for worker lifecycle state projection.

Verifies that the agent management projection correctly derives lifecycle
states from existing facts (registry, todo, session binding, activity).

Run from the repository root:
uv run --extra test python examples/worker-lifecycle-state-smoke.py
"""

from __future__ import annotations

import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path

# Add the repository root to the path for direct execution
sys.path.insert(0, str(Path(__file__).parent.parent))


def _recent_activity() -> str:
"""Activity timestamp within the activity threshold (8 hours)."""
return (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()

from loopx.control_plane.agents.management_projection import ( # noqa: E402
WORKER_LIFECYCLE_STATE_ADDRESSABLE,
WORKER_LIFECYCLE_STATE_BLOCKED,
WORKER_LIFECYCLE_STATE_BOUND,
WORKER_LIFECYCLE_STATE_EXECUTING,
WORKER_LIFECYCLE_STATE_LAUNCHABLE,
WORKER_LIFECYCLE_STATE_REGISTERED,
build_agent_management_projection,
)


def build_status_payload() -> dict:
"""Build a minimal status payload with known facts."""
return {
"goal_filter": "smoke-goal",
"run_history": {
"goals": [
{
"id": "smoke-goal",
"coordination": {
"registered_agents": [
"worker-registered",
"worker-addressable",
"worker-bound",
"worker-launchable",
"worker-executing",
"worker-blocked",
],
"thread_agent_bindings": [
{
"agent_id": "worker-addressable",
"thread_id": "thread-1",
"host_surface": "codex-app",
},
{
"agent_id": "worker-bound",
"thread_id": "thread-2",
"host_surface": "codex-app",
},
{
"agent_id": "worker-executing",
"thread_id": "thread-3",
"host_surface": "codex-app",
},
],
},
}
]
},
"attention_queue": {
"items": [
{
"goal_id": "smoke-goal",
"agent_todos": {
"items": [
{
"todo_id": "todo-bound",
"claimed_by": "worker-bound",
"status": "open",
"updated_at": "2026-09-15T12:00:00+00:00",
},
{
"todo_id": "todo-launchable",
"claimed_by": "worker-launchable",
"status": "open",
"updated_at": "2026-09-15T12:00:00+00:00",
},
{
"todo_id": "todo-executing",
"claimed_by": "worker-executing",
"status": "open",
"updated_at": _recent_activity(),
},
{
"todo_id": "todo-blocked",
"claimed_by": "worker-blocked",
"status": "blocked",
"task_class": "blocker",
"updated_at": "2026-09-17T12:00:00+00:00",
},
]
},
}
]
},
}


def main() -> int:
payload = build_status_payload()
projection = build_agent_management_projection(payload)

agents = {a["agent_id"]: a for a in projection.get("agents", [])}

# Verify each worker's lifecycle state
expected = {
"worker-registered": WORKER_LIFECYCLE_STATE_REGISTERED,
"worker-addressable": WORKER_LIFECYCLE_STATE_ADDRESSABLE,
"worker-bound": WORKER_LIFECYCLE_STATE_BOUND,
"worker-launchable": WORKER_LIFECYCLE_STATE_LAUNCHABLE,
"worker-executing": WORKER_LIFECYCLE_STATE_EXECUTING,
"worker-blocked": WORKER_LIFECYCLE_STATE_BLOCKED,
}

failures = []
for agent_id, expected_state in expected.items():
agent = agents.get(agent_id)
if agent is None:
failures.append(f"{agent_id}: missing from projection")
continue
actual_state = agent.get("state")
if actual_state != expected_state:
failures.append(
f"{agent_id}: expected {expected_state}, got {actual_state}"
)
else:
print(f" {agent_id}: {actual_state} ✓")

if failures:
print("\nFAILURES:")
for failure in failures:
print(f" {failure}")
return 1

print(f"\nAll {len(expected)} lifecycle states verified.")
return 0


if __name__ == "__main__":
raise SystemExit(main())
Loading
Loading