fix(trigger): defer local triggers until session idle
This commit is contained in:
@@ -730,6 +730,56 @@ async def test_cron_turn_deferred_while_session_active(tmp_path):
|
||||
assert loop.pending_cron_job_ids_for_session(session_key) == set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_trigger_turn_deferred_while_session_active(tmp_path):
|
||||
"""Local trigger turns wait for the active session instead of becoming injections."""
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.triggers.local_session_turns import LOCAL_TRIGGER_META
|
||||
|
||||
loop = _make_loop(tmp_path)
|
||||
loop._dispatch = AsyncMock() # type: ignore[method-assign]
|
||||
|
||||
session_key = "websocket:chat-1"
|
||||
pending = asyncio.Queue(maxsize=20)
|
||||
loop._pending_queues[session_key] = pending
|
||||
|
||||
run_task = asyncio.create_task(loop.run())
|
||||
msg = InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="trigger",
|
||||
chat_id="chat-1",
|
||||
content="review failed CI",
|
||||
metadata={
|
||||
LOCAL_TRIGGER_META: {
|
||||
"trigger_id": "trg_123",
|
||||
"trigger_name": "CI review",
|
||||
"delivery_id": "tdl_123",
|
||||
},
|
||||
},
|
||||
session_key_override=session_key,
|
||||
)
|
||||
await loop.bus.publish_inbound(msg)
|
||||
|
||||
for _ in range(20):
|
||||
if loop._local_trigger_turns.deferred_queues.get(session_key):
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
loop.stop()
|
||||
await asyncio.wait_for(run_task, timeout=2)
|
||||
|
||||
assert pending.empty()
|
||||
assert loop._dispatch.await_count == 0
|
||||
assert loop._local_trigger_turns.deferred_queues[session_key] == [msg]
|
||||
assert loop.pending_local_trigger_ids_for_session(session_key) == {"trg_123"}
|
||||
|
||||
assert await loop._local_trigger_turns.publish_next_deferred(session_key) is True
|
||||
queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5)
|
||||
assert queued is msg
|
||||
assert session_key not in loop._local_trigger_turns.deferred_queues
|
||||
assert loop.pending_local_trigger_ids_for_session(session_key) == set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submitted_cron_turn_reports_pending_until_completed(tmp_path):
|
||||
"""Bound cron jobs remain marked pending while their session turn is in flight."""
|
||||
@@ -766,6 +816,48 @@ async def test_submitted_cron_turn_reports_pending_until_completed(tmp_path):
|
||||
assert loop.pending_cron_job_ids_for_session(session_key) == set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submitted_local_trigger_turn_reports_pending_until_completed(tmp_path):
|
||||
"""Local triggers remain marked pending while their session turn is in flight."""
|
||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||
from nanobot.triggers.local_session_turns import LOCAL_TRIGGER_META
|
||||
|
||||
loop = _make_loop(tmp_path)
|
||||
loop._running = True
|
||||
|
||||
session_key = "websocket:chat-1"
|
||||
msg = InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="trigger",
|
||||
chat_id="chat-1",
|
||||
content="review failed CI",
|
||||
metadata={
|
||||
LOCAL_TRIGGER_META: {
|
||||
"trigger_id": "trg_123",
|
||||
"trigger_name": "CI review",
|
||||
"delivery_id": "tdl_123",
|
||||
},
|
||||
},
|
||||
session_key_override=session_key,
|
||||
)
|
||||
|
||||
submit_task = asyncio.create_task(loop.submit_local_trigger_turn(msg))
|
||||
queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5)
|
||||
|
||||
assert queued is msg
|
||||
assert loop.pending_local_trigger_ids_for_session(session_key) == {"trg_123"}
|
||||
|
||||
response = OutboundMessage(
|
||||
channel="websocket",
|
||||
chat_id="chat-1",
|
||||
content="done",
|
||||
)
|
||||
loop._local_trigger_turns.complete(msg, response=response)
|
||||
|
||||
assert await asyncio.wait_for(submit_task, timeout=0.5) is response
|
||||
assert loop.pending_local_trigger_ids_for_session(session_key) == set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_queue_preserves_overflow_for_next_injection_cycle(tmp_path):
|
||||
"""Pending queue should leave overflow messages queued for later drains."""
|
||||
|
||||
@@ -1986,6 +1986,118 @@ def test_gateway_bound_cron_runs_as_session_turn(
|
||||
assert msg.metadata["thread_id"] == "om_root123"
|
||||
|
||||
|
||||
def test_gateway_local_trigger_queue_submits_agent_turns(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
config = Config()
|
||||
config.agents.defaults.workspace = str(tmp_path / "config-workspace")
|
||||
config.agents.defaults.dream.enabled = False
|
||||
config.gateway.heartbeat.enabled = False
|
||||
bus = MagicMock()
|
||||
seen: dict[str, object] = {}
|
||||
|
||||
_patch_cli_command_runtime(
|
||||
monkeypatch,
|
||||
config,
|
||||
message_bus=lambda: bus,
|
||||
session_manager=lambda _workspace: _FakeSessionManager(),
|
||||
cron_service=lambda _store_path: _FakeCronService(),
|
||||
)
|
||||
|
||||
class _FakeMemory:
|
||||
def get_latest_cursor(self) -> int:
|
||||
return 0
|
||||
|
||||
def get_last_dream_cursor(self) -> int:
|
||||
return 0
|
||||
|
||||
def set_last_dream_cursor(self, _cursor: int) -> None:
|
||||
return None
|
||||
|
||||
class _FakeContext:
|
||||
memory = _FakeMemory()
|
||||
|
||||
class _FakeSessionManager:
|
||||
def flush_all(self) -> int:
|
||||
return 0
|
||||
|
||||
def list_sessions(self) -> list[dict[str, object]]:
|
||||
return []
|
||||
|
||||
class _FakeCronService:
|
||||
def __init__(self) -> None:
|
||||
self.on_job = None
|
||||
|
||||
async def start(self) -> None:
|
||||
return None
|
||||
|
||||
def stop(self) -> None:
|
||||
return None
|
||||
|
||||
def status(self) -> dict[str, int]:
|
||||
return {"jobs": 0}
|
||||
|
||||
def register_system_job(self, _job) -> None:
|
||||
return None
|
||||
|
||||
class _FakeAgentLoop:
|
||||
@classmethod
|
||||
def from_config(cls, config, bus=None, **extra):
|
||||
return cls(**extra)
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
self.model = "test-model"
|
||||
self.provider = _fake_provider()
|
||||
self.tools = {}
|
||||
self.context = _FakeContext()
|
||||
self.sessions = kwargs["session_manager"]
|
||||
self.submit_local_trigger_turn = AsyncMock()
|
||||
seen["agent"] = self
|
||||
|
||||
def _schedule_background(self, _coro) -> None:
|
||||
return None
|
||||
|
||||
async def run(self) -> None:
|
||||
await asyncio.Event().wait()
|
||||
|
||||
async def close_mcp(self) -> None:
|
||||
return None
|
||||
|
||||
def stop(self) -> None:
|
||||
return None
|
||||
|
||||
class _FakeChannelManager:
|
||||
enabled_channels: list[str] = []
|
||||
|
||||
def __init__(self, *_args, **_kwargs) -> None:
|
||||
return None
|
||||
|
||||
async def start_all(self) -> None:
|
||||
await asyncio.Event().wait()
|
||||
|
||||
async def stop_all(self) -> None:
|
||||
return None
|
||||
|
||||
async def _fake_run_local_trigger_queue(**kwargs):
|
||||
seen["local_trigger_queue_kwargs"] = kwargs
|
||||
raise _StopGatewayError("stop")
|
||||
|
||||
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
||||
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.triggers.local_runner.run_local_trigger_queue",
|
||||
_fake_run_local_trigger_queue,
|
||||
)
|
||||
|
||||
cli_commands._run_gateway(config, health_server_enabled=False)
|
||||
|
||||
agent = seen["agent"]
|
||||
kwargs = seen["local_trigger_queue_kwargs"]
|
||||
assert kwargs["bus"] is bus
|
||||
assert kwargs["submit_turn"] is agent.submit_local_trigger_turn
|
||||
|
||||
|
||||
def test_gateway_workspace_override_does_not_migrate_legacy_cron(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
|
||||
@@ -128,6 +128,105 @@ async def test_local_trigger_queue_publishes_bound_inbound_message(tmp_path: Pat
|
||||
assert store.claim_deliveries() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_trigger_queue_waits_for_submitted_turn_before_ack(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
store = LocalTriggerStore(tmp_path)
|
||||
trigger = store.create(
|
||||
name="CI review",
|
||||
channel="websocket",
|
||||
chat_id="chat-1",
|
||||
session_key="websocket:chat-1",
|
||||
)
|
||||
store.enqueue(trigger.id, "Review failed CI")
|
||||
submitted: list[InboundMessage] = []
|
||||
release = asyncio.Event()
|
||||
|
||||
async def _submit_turn(msg: InboundMessage):
|
||||
submitted.append(msg)
|
||||
await release.wait()
|
||||
return None
|
||||
|
||||
task = asyncio.create_task(
|
||||
run_local_trigger_queue(
|
||||
store=store,
|
||||
submit_turn=_submit_turn,
|
||||
poll_interval_s=0.01,
|
||||
)
|
||||
)
|
||||
try:
|
||||
for _ in range(100):
|
||||
if submitted:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
assert len(submitted) == 1
|
||||
assert list(store.processing_dir.glob("*.json"))
|
||||
stored = store.get(trigger.id)
|
||||
assert stored is not None
|
||||
assert stored.last_status is None
|
||||
|
||||
release.set()
|
||||
for _ in range(100):
|
||||
stored = store.get(trigger.id)
|
||||
if stored and stored.last_status == "ok":
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
assert not list(store.processing_dir.glob("*.json"))
|
||||
stored = store.get(trigger.id)
|
||||
assert stored is not None
|
||||
assert stored.last_status == "ok"
|
||||
assert store.claim_deliveries() == []
|
||||
finally:
|
||||
task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_trigger_queue_requeues_when_submitted_turn_is_interrupted(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
store = LocalTriggerStore(tmp_path)
|
||||
trigger = store.create(
|
||||
name="CI review",
|
||||
channel="websocket",
|
||||
chat_id="chat-1",
|
||||
session_key="websocket:chat-1",
|
||||
)
|
||||
store.enqueue(trigger.id, "Review failed CI")
|
||||
started = asyncio.Event()
|
||||
|
||||
async def _submit_turn(_msg: InboundMessage):
|
||||
started.set()
|
||||
await asyncio.Future()
|
||||
|
||||
task = asyncio.create_task(
|
||||
run_local_trigger_queue(
|
||||
store=store,
|
||||
submit_turn=_submit_turn,
|
||||
poll_interval_s=0.01,
|
||||
)
|
||||
)
|
||||
try:
|
||||
await asyncio.wait_for(started.wait(), timeout=1)
|
||||
task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
reclaimed = store.claim_deliveries()
|
||||
assert len(reclaimed) == 1
|
||||
assert reclaimed[0].trigger_id == trigger.id
|
||||
assert reclaimed[0].attempts == 1
|
||||
assert reclaimed[0].last_error == "CancelledError"
|
||||
finally:
|
||||
task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_trigger_queue_recovers_processing_delivery_on_start(
|
||||
tmp_path: Path,
|
||||
|
||||
Reference in New Issue
Block a user