mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
80 lines
3.2 KiB
Python
80 lines
3.2 KiB
Python
"""#102: a concurrent mid-verb state change surfaces as a clean INVALID_STATE.
|
|||
|
|
|
||
|
|
The verb runner wraps the composed atomic actions in a savepoint and detects a
|
||
|
|
mid-sequence ``None``: a composed action returns ``None`` when its source-status
|
||
|
|
check fails after a CONCURRENT transition moved the row between the verb's
|
||
|
|
precondition gate and execution (e.g. ``i_will_plan``'s gate saw
|
||
|
|
``needs_revision`` but a racing ``i_am_blocked`` moved it to ``blocked``, so
|
||
|
|
``claim()`` found no valid transition and returned ``None``). The runner raises
|
||
|
|
``INVALID_STATE`` with a re-fetch instruction instead of crashing on
|
||
|
|
``None.id`` — so the choreographer surfaces an actionable rejection and the agent
|
||
|
|
re-fetches + re-issues, never respawn-loops. This pins that protection.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from unittest.mock import AsyncMock, MagicMock
|
||
|
|
from uuid import uuid4
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from roboco.services.gateway.choreographer._verb_runner import VerbRunner
|
||
|
|
|
||
|
|
|
||
|
|
def _runner(task_service: MagicMock) -> VerbRunner:
|
||
|
|
return VerbRunner(task_service=task_service, git_service=MagicMock())
|
||
|
|
|
||
|
|
|
||
|
|
def _session_with_savepoint() -> MagicMock:
|
||
|
|
session = MagicMock()
|
||
|
|
session.begin_nested = MagicMock(
|
||
|
|
return_value=MagicMock(
|
||
|
|
__aenter__=AsyncMock(return_value=None),
|
||
|
|
__aexit__=AsyncMock(return_value=False),
|
||
|
|
)
|
||
|
|
)
|
||
|
|
return session
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_mid_verb_none_raises_invalid_state() -> None:
|
||
|
|
"""i_will_plan composes (claim, set_plan, start). If a concurrent
|
||
|
|
transition makes ``set_plan`` return None mid-sequence, the runner raises
|
||
|
|
INVALID_STATE before dispatching ``start`` — not a cryptic None.id crash."""
|
||
|
|
task = MagicMock(id=uuid4())
|
||
|
|
task_service = MagicMock()
|
||
|
|
task_service.session = _session_with_savepoint()
|
||
|
|
task_service.claim = AsyncMock(return_value=task)
|
||
|
|
task_service.set_plan = AsyncMock(return_value=None) # mid-verb source-status fail
|
||
|
|
task_service.start = AsyncMock(return_value=task)
|
||
|
|
|
||
|
|
agent = MagicMock(id=uuid4())
|
||
|
|
ctx = MagicMock()
|
||
|
|
runner = _runner(task_service)
|
||
|
|
|
||
|
|
with pytest.raises(ValueError, match="INVALID_STATE"):
|
||
|
|
await runner.run_intent("i_will_plan", task, agent, ctx)
|
||
|
|
|
||
|
|
# start must NOT run after the None — the runner fails loud before it.
|
||
|
|
task_service.start.assert_not_awaited()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_last_composed_none_flows_out_as_result() -> None:
|
||
|
|
"""A None from the LAST composed action is the verb's own result (e.g. a
|
||
|
|
board verb's decline), not a mid-verb crash — it flows out so the caller's
|
||
|
|
``if task is None`` handler surfaces the verb-specific message. The
|
||
|
|
INVALID_STATE guard fires only on an INTERMEDIATE None."""
|
||
|
|
task = MagicMock(id=uuid4())
|
||
|
|
task_service = MagicMock()
|
||
|
|
task_service.session = _session_with_savepoint()
|
||
|
|
task_service.claim = AsyncMock(return_value=task)
|
||
|
|
task_service.set_plan = AsyncMock(return_value=task)
|
||
|
|
task_service.start = AsyncMock(return_value=None) # last action declines
|
||
|
|
|
||
|
|
agent = MagicMock(id=uuid4())
|
||
|
|
ctx = MagicMock()
|
||
|
|
runner = _runner(task_service)
|
||
|
|
|
||
|
|
result = await runner.run_intent("i_will_plan", task, agent, ctx)
|
||
|
|
assert result is None # the verb's own None result, not a raised INVALID_STATE
|