feat: GET /agent/status + idempotent POST same-state #22

Merged
hzhang merged 2 commits from feat/get-agent-status into main 2026-05-22 21:59:10 +00:00

View File

@@ -52,6 +52,7 @@ from app.schemas.calendar import (
) )
from app.services.agent_heartbeat import get_pending_slots_for_agent from app.services.agent_heartbeat import get_pending_slots_for_agent
from app.services.agent_status import ( from app.services.agent_status import (
AgentStatusError,
record_heartbeat, record_heartbeat,
transition_to_busy, transition_to_busy,
transition_to_idle, transition_to_idle,
@@ -561,6 +562,29 @@ def agent_update_virtual_slot(
return TimeSlotEditResponse(slot=_slot_to_response(slot), warnings=[]) return TimeSlotEditResponse(slot=_slot_to_response(slot), warnings=[])
@router.get(
"/agent/status",
summary="Read an agent's current runtime status (no side effects)",
)
def get_agent_status(
agent_id: str = Query(..., description="Target agent_id"),
x_claw_identifier: str = Header(..., alias="X-Claw-Identifier"),
db: Session = Depends(get_db),
):
"""Return `{agent_id, status}` so callers (Fabric.OpenclawPlugin's
triage on-call gate, etc.) can decide whether the agent is currently
eligible without flipping their state.
No-op for unknown agents — returns 404 with `{detail: 'Agent not
found'}` so the caller can decide whether to fail-open or fail-closed.
"""
agent = _require_agent(db, agent_id, x_claw_identifier)
return {
"agent_id": agent.agent_id,
"status": agent.status.value if hasattr(agent.status, 'value') else str(agent.status),
}
@router.post( @router.post(
"/agent/status", "/agent/status",
summary="Update agent runtime status from plugin", summary="Update agent runtime status from plugin",
@@ -571,19 +595,31 @@ def update_agent_status(
): ):
agent = _require_agent(db, payload.agent_id, payload.claw_identifier) agent = _require_agent(db, payload.agent_id, payload.claw_identifier)
target = (payload.status or '').lower().strip() target = (payload.status or '').lower().strip()
if target == AgentStatus.IDLE.value: # Idempotent same-state transition: a 'busy → busy' request is a
transition_to_idle(db, agent) # no-op rather than a 500. Lets plugin status gates / cli `--set`
elif target == AgentStatus.BUSY.value: # be safe to fire-and-forget without first reading current state.
transition_to_busy(db, agent, slot_type=SlotType.WORK) current = agent.status.value if hasattr(agent.status, 'value') else str(agent.status)
elif target == AgentStatus.ON_CALL.value: if current == target:
transition_to_busy(db, agent, slot_type=SlotType.ON_CALL) return {"ok": True, "agent_id": agent.agent_id, "status": current, "no_change": True}
elif target == AgentStatus.OFFLINE.value: try:
transition_to_offline(db, agent) if target == AgentStatus.IDLE.value:
elif target == AgentStatus.EXHAUSTED.value: transition_to_idle(db, agent)
reason = ExhaustReason.BILLING if payload.exhaust_reason == 'billing' else ExhaustReason.RATE_LIMIT elif target == AgentStatus.BUSY.value:
transition_to_exhausted(db, agent, reason=reason, recovery_at=payload.recovery_at) transition_to_busy(db, agent, slot_type=SlotType.WORK)
else: elif target == AgentStatus.ON_CALL.value:
raise HTTPException(status_code=400, detail="Unsupported agent status") transition_to_busy(db, agent, slot_type=SlotType.ON_CALL)
elif target == AgentStatus.OFFLINE.value:
transition_to_offline(db, agent)
elif target == AgentStatus.EXHAUSTED.value:
reason = ExhaustReason.BILLING if payload.exhaust_reason == 'billing' else ExhaustReason.RATE_LIMIT
transition_to_exhausted(db, agent, reason=reason, recovery_at=payload.recovery_at)
else:
raise HTTPException(status_code=400, detail="Unsupported agent status")
except AgentStatusError as e:
# State-machine violation (e.g. busy → busy via wrong precondition)
# → 409 with the rejected transition explained, instead of a 500.
db.rollback()
raise HTTPException(status_code=409, detail=str(e))
db.commit() db.commit()
return {"ok": True, "agent_id": agent.agent_id, "status": agent.status.value if hasattr(agent.status, 'value') else str(agent.status)} return {"ok": True, "agent_id": agent.agent_id, "status": agent.status.value if hasattr(agent.status, 'value') else str(agent.status)}