mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
fix(gateway): working exits for wedged agents + declare_coverage roll-up unblock (#341)
A live task burned 5+ hours because every exit was locked. unclaim now works from verifying and needs_revision (service guard + lifecycle edge); the circuit breaker and the i_am_done push-failure remediate name the working chain ending in unclaim(); sync_branch(stash=true) clears the DIRTY_WORKSPACE dead-end (pop-conflict preserves the stash); blocking a task QA already owns now says to idle instead of listing states; the orchestrator auto-block logs real errors and skips states where blocking is meaningless instead of force-blocking them. declare_coverage (cell/main PM) retroactively stamps parent-AC refs on a child that implements them -- closing the roll-up deadlock where the declaring child was cancelled and its re-delegated replacement completed the work uncredited. Cancelling a ref-declaring child now warns and surfaces the orphaned criteria. Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
"""`_auto_block_task` must be state-aware and never log an empty error.
|
||||
|
||||
Live incident: the orchestrator logged `{"error": "", "event": "Failed to
|
||||
auto-block task"}` for a task whose owning container had died mid-
|
||||
awaiting_qa — an empty error string with no diagnostic value, from a PATCH
|
||||
attempting to force a task QA already owns back to "blocked". This pins:
|
||||
|
||||
- a task already past dev control (awaiting_qa, terminal, ...) is skipped
|
||||
with an info log, no PATCH attempted
|
||||
- a still-blockable task (pending) proceeds to the PATCH as before
|
||||
- a PATCH failure logs a non-empty error even for exception types whose
|
||||
str() is empty (e.g. a bare TimeoutError)
|
||||
- a failed status pre-check does not swallow the block attempt itself
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from roboco.runtime.orchestrator import AgentOrchestrator
|
||||
|
||||
|
||||
def _make_orch() -> AgentOrchestrator:
|
||||
return AgentOrchestrator.__new__(AgentOrchestrator)
|
||||
|
||||
|
||||
def _resp(status_code_ok: bool, payload: dict[str, Any]) -> MagicMock:
|
||||
r = MagicMock()
|
||||
r.is_success = status_code_ok
|
||||
r.json.return_value = payload
|
||||
return r
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_block_skips_task_already_in_awaiting_qa() -> None:
|
||||
orch = _make_orch()
|
||||
client: Any = AsyncMock()
|
||||
client.get.return_value = _resp(True, {"status": "awaiting_qa"})
|
||||
|
||||
await orch._auto_block_task(client, "tid-1", "container died")
|
||||
|
||||
client.patch.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_block_skips_completed_task() -> None:
|
||||
orch = _make_orch()
|
||||
client: Any = AsyncMock()
|
||||
client.get.return_value = _resp(True, {"status": "completed"})
|
||||
|
||||
await orch._auto_block_task(client, "tid-2", "stale readiness check")
|
||||
|
||||
client.patch.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_block_proceeds_for_pending_task() -> None:
|
||||
"""The main existing use case (stuck pending tasks) must be unaffected."""
|
||||
orch = _make_orch()
|
||||
client: Any = AsyncMock()
|
||||
client.get.return_value = _resp(True, {"status": "pending"})
|
||||
|
||||
await orch._auto_block_task(client, "tid-3", "needs a project_id")
|
||||
|
||||
client.patch.assert_awaited_once()
|
||||
args, kwargs = client.patch.await_args
|
||||
assert "tid-3" in args[0]
|
||||
assert kwargs["json"]["status"] == "blocked"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_block_proceeds_when_status_precheck_fails() -> None:
|
||||
"""A GET failure must not swallow the block attempt — fall through."""
|
||||
orch = _make_orch()
|
||||
client: Any = AsyncMock()
|
||||
client.get.side_effect = RuntimeError("network down")
|
||||
|
||||
await orch._auto_block_task(client, "tid-4", "some reason")
|
||||
|
||||
client.patch.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_block_logs_nonempty_error_for_blank_exception() -> None:
|
||||
"""str(TimeoutError()) is '' — the log must still carry a real message."""
|
||||
assert str(TimeoutError()) == "" # the exact gotcha this guards against
|
||||
orch = _make_orch()
|
||||
client: Any = AsyncMock()
|
||||
client.get.return_value = _resp(True, {"status": "pending"})
|
||||
client.patch.side_effect = TimeoutError()
|
||||
|
||||
with patch("roboco.runtime.orchestrator.logger") as mock_logger:
|
||||
await orch._auto_block_task(client, "tid-5", "some reason")
|
||||
|
||||
mock_logger.error.assert_called_once()
|
||||
_, kwargs = mock_logger.error.call_args
|
||||
assert kwargs["error"], "error field must never be blank"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-q"])
|
||||
Reference in New Issue
Block a user