fix(trigger): defer local triggers until session idle

This commit is contained in:
chengyongru
2026-07-02 13:32:46 +08:00
committed by Xubin Ren
parent 09bde468eb
commit f32007c83f
13 changed files with 543 additions and 35 deletions
+92
View File
@@ -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."""
+112
View File
@@ -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:
+99
View File
@@ -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,