Files
nanobot/tests/triggers/test_local_triggers.py
T
chengyongru be3a42ebac fix(gateway): keep event loop responsive (NAN-33)
Move blocking filesystem, persistence, subprocess, media, and DNS work off the gateway event loop while preserving existing contracts. Add bounded cancellation and responsiveness regression coverage.
2026-08-25 10:18:13 +08:00

975 lines
32 KiB
Python

from __future__ import annotations
import asyncio
import errno
import json
import os
import threading
from contextlib import suppress
from pathlib import Path
import pytest
from nanobot.agent.automation_turns import AutomationTurnError
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.triggers.local_runner import run_local_trigger_queue
from nanobot.triggers.local_store import LocalTriggerStore, TriggerDisabledError
from nanobot.triggers.local_turns import LocalTriggerTurnCoordinator
from nanobot.triggers.local_types import LocalTrigger, TriggerDelivery
from nanobot.webui.metadata import WEBUI_MESSAGE_SOURCE_METADATA_KEY, WEBUI_TURN_METADATA_KEY
def _channel_is_enabled(_name: str) -> bool:
return True
def _write_delivery_file(path: Path, *, trigger_id: str, delivery_id: str) -> None:
path.write_text(
json.dumps(
{
"version": 1,
"delivery": {
"id": delivery_id,
"triggerId": trigger_id,
"content": "queued",
"createdAtMs": 1,
"attempts": 0,
"lastError": None,
},
}
),
encoding="utf-8",
)
def _read_run_record(store: LocalTriggerStore, run_id: str) -> dict:
return json.loads((store.runs_dir / f"{run_id}.json").read_text(encoding="utf-8"))
def test_trigger_store_allows_multiple_triggers_per_session(tmp_path: Path) -> None:
store = LocalTriggerStore(tmp_path)
first = store.create(
name="PR review",
channel="websocket",
chat_id="chat-1",
session_key="websocket:chat-1",
)
second = store.create(
name="CI summary",
channel="websocket",
chat_id="chat-1",
session_key="websocket:chat-1",
)
triggers = store.list_for_session("websocket:chat-1")
assert {trigger.id for trigger in triggers} == {first.id, second.id}
assert first.id.startswith("trg_")
assert second.id.startswith("trg_")
assert first.id != second.id
def test_trigger_store_atomic_writes_ignore_unsupported_directory_fsync(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Shared folders may allow opening directories but reject directory fsync."""
store = LocalTriggerStore(tmp_path)
real_open = os.open
real_close = os.close
real_fsync = os.fsync
directory_fds: set[int] = set()
def fake_open(path: str, flags: int, *args: object, **kwargs: object) -> int:
fd = real_open(path, flags, *args, **kwargs)
if Path(path).name in {"triggers", "runs"}:
directory_fds.add(fd)
return fd
def fake_fsync(fd: int) -> None:
if fd in directory_fds:
raise OSError(errno.EINVAL, "Invalid argument")
real_fsync(fd)
def fake_close(fd: int) -> None:
directory_fds.discard(fd)
real_close(fd)
monkeypatch.setattr(os, "open", fake_open)
monkeypatch.setattr(os, "close", fake_close)
monkeypatch.setattr(os, "fsync", fake_fsync)
trigger = store.create(
name="Shared folder safe",
channel="websocket",
chat_id="chat-1",
session_key="websocket:chat-1",
)
assert store.get(trigger.id) is not None
delivery = store.enqueue(trigger.id, "queued from shared folder")
record = _read_run_record(store, delivery.id)
assert record["content"] == "queued from shared folder"
def test_enqueue_rejects_disabled_trigger(tmp_path: Path) -> None:
store = LocalTriggerStore(tmp_path)
trigger = store.create(
name="Disabled",
channel="telegram",
chat_id="123",
session_key="telegram:123",
)
store.enable(trigger.id, enabled=False)
with pytest.raises(TriggerDisabledError):
store.enqueue(trigger.id, "Review PR #4502")
def test_enqueue_writes_trigger_run_record(tmp_path: Path) -> None:
store = LocalTriggerStore(tmp_path)
trigger = store.create(
name="PR review",
channel="websocket",
chat_id="chat-1",
session_key="websocket:chat-1",
origin_metadata={"webui": True},
)
delivery = store.enqueue(trigger.id, "Review PR #4591")
record = _read_run_record(store, delivery.id)
assert record["run_id"] == delivery.id
assert record["kind"] == "local_trigger"
assert record["status"] == "queued"
assert record["trigger_id"] == trigger.id
assert record["trigger_name"] == "PR review"
assert record["delivery_id"] == delivery.id
assert record["session_key"] == "websocket:chat-1"
assert record["channel"] == "websocket"
assert record["chat_id"] == "chat-1"
assert record["sender_id"] == "trigger"
assert record["content"] == "Review PR #4591"
assert record["origin_metadata"] == {"webui": True}
assert record["updated_at_ms"] > 0
stored = store.get(trigger.id)
assert stored is not None
assert stored.last_message == "Review PR #4591"
def test_enqueue_rolls_back_delivery_and_audit_when_trigger_save_fails(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
store = LocalTriggerStore(tmp_path)
trigger = store.create(
name="PR review",
channel="websocket",
chat_id="chat-1",
session_key="websocket:chat-1",
)
def fail_save(_triggers: list[LocalTrigger]) -> None:
raise OSError("store write failed")
monkeypatch.setattr(store, "_save_triggers_unlocked", fail_save)
with pytest.raises(OSError, match="store write failed"):
store.enqueue(trigger.id, "Review PR #4591")
assert list(store.inbox_dir.glob("*.json")) == []
assert list(store.runs_dir.glob("*.json")) == []
stored = LocalTriggerStore(tmp_path).get(trigger.id)
assert stored is not None
assert stored.last_message == ""
def test_delivery_run_record_truncates_large_content_and_response(tmp_path: Path) -> None:
store = LocalTriggerStore(tmp_path)
trigger = store.create(
name="Large audit",
channel="websocket",
chat_id="chat-1",
session_key="websocket:chat-1",
)
large_content = "content-" * 1000
large_response = "response-" * 1000
delivery = store.enqueue(trigger.id, large_content)
queued_record = _read_run_record(store, delivery.id)
assert queued_record["content"].startswith("content-")
assert queued_record["content"].endswith("\n... (truncated)")
assert len(queued_record["content"]) < len(large_content)
stored = store.get(trigger.id)
assert stored is not None
assert stored.last_message == queued_record["content"]
store.write_delivery_run_record(
delivery,
trigger=trigger,
status="ok",
response=large_response,
)
final_record = _read_run_record(store, delivery.id)
assert final_record["content"].endswith("\n... (truncated)")
assert final_record["response"].startswith("response-")
assert final_record["response"].endswith("\n... (truncated)")
assert len(final_record["response"]) < len(large_response)
def test_delete_removes_delivery_files_for_trigger(tmp_path: Path) -> None:
store = LocalTriggerStore(tmp_path)
trigger = store.create(
name="PR review",
channel="websocket",
chat_id="chat-1",
session_key="websocket:chat-1",
)
other = store.create(
name="CI summary",
channel="websocket",
chat_id="chat-2",
session_key="websocket:chat-2",
)
inbox = store.inbox_dir / "1-tdl_inbox.json"
processing = store.processing_dir / "2-tdl_processing.json"
failed = store.failed_dir / "3-tdl_failed.json"
other_inbox = store.inbox_dir / "4-tdl_other.json"
_write_delivery_file(inbox, trigger_id=trigger.id, delivery_id="tdl_inbox")
_write_delivery_file(processing, trigger_id=trigger.id, delivery_id="tdl_processing")
_write_delivery_file(failed, trigger_id=trigger.id, delivery_id="tdl_failed")
_write_delivery_file(other_inbox, trigger_id=other.id, delivery_id="tdl_other")
assert store.delete(trigger.id) is True
assert store.get(trigger.id) is None
assert not inbox.exists()
assert not processing.exists()
assert not failed.exists()
assert other_inbox.exists()
assert store.get(other.id) is not None
def test_recover_processing_deliveries_requeues_claimed_delivery(tmp_path: Path) -> None:
store = LocalTriggerStore(tmp_path)
trigger = store.create(
name="PR review",
channel="websocket",
chat_id="chat-1",
session_key="websocket:chat-1",
)
store.enqueue(trigger.id, "Review PR #4591")
claimed = store.claim_deliveries()
assert len(claimed) == 1
assert claimed[0].path is not None
assert claimed[0].path.parent.name == "processing"
assert LocalTriggerStore(tmp_path).claim_deliveries() == []
restarted = LocalTriggerStore(tmp_path)
assert restarted.recover_processing_deliveries() == 1
reclaimed = restarted.claim_deliveries()
assert len(reclaimed) == 1
assert reclaimed[0].trigger_id == trigger.id
assert reclaimed[0].content == "Review PR #4591"
assert reclaimed[0].attempts == 1
assert reclaimed[0].last_error == "delivery was recovered from interrupted processing"
@pytest.mark.asyncio
async def test_local_trigger_queue_cancellation_waits_for_claim_mutation(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
store = LocalTriggerStore(tmp_path)
trigger = store.create(
name="PR review",
channel="websocket",
chat_id="chat-1",
session_key="websocket:chat-1",
)
store.enqueue(trigger.id, "Review PR #4591")
claim_started = threading.Event()
allow_claim = threading.Event()
claim_finished = threading.Event()
claim_deliveries = store.claim_deliveries
submitted: list[InboundMessage] = []
def blocked_claim_deliveries(*, limit: int = 20) -> list[TriggerDelivery]:
claim_started.set()
if not allow_claim.wait(timeout=2):
raise TimeoutError("test did not release delivery claim")
try:
return claim_deliveries(limit=limit)
finally:
claim_finished.set()
async def submit_turn(msg: InboundMessage) -> None:
submitted.append(msg)
monkeypatch.setattr(store, "claim_deliveries", blocked_claim_deliveries)
task = asyncio.create_task(
run_local_trigger_queue(
store=store,
submit_turn=submit_turn,
is_channel_enabled=_channel_is_enabled,
poll_interval_s=0.01,
)
)
try:
assert await asyncio.to_thread(claim_started.wait, 1)
task.cancel()
await asyncio.sleep(0)
assert not task.done()
assert len(list(store.inbox_dir.glob("*.json"))) == 1
assert list(store.processing_dir.glob("*.json")) == []
allow_claim.set()
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(task, timeout=1)
assert claim_finished.is_set()
state_at_cancellation = (
sorted(path.name for path in store.inbox_dir.glob("*.json")),
sorted(path.name for path in store.processing_dir.glob("*.json")),
)
assert state_at_cancellation[0] == []
assert len(state_at_cancellation[1]) == 1
assert submitted == []
# The worker has returned before cancellation is visible, so no detached
# claim can move another file after the cancellation report.
await asyncio.sleep(0)
assert (
sorted(path.name for path in store.inbox_dir.glob("*.json")),
sorted(path.name for path in store.processing_dir.glob("*.json")),
) == state_at_cancellation
finally:
allow_claim.set()
task.cancel()
with suppress(asyncio.CancelledError):
await task
@pytest.mark.asyncio
async def test_local_trigger_queue_cancellation_waits_for_startup_recovery(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
store = LocalTriggerStore(tmp_path)
trigger = store.create(
name="PR review",
channel="websocket",
chat_id="chat-1",
session_key="websocket:chat-1",
)
store.enqueue(trigger.id, "Review PR #4591")
assert len(store.claim_deliveries()) == 1
restarted = LocalTriggerStore(tmp_path)
recovery_started = threading.Event()
allow_recovery = threading.Event()
recovery_finished = threading.Event()
recover_processing_deliveries = restarted.recover_processing_deliveries
submitted: list[InboundMessage] = []
def blocked_recovery() -> int:
recovery_started.set()
if not allow_recovery.wait(timeout=2):
raise TimeoutError("test did not release delivery recovery")
try:
return recover_processing_deliveries()
finally:
recovery_finished.set()
async def submit_turn(msg: InboundMessage) -> None:
submitted.append(msg)
monkeypatch.setattr(restarted, "recover_processing_deliveries", blocked_recovery)
task = asyncio.create_task(
run_local_trigger_queue(
store=restarted,
submit_turn=submit_turn,
is_channel_enabled=_channel_is_enabled,
poll_interval_s=0.01,
)
)
try:
assert await asyncio.to_thread(recovery_started.wait, 1)
task.cancel()
await asyncio.sleep(0)
assert not task.done()
assert list(restarted.inbox_dir.glob("*.json")) == []
assert len(list(restarted.processing_dir.glob("*.json"))) == 1
allow_recovery.set()
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(task, timeout=1)
assert recovery_finished.is_set()
state_at_cancellation = (
sorted(path.name for path in restarted.inbox_dir.glob("*.json")),
sorted(path.name for path in restarted.processing_dir.glob("*.json")),
)
assert len(state_at_cancellation[0]) == 1
assert state_at_cancellation[1] == []
recovered_payload = json.loads(
(restarted.inbox_dir / state_at_cancellation[0][0]).read_text(encoding="utf-8")
)
assert recovered_payload["delivery"]["attempts"] == 1
assert submitted == []
await asyncio.sleep(0)
assert (
sorted(path.name for path in restarted.inbox_dir.glob("*.json")),
sorted(path.name for path in restarted.processing_dir.glob("*.json")),
) == state_at_cancellation
finally:
allow_recovery.set()
task.cancel()
with suppress(asyncio.CancelledError):
await task
@pytest.mark.asyncio
async def test_local_trigger_queue_submits_bound_inbound_message(tmp_path: Path) -> None:
store = LocalTriggerStore(tmp_path)
trigger = store.create(
name="PR review",
channel="websocket",
chat_id="chat-1",
session_key="websocket:chat-1",
origin_metadata={"webui": True, WEBUI_TURN_METADATA_KEY: "old-turn"},
)
delivery = store.enqueue(trigger.id, "Review PR #4502")
submitted: list[InboundMessage] = []
async def _submit_turn(msg: InboundMessage):
submitted.append(msg)
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content="done")
task = asyncio.create_task(
run_local_trigger_queue(
store=store,
submit_turn=_submit_turn,
is_channel_enabled=_channel_is_enabled,
poll_interval_s=0.01,
)
)
try:
for _ in range(100):
if submitted:
break
await asyncio.sleep(0.01)
finally:
task.cancel()
with suppress(asyncio.CancelledError):
await task
assert len(submitted) == 1
msg = submitted[0]
assert msg.channel == "websocket"
assert msg.chat_id == "chat-1"
assert msg.sender_id == "trigger"
assert msg.content == "Review PR #4502"
assert msg.session_key_override == "websocket:chat-1"
assert msg.metadata[WEBUI_TURN_METADATA_KEY].startswith(f"trigger:{trigger.id}:")
assert msg.metadata[WEBUI_TURN_METADATA_KEY] != "old-turn"
assert msg.metadata[WEBUI_MESSAGE_SOURCE_METADATA_KEY] == {
"kind": "local_trigger",
"label": "PR review",
}
assert msg.metadata["_local_trigger"]["trigger_id"] == trigger.id
assert (
msg.metadata["_local_trigger"]["persist_content"]
== "Local trigger received: PR review\n\nReview PR #4502"
)
stored = store.get(trigger.id)
assert stored is not None
assert stored.last_status == "ok"
assert stored.last_run_at_ms is not None
assert store.claim_deliveries() == []
record = _read_run_record(store, delivery.id)
assert record["status"] == "ok"
assert record["response"] == "done"
assert record["trigger_id"] == trigger.id
@pytest.mark.asyncio
async def test_local_trigger_queue_rejects_unavailable_target_channel(tmp_path: Path) -> None:
store = LocalTriggerStore(tmp_path)
trigger = store.create(
name="PR review",
channel="telegram",
chat_id="chat-1",
session_key="telegram:chat-1",
)
delivery = store.enqueue(trigger.id, "Review PR #4502")
submitted: list[InboundMessage] = []
async def _submit_turn(msg: InboundMessage):
submitted.append(msg)
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content="done")
task = asyncio.create_task(
run_local_trigger_queue(
store=store,
submit_turn=_submit_turn,
is_channel_enabled=lambda name: name == "websocket",
poll_interval_s=0.01,
)
)
try:
for _ in range(100):
stored = store.get(trigger.id)
if stored and stored.last_status == "error":
break
await asyncio.sleep(0.01)
finally:
task.cancel()
with suppress(asyncio.CancelledError):
await task
stored = store.get(trigger.id)
assert submitted == []
assert stored is not None
assert stored.last_status == "error"
assert stored.last_error == "target channel is not enabled: telegram"
assert store.claim_deliveries() == []
record = _read_run_record(store, delivery.id)
assert record["status"] == "error"
assert record["error"] == "target channel is not enabled: telegram"
@pytest.mark.asyncio
async def test_local_trigger_queue_waits_for_submitted_turn_before_ack(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
store = LocalTriggerStore(tmp_path)
trigger = store.create(
name="CI review",
channel="websocket",
chat_id="chat-1",
session_key="websocket:chat-1",
)
delivery = store.enqueue(trigger.id, "Review failed CI")
submitted: list[InboundMessage] = []
release = asyncio.Event()
completion_started = threading.Event()
allow_completion = threading.Event()
complete_delivery = store.complete_delivery
def _complete_delivery(claimed: TriggerDelivery) -> None:
completion_started.set()
if not allow_completion.wait(timeout=1):
raise TimeoutError("test did not release delivery completion")
complete_delivery(claimed)
monkeypatch.setattr(store, "complete_delivery", _complete_delivery)
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,
is_channel_enabled=_channel_is_enabled,
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"))
record = _read_run_record(store, delivery.id)
assert record["status"] == "processing"
stored = store.get(trigger.id)
assert stored is not None
assert stored.last_status is None
release.set()
assert await asyncio.to_thread(completion_started.wait, 1)
stored = store.get(trigger.id)
assert stored is not None
assert stored.last_status is None
assert list(store.processing_dir.glob("*.json"))
allow_completion.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() == []
record = _read_run_record(store, delivery.id)
assert record["status"] == "ok"
finally:
allow_completion.set()
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",
)
delivery = 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,
is_channel_enabled=_channel_is_enabled,
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"
record = _read_run_record(store, delivery.id)
assert record["status"] == "interrupted"
assert record["attempts"] == 1
finally:
task.cancel()
with suppress(asyncio.CancelledError):
await task
@pytest.mark.asyncio
async def test_accepted_turn_cancellation_settles_delivery_without_replay(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
store = LocalTriggerStore(tmp_path)
trigger = store.create(
name="CI review",
channel="websocket",
chat_id="chat-1",
session_key="websocket:chat-1",
)
delivery = store.enqueue(trigger.id, "Review failed CI")
inbound: asyncio.Queue[InboundMessage] = asyncio.Queue()
work_accepted = asyncio.Event()
release_work = asyncio.Event()
effects: list[str] = []
publish_count = 0
async def publish(msg: InboundMessage) -> None:
nonlocal publish_count
publish_count += 1
await inbound.put(msg)
coordinator = LocalTriggerTurnCoordinator(
publish_inbound=publish,
dispatch=lambda _msg: asyncio.sleep(0),
is_running=lambda: True,
)
async def agent_worker() -> None:
msg = await inbound.get()
work_accepted.set()
await release_work.wait()
effects.append(msg.content)
coordinator.complete(msg)
agent_task = asyncio.create_task(agent_worker())
queue_task = asyncio.create_task(
run_local_trigger_queue(
store=store,
submit_turn=coordinator.submit,
is_channel_enabled=_channel_is_enabled,
poll_interval_s=0.01,
)
)
restarted_task: asyncio.Task[None] | None = None
try:
await asyncio.wait_for(work_accepted.wait(), timeout=1)
queue_task.cancel()
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(queue_task, timeout=1)
# The delivery is durably settled before cancellation is reported even
# though the independently-owned agent turn has not completed yet.
assert effects == []
assert not list(store.processing_dir.glob("*.json"))
assert _read_run_record(store, delivery.id)["status"] == "accepted"
restarted = LocalTriggerStore(tmp_path)
replayed: list[InboundMessage] = []
poll_completed = threading.Event()
claim_deliveries = restarted.claim_deliveries
def tracked_claim_deliveries(*, limit: int = 20) -> list[TriggerDelivery]:
try:
return claim_deliveries(limit=limit)
finally:
poll_completed.set()
async def replay_submit(msg: InboundMessage) -> None:
replayed.append(msg)
monkeypatch.setattr(restarted, "claim_deliveries", tracked_claim_deliveries)
restarted_task = asyncio.create_task(
run_local_trigger_queue(
store=restarted,
submit_turn=replay_submit,
is_channel_enabled=_channel_is_enabled,
poll_interval_s=1,
)
)
assert await asyncio.to_thread(poll_completed.wait, 1)
assert replayed == []
assert restarted.claim_deliveries() == []
release_work.set()
await asyncio.wait_for(agent_task, timeout=1)
assert effects == ["Review failed CI"]
assert publish_count == 1
finally:
release_work.set()
if restarted_task is not None:
restarted_task.cancel()
with suppress(asyncio.CancelledError):
await restarted_task
queue_task.cancel()
with suppress(asyncio.CancelledError):
await queue_task
if not agent_task.done():
agent_task.cancel()
with suppress(asyncio.CancelledError):
await agent_task
@pytest.mark.asyncio
async def test_local_trigger_queue_does_not_retry_completed_agent_failure(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
store = LocalTriggerStore(tmp_path)
trigger = store.create(
name="CI review",
channel="websocket",
chat_id="chat-1",
session_key="websocket:chat-1",
)
delivery = store.enqueue(trigger.id, "Review failed CI")
started = asyncio.Event()
completion_started = threading.Event()
allow_completion = threading.Event()
complete_delivery = store.complete_delivery
def _complete_delivery(claimed: TriggerDelivery) -> None:
completion_started.set()
if not allow_completion.wait(timeout=1):
raise TimeoutError("test did not release delivery completion")
complete_delivery(claimed)
monkeypatch.setattr(store, "complete_delivery", _complete_delivery)
async def _submit_turn(_msg: InboundMessage):
started.set()
raise AutomationTurnError("model failed")
task = asyncio.create_task(
run_local_trigger_queue(
store=store,
submit_turn=_submit_turn,
is_channel_enabled=_channel_is_enabled,
poll_interval_s=0.01,
)
)
try:
await asyncio.wait_for(started.wait(), timeout=1)
assert await asyncio.to_thread(completion_started.wait, 1)
stored = store.get(trigger.id)
assert stored is not None
assert stored.last_status is None
assert list(store.processing_dir.glob("*.json"))
allow_completion.set()
for _ in range(100):
stored = store.get(trigger.id)
if stored and stored.last_status == "error":
break
await asyncio.sleep(0.01)
stored = store.get(trigger.id)
assert stored is not None
assert stored.last_status == "error"
assert stored.last_error == "model failed"
assert store.claim_deliveries() == []
assert not list(store.processing_dir.glob("*.json"))
assert not list(store.failed_dir.glob("*.json"))
record = _read_run_record(store, delivery.id)
assert record["status"] == "error"
assert record["error"] == "model failed"
finally:
allow_completion.set()
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,
) -> None:
store = LocalTriggerStore(tmp_path)
trigger = store.create(
name="PR review",
channel="websocket",
chat_id="chat-1",
session_key="websocket:chat-1",
)
store.enqueue(trigger.id, "Review PR #4591")
assert len(store.claim_deliveries()) == 1
submitted: list[InboundMessage] = []
async def _submit_turn(msg: InboundMessage):
submitted.append(msg)
return None
restarted = LocalTriggerStore(tmp_path)
task = asyncio.create_task(
run_local_trigger_queue(
store=restarted,
submit_turn=_submit_turn,
is_channel_enabled=_channel_is_enabled,
poll_interval_s=0.01,
)
)
try:
for _ in range(100):
if submitted:
break
await asyncio.sleep(0.01)
finally:
task.cancel()
with suppress(asyncio.CancelledError):
await task
assert len(submitted) == 1
assert submitted[0].content == "Review PR #4591"
assert submitted[0].metadata["_local_trigger"]["trigger_id"] == trigger.id
assert restarted.claim_deliveries() == []
def test_local_trigger_from_dict_accepts_null_run_at_ms() -> None:
trigger = LocalTrigger.from_dict(
{
"id": "t1",
"name": "n",
"enabled": True,
"channel": "websocket",
"chatId": "c1",
"sessionKey": "websocket:c1",
"runHistory": [{"runAtMs": None, "status": "ok"}],
"createdAtMs": None,
"updatedAtMs": None,
}
)
assert trigger.run_history[0].run_at_ms == 0
assert trigger.created_at_ms == 0
assert trigger.updated_at_ms == 0
delivery = TriggerDelivery.from_dict(
{
"id": "d1",
"triggerId": "t1",
"content": "hi",
"createdAtMs": None,
"attempts": None,
}
)
assert delivery.created_at_ms == 0
assert delivery.attempts == 0
def test_local_trigger_from_dict_coerces_string_last_run_at_ms() -> None:
"""String lastRunAtMs must coerce to int like cron store ms fields."""
trigger = LocalTrigger.from_dict(
{
"id": "t1",
"name": "n",
"enabled": True,
"channel": "websocket",
"chatId": "c1",
"sessionKey": "websocket:c1",
"lastRunAtMs": "1710000000000",
"createdAtMs": 1,
"updatedAtMs": 1,
}
)
assert trigger.last_run_at_ms == 1710000000000
assert trigger.last_run_at_ms < 1710000000001
trigger_null = LocalTrigger.from_dict(
{
"id": "t2",
"name": "n",
"enabled": True,
"sessionKey": "websocket:c1",
"lastRunAtMs": None,
"createdAtMs": 1,
"updatedAtMs": 1,
}
)
assert trigger_null.last_run_at_ms is None
def test_local_trigger_from_dict_accepts_null_run_history() -> None:
"""Null runHistory must load as empty, matching CronJobState.from_store_dict."""
trigger = LocalTrigger.from_dict(
{
"id": "t1",
"name": "n",
"enabled": True,
"channel": "websocket",
"chatId": "c1",
"sessionKey": "websocket:c1",
"runHistory": None,
"createdAtMs": 1,
"updatedAtMs": 1,
}
)
assert trigger.run_history == []