From 58e7a76e1af2c17198bbb26540bd88e84e0defe2 Mon Sep 17 00:00:00 2001 From: hanghang zhang Date: Fri, 22 May 2026 22:34:10 +0100 Subject: [PATCH 1/2] =?UTF-8?q?feat(calendar):=20GET=20/agent/status=20?= =?UTF-8?q?=E2=80=94=20read-only=20status=20query=20for=20plugin=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously only POST /agent/status existed (for state transitions). Fabric.OpenclawPlugin's triage on-call gate needs to check whether the on-duty agent is currently on_call without flipping their state — so the wake decision is read-only. GET returns {agent_id, status}, 404 if unknown. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/routers/calendar.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/app/api/routers/calendar.py b/app/api/routers/calendar.py index 1d00c98..d9b84f0 100644 --- a/app/api/routers/calendar.py +++ b/app/api/routers/calendar.py @@ -561,6 +561,29 @@ def agent_update_virtual_slot( 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( "/agent/status", summary="Update agent runtime status from plugin", -- 2.49.1 From ba0e0a01f81fee0fde2ec0dca271dd10e640ad9b Mon Sep 17 00:00:00 2001 From: hanghang zhang Date: Fri, 22 May 2026 22:46:30 +0100 Subject: [PATCH 2/2] fix(calendar): /agent/status idempotent + 409 on bad transition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same-state transition was 500 (transition_to_busy asserts current=IDLE). Now: short-circuit identical target → 200 no_change=true. Any other state-machine violation surfaces as 409 with the actual error message instead of generic 500. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/routers/calendar.py | 39 ++++++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/app/api/routers/calendar.py b/app/api/routers/calendar.py index d9b84f0..4d5d837 100644 --- a/app/api/routers/calendar.py +++ b/app/api/routers/calendar.py @@ -52,6 +52,7 @@ from app.schemas.calendar import ( ) from app.services.agent_heartbeat import get_pending_slots_for_agent from app.services.agent_status import ( + AgentStatusError, record_heartbeat, transition_to_busy, transition_to_idle, @@ -594,19 +595,31 @@ def update_agent_status( ): agent = _require_agent(db, payload.agent_id, payload.claw_identifier) target = (payload.status or '').lower().strip() - if target == AgentStatus.IDLE.value: - transition_to_idle(db, agent) - elif target == AgentStatus.BUSY.value: - transition_to_busy(db, agent, slot_type=SlotType.WORK) - elif target == AgentStatus.ON_CALL.value: - 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") + # Idempotent same-state transition: a 'busy → busy' request is a + # no-op rather than a 500. Lets plugin status gates / cli `--set` + # be safe to fire-and-forget without first reading current state. + current = agent.status.value if hasattr(agent.status, 'value') else str(agent.status) + if current == target: + return {"ok": True, "agent_id": agent.agent_id, "status": current, "no_change": True} + try: + if target == AgentStatus.IDLE.value: + transition_to_idle(db, agent) + elif target == AgentStatus.BUSY.value: + transition_to_busy(db, agent, slot_type=SlotType.WORK) + elif target == AgentStatus.ON_CALL.value: + 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() return {"ok": True, "agent_id": agent.agent_id, "status": agent.status.value if hasattr(agent.status, 'value') else str(agent.status)} -- 2.49.1