refactor(trigger): share automation turn delivery
This commit is contained in:
@@ -78,17 +78,18 @@ nanobot trigger trg_8K4P2Q9X "Review PR #4502"
|
||||
Replace `"Review PR #4502"` with the message you want nanobot to receive. The
|
||||
trigger is bound to the session where it was created, so the message goes back
|
||||
to that same chat. Keep `nanobot gateway` running so trigger messages can be
|
||||
delivered. The trigger message starts an automation turn; it is not shown in
|
||||
the chat as a normal user message. If that session is already running a turn,
|
||||
the trigger waits until the session is idle instead of being injected into the
|
||||
active turn.
|
||||
delivered. The trigger message starts an automation turn recorded in that
|
||||
session with the message you passed to the CLI; it is not treated as a normal
|
||||
user message. If that session is already running a turn, the trigger waits
|
||||
until the session is idle instead of being injected into the active turn.
|
||||
|
||||
Trigger deliveries are stored in the workspace until their linked agent turn
|
||||
finishes successfully. If the gateway exits after claiming a delivery but before
|
||||
the turn completes, the next gateway start requeues that delivery. This is an
|
||||
at-least-once local queue: a delivery may run more than once if the process
|
||||
exits at the wrong time, so external scripts should make repeated trigger
|
||||
messages safe.
|
||||
messages safe. If the delivery reaches the agent and the agent turn fails, the
|
||||
delivery is marked failed in Automations instead of retrying forever.
|
||||
|
||||
For longer or generated content, omit the message argument and pipe stdin:
|
||||
|
||||
|
||||
@@ -133,7 +133,8 @@ nanobot trigger trg_8K4P2Q9X "Review PR #4502"
|
||||
```
|
||||
|
||||
Keep `nanobot gateway` running so the message can be delivered to the linked
|
||||
chat/session.
|
||||
chat/session. The message is recorded as an automation turn in that session,
|
||||
not as a normal chat message typed by the user.
|
||||
|
||||
The command writes to a workspace-local durable queue. If `nanobot gateway` is
|
||||
not running yet, the message waits in that workspace. If the target session is
|
||||
@@ -141,8 +142,9 @@ already running a turn, the trigger waits for that session to become idle. If th
|
||||
gateway exits after claiming a delivery but before the linked turn completes,
|
||||
the next gateway start requeues that delivery. The queue is at-least-once, not
|
||||
exactly-once, so the same message can be delivered again after an interrupted
|
||||
process. Run one gateway consumer per workspace; this local queue is not a
|
||||
distributed multi-consumer queue.
|
||||
process. If the agent receives the delivery and the turn fails, the delivery is
|
||||
marked failed instead of retried indefinitely. Run one gateway consumer per
|
||||
workspace; this local queue is not a distributed multi-consumer queue.
|
||||
|
||||
Use stdin when another local process generates the message:
|
||||
|
||||
|
||||
+4
-2
@@ -150,8 +150,10 @@ nanobot to respond in that session. Webhook servers, third-party auth, and
|
||||
event-to-message formatting stay outside nanobot. Trigger deliveries are stored
|
||||
in the workspace until the linked agent turn finishes successfully. If the
|
||||
target session is busy, the trigger waits until that session is idle instead of
|
||||
being injected into the active turn. Delivery is at-least-once, so external
|
||||
systems should tolerate repeated trigger messages.
|
||||
being injected into the active turn. The message is recorded as an automation
|
||||
turn in that session. Delivery is at-least-once, so external systems should
|
||||
tolerate repeated trigger messages; a delivery that reaches the agent but fails
|
||||
is marked failed rather than retried forever.
|
||||
|
||||
## Where to Go Next
|
||||
|
||||
|
||||
+3
-1
@@ -137,7 +137,9 @@ restarts and are requeued if the process exits before the linked turn completes.
|
||||
If the linked session is already running a turn, the local trigger waits until
|
||||
that session is idle instead of being injected into the active turn. This is an
|
||||
at-least-once local queue, so repeated delivery is possible after an interrupted
|
||||
process.
|
||||
process. A delivered trigger is recorded as an automation turn in the linked
|
||||
session; if the agent receives it but the turn fails, Automations marks the run
|
||||
failed instead of retrying indefinitely.
|
||||
|
||||
For recurring background checks that should stay quiet unless there is something
|
||||
useful to report, use the protected heartbeat job by editing `HEARTBEAT.md`
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Shared coordination for session-bound automation turns."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import dataclasses
|
||||
from collections.abc import Awaitable, Callable, Iterable
|
||||
|
||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||
|
||||
|
||||
class AutomationTurnError(RuntimeError):
|
||||
"""Raised when an automation turn reaches the agent and finishes with an error."""
|
||||
|
||||
|
||||
async def publish_next_deferred_turn(
|
||||
*,
|
||||
deferred_queues: dict[str, list[InboundMessage]],
|
||||
publish_inbound: Callable[[InboundMessage], Awaitable[None]],
|
||||
session_key: str,
|
||||
) -> bool:
|
||||
"""Publish the next deferred automation turn for a session."""
|
||||
queue = deferred_queues.get(session_key)
|
||||
if not queue:
|
||||
return False
|
||||
msg = queue.pop(0)
|
||||
if not queue:
|
||||
deferred_queues.pop(session_key, None)
|
||||
await publish_inbound(msg)
|
||||
return True
|
||||
|
||||
|
||||
class AutomationTurnCoordinator:
|
||||
"""Manage automation turns without mixing them into live injections."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
publish_inbound: Callable[[InboundMessage], Awaitable[None]],
|
||||
dispatch: Callable[[InboundMessage], Awaitable[object]],
|
||||
is_running: Callable[[], bool],
|
||||
turn_id: Callable[[InboundMessage], str | None],
|
||||
pending_id: Callable[[InboundMessage], str | None],
|
||||
should_defer_turn: Callable[[InboundMessage, str, Iterable[str]], bool],
|
||||
missing_id_error: str,
|
||||
duplicate_id_error: Callable[[str], str],
|
||||
deferred_queues: dict[str, list[InboundMessage]] | None = None,
|
||||
) -> None:
|
||||
self._publish_inbound = publish_inbound
|
||||
self._dispatch = dispatch
|
||||
self._is_running = is_running
|
||||
self._turn_id = turn_id
|
||||
self._pending_id = pending_id
|
||||
self._should_defer_turn = should_defer_turn
|
||||
self._missing_id_error = missing_id_error
|
||||
self._duplicate_id_error = duplicate_id_error
|
||||
self.deferred_queues = deferred_queues if deferred_queues is not None else {}
|
||||
self._waiters: dict[str, asyncio.Future[OutboundMessage | None]] = {}
|
||||
self._pending_messages_by_turn_id: dict[str, InboundMessage] = {}
|
||||
|
||||
async def submit(self, msg: InboundMessage) -> OutboundMessage | None:
|
||||
"""Submit an automation turn and wait for its session response."""
|
||||
turn_id = self._turn_id(msg)
|
||||
if not turn_id:
|
||||
raise ValueError(self._missing_id_error)
|
||||
if turn_id in self._waiters:
|
||||
raise RuntimeError(self._duplicate_id_error(turn_id))
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
future: asyncio.Future[OutboundMessage | None] = loop.create_future()
|
||||
self._waiters[turn_id] = future
|
||||
self._pending_messages_by_turn_id[turn_id] = msg
|
||||
try:
|
||||
if self._is_running():
|
||||
await self._publish_inbound(msg)
|
||||
else:
|
||||
await self._dispatch(msg)
|
||||
try:
|
||||
return await future
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise AutomationTurnError(str(exc) or exc.__class__.__name__) from exc
|
||||
finally:
|
||||
self._waiters.pop(turn_id, None)
|
||||
self._pending_messages_by_turn_id.pop(turn_id, None)
|
||||
|
||||
def defer_if_active(
|
||||
self,
|
||||
msg: InboundMessage,
|
||||
*,
|
||||
session_key: str,
|
||||
active_session_keys: Iterable[str],
|
||||
) -> bool:
|
||||
"""Defer an automation turn when its target session is already active."""
|
||||
if not self._should_defer_turn(msg, session_key, active_session_keys):
|
||||
return False
|
||||
pending_msg = msg
|
||||
if session_key != msg.session_key:
|
||||
pending_msg = dataclasses.replace(
|
||||
msg,
|
||||
session_key_override=session_key,
|
||||
)
|
||||
self.deferred_queues.setdefault(session_key, []).append(pending_msg)
|
||||
return True
|
||||
|
||||
def complete(
|
||||
self,
|
||||
msg: InboundMessage,
|
||||
*,
|
||||
response: OutboundMessage | None = None,
|
||||
error: BaseException | None = None,
|
||||
) -> None:
|
||||
turn_id = self._turn_id(msg)
|
||||
if not turn_id:
|
||||
return
|
||||
future = self._waiters.get(turn_id)
|
||||
if future is None or future.done():
|
||||
return
|
||||
if error is not None:
|
||||
future.set_exception(error)
|
||||
else:
|
||||
future.set_result(response)
|
||||
|
||||
def pending_ids_for_session(self, session_key: str) -> set[str]:
|
||||
"""Return automation IDs that are waiting for or running in *session_key*."""
|
||||
pending_ids: set[str] = set()
|
||||
for msg in self.deferred_queues.get(session_key, []):
|
||||
pending_id = self._pending_id(msg)
|
||||
if pending_id:
|
||||
pending_ids.add(pending_id)
|
||||
for msg in self._pending_messages_by_turn_id.values():
|
||||
if msg.session_key != session_key:
|
||||
continue
|
||||
pending_id = self._pending_id(msg)
|
||||
if pending_id:
|
||||
pending_ids.add(pending_id)
|
||||
return pending_ids
|
||||
|
||||
async def publish_next_deferred(self, session_key: str) -> bool:
|
||||
return await publish_next_deferred_turn(
|
||||
deferred_queues=self.deferred_queues,
|
||||
publish_inbound=self._publish_inbound,
|
||||
session_key=session_key,
|
||||
)
|
||||
+22
-108
@@ -2,11 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import dataclasses
|
||||
from collections.abc import Awaitable, Callable, Iterable
|
||||
|
||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||
from nanobot.agent.automation_turns import AutomationTurnCoordinator
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.cron.session_turns import (
|
||||
cron_run_id,
|
||||
cron_trigger,
|
||||
@@ -14,7 +13,7 @@ from nanobot.cron.session_turns import (
|
||||
)
|
||||
|
||||
|
||||
class CronTurnCoordinator:
|
||||
class CronTurnCoordinator(AutomationTurnCoordinator):
|
||||
"""Manage scheduled cron turns without mixing them into live injections."""
|
||||
|
||||
def __init__(
|
||||
@@ -23,116 +22,31 @@ class CronTurnCoordinator:
|
||||
publish_inbound: Callable[[InboundMessage], Awaitable[None]],
|
||||
dispatch: Callable[[InboundMessage], Awaitable[object]],
|
||||
is_running: Callable[[], bool],
|
||||
deferred_queues: dict[str, list[InboundMessage]] | None = None,
|
||||
) -> None:
|
||||
self._publish_inbound = publish_inbound
|
||||
self._dispatch = dispatch
|
||||
self._is_running = is_running
|
||||
self.deferred_queues: dict[str, list[InboundMessage]] = {}
|
||||
self._waiters: dict[str, asyncio.Future[OutboundMessage | None]] = {}
|
||||
self._pending_messages_by_run_id: dict[str, InboundMessage] = {}
|
||||
|
||||
async def submit(self, msg: InboundMessage) -> OutboundMessage | None:
|
||||
"""Submit a scheduled cron turn and wait for its session response."""
|
||||
run_id = cron_run_id(msg.metadata)
|
||||
if not run_id:
|
||||
raise ValueError("cron turn metadata must include a run_id")
|
||||
if run_id in self._waiters:
|
||||
raise RuntimeError(f"cron run {run_id!r} is already pending")
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
future: asyncio.Future[OutboundMessage | None] = loop.create_future()
|
||||
self._waiters[run_id] = future
|
||||
self._pending_messages_by_run_id[run_id] = msg
|
||||
try:
|
||||
if self._is_running():
|
||||
await self._publish_inbound(msg)
|
||||
else:
|
||||
await self._dispatch(msg)
|
||||
return await future
|
||||
finally:
|
||||
self._waiters.pop(run_id, None)
|
||||
self._pending_messages_by_run_id.pop(run_id, None)
|
||||
|
||||
def should_defer(
|
||||
self,
|
||||
msg: InboundMessage,
|
||||
*,
|
||||
session_key: str,
|
||||
active_session_keys: Iterable[str],
|
||||
) -> bool:
|
||||
return (
|
||||
defer_cron_until_session_idle(msg.metadata)
|
||||
and session_key in active_session_keys
|
||||
super().__init__(
|
||||
publish_inbound=publish_inbound,
|
||||
dispatch=dispatch,
|
||||
is_running=is_running,
|
||||
turn_id=lambda msg: cron_run_id(msg.metadata),
|
||||
pending_id=_cron_job_id,
|
||||
should_defer_turn=_should_defer_cron_turn,
|
||||
missing_id_error="cron turn metadata must include a run_id",
|
||||
duplicate_id_error=lambda run_id: f"cron run {run_id!r} is already pending",
|
||||
deferred_queues=deferred_queues,
|
||||
)
|
||||
|
||||
def defer_if_active(
|
||||
self,
|
||||
msg: InboundMessage,
|
||||
*,
|
||||
session_key: str,
|
||||
active_session_keys: Iterable[str],
|
||||
) -> bool:
|
||||
"""Defer a cron turn when its target session is already active."""
|
||||
if not self.should_defer(
|
||||
msg,
|
||||
session_key=session_key,
|
||||
active_session_keys=active_session_keys,
|
||||
):
|
||||
return False
|
||||
pending_msg = msg
|
||||
if session_key != msg.session_key:
|
||||
pending_msg = dataclasses.replace(
|
||||
msg,
|
||||
session_key_override=session_key,
|
||||
)
|
||||
self.defer(session_key, pending_msg)
|
||||
return True
|
||||
|
||||
def complete(
|
||||
self,
|
||||
msg: InboundMessage,
|
||||
*,
|
||||
response: OutboundMessage | None = None,
|
||||
error: BaseException | None = None,
|
||||
) -> None:
|
||||
run_id = cron_run_id(msg.metadata)
|
||||
if not run_id:
|
||||
return
|
||||
future = self._waiters.get(run_id)
|
||||
if future is None or future.done():
|
||||
return
|
||||
if error is not None:
|
||||
future.set_exception(error)
|
||||
else:
|
||||
future.set_result(response)
|
||||
|
||||
def defer(self, session_key: str, msg: InboundMessage) -> None:
|
||||
self.deferred_queues.setdefault(session_key, []).append(msg)
|
||||
|
||||
def pending_job_ids_for_session(self, session_key: str) -> set[str]:
|
||||
"""Return cron jobs that are waiting for or running in *session_key*."""
|
||||
job_ids: set[str] = set()
|
||||
for msg in self.deferred_queues.get(session_key, []):
|
||||
job_id = _cron_job_id(msg)
|
||||
if job_id:
|
||||
job_ids.add(job_id)
|
||||
for msg in self._pending_messages_by_run_id.values():
|
||||
if msg.session_key != session_key:
|
||||
continue
|
||||
job_id = _cron_job_id(msg)
|
||||
if job_id:
|
||||
job_ids.add(job_id)
|
||||
return job_ids
|
||||
return self.pending_ids_for_session(session_key)
|
||||
|
||||
async def publish_next_deferred(self, session_key: str) -> bool:
|
||||
queue = self.deferred_queues.get(session_key)
|
||||
if not queue:
|
||||
return False
|
||||
msg = queue.pop(0)
|
||||
if not queue:
|
||||
self.deferred_queues.pop(session_key, None)
|
||||
await self._publish_inbound(msg)
|
||||
return True
|
||||
|
||||
def _should_defer_cron_turn(
|
||||
msg: InboundMessage,
|
||||
session_key: str,
|
||||
active_session_keys: Iterable[str],
|
||||
) -> bool:
|
||||
return defer_cron_until_session_idle(msg.metadata) and session_key in active_session_keys
|
||||
|
||||
|
||||
def _cron_job_id(msg: InboundMessage) -> str | None:
|
||||
|
||||
+37
-35
@@ -18,6 +18,7 @@ from loguru import logger
|
||||
from nanobot.agent import context as agent_context
|
||||
from nanobot.agent import model_presets as preset_helpers
|
||||
from nanobot.agent.autocompact import AutoCompact
|
||||
from nanobot.agent.automation_turns import publish_next_deferred_turn
|
||||
from nanobot.agent.context import ContextBuilder
|
||||
from nanobot.agent.cron_turns import CronTurnCoordinator
|
||||
from nanobot.agent.hook import AgentHook, CompositeHook
|
||||
@@ -225,6 +226,7 @@ class AgentLoop:
|
||||
runtime_events: RuntimeEventBus | None = None,
|
||||
runtime_model_publisher: Callable[[str, str | None], None] | None = None,
|
||||
restart_mode: str = "auto",
|
||||
local_trigger_store: Any | None = None,
|
||||
):
|
||||
from nanobot.config.schema import ToolsConfig
|
||||
|
||||
@@ -272,6 +274,7 @@ class AgentLoop:
|
||||
):
|
||||
self._image_generation_provider_configs["openrouter"] = image_generation_provider_config
|
||||
self.cron_service = cron_service
|
||||
self.local_trigger_store = local_trigger_store
|
||||
self.restrict_to_workspace = restrict_to_workspace
|
||||
self.workspace_scopes = WorkspaceScopeResolver(
|
||||
default_workspace=workspace,
|
||||
@@ -316,15 +319,22 @@ class AgentLoop:
|
||||
# When a session has an active task, new messages for that session
|
||||
# are routed here instead of creating a new task.
|
||||
self._pending_queues: dict[str, asyncio.Queue] = {}
|
||||
self._deferred_automation_turns: dict[str, list[InboundMessage]] = {}
|
||||
self._cron_turns = CronTurnCoordinator(
|
||||
publish_inbound=self.bus.publish_inbound,
|
||||
dispatch=self._dispatch,
|
||||
is_running=lambda: self._running,
|
||||
deferred_queues=self._deferred_automation_turns,
|
||||
)
|
||||
self._local_trigger_turns = LocalTriggerTurnCoordinator(
|
||||
publish_inbound=self.bus.publish_inbound,
|
||||
dispatch=self._dispatch,
|
||||
is_running=lambda: self._running,
|
||||
deferred_queues=self._deferred_automation_turns,
|
||||
)
|
||||
self._automation_turn_coordinators = (
|
||||
("cron", self._cron_turns),
|
||||
("local trigger", self._local_trigger_turns),
|
||||
)
|
||||
# NANOBOT_MAX_CONCURRENT_REQUESTS: <=0 means unlimited; default 3.
|
||||
_max = int(os.environ.get("NANOBOT_MAX_CONCURRENT_REQUESTS", "3"))
|
||||
@@ -605,9 +615,11 @@ class AgentLoop:
|
||||
return self._local_trigger_turns.pending_trigger_ids_for_session(session_key)
|
||||
|
||||
async def _publish_next_deferred_automation_turn(self, session_key: str) -> None:
|
||||
if await self._cron_turns.publish_next_deferred(session_key):
|
||||
return
|
||||
await self._local_trigger_turns.publish_next_deferred(session_key)
|
||||
await publish_next_deferred_turn(
|
||||
deferred_queues=self._deferred_automation_turns,
|
||||
publish_inbound=self.bus.publish_inbound,
|
||||
session_key=session_key,
|
||||
)
|
||||
|
||||
def _persist_user_message_early(
|
||||
self,
|
||||
@@ -938,25 +950,21 @@ class AgentLoop:
|
||||
self.commands.dispatch_priority,
|
||||
)
|
||||
continue
|
||||
if self._cron_turns.defer_if_active(
|
||||
msg,
|
||||
session_key=effective_key,
|
||||
active_session_keys=self._pending_queues.keys(),
|
||||
):
|
||||
logger.info(
|
||||
"Deferred cron turn for active session {}",
|
||||
effective_key,
|
||||
)
|
||||
continue
|
||||
if self._local_trigger_turns.defer_if_active(
|
||||
msg,
|
||||
session_key=effective_key,
|
||||
active_session_keys=self._pending_queues.keys(),
|
||||
):
|
||||
logger.info(
|
||||
"Deferred local trigger turn for active session {}",
|
||||
effective_key,
|
||||
)
|
||||
deferred = False
|
||||
for label, coordinator in self._automation_turn_coordinators:
|
||||
if coordinator.defer_if_active(
|
||||
msg,
|
||||
session_key=effective_key,
|
||||
active_session_keys=self._pending_queues.keys(),
|
||||
):
|
||||
logger.info(
|
||||
"Deferred {} turn for active session {}",
|
||||
label,
|
||||
effective_key,
|
||||
)
|
||||
deferred = True
|
||||
break
|
||||
if deferred:
|
||||
continue
|
||||
# If this session already has an active pending queue (i.e. a task
|
||||
# is processing this session), route the message there for mid-turn
|
||||
@@ -1079,17 +1087,11 @@ class AgentLoop:
|
||||
session_key=session_key,
|
||||
metadata=msg.metadata,
|
||||
)
|
||||
self._cron_turns.complete(msg, response=response)
|
||||
self._local_trigger_turns.complete(msg, response=response)
|
||||
for _, coordinator in self._automation_turn_coordinators:
|
||||
coordinator.complete(msg, response=response)
|
||||
except asyncio.CancelledError:
|
||||
self._cron_turns.complete(
|
||||
msg,
|
||||
error=asyncio.CancelledError(),
|
||||
)
|
||||
self._local_trigger_turns.complete(
|
||||
msg,
|
||||
error=asyncio.CancelledError(),
|
||||
)
|
||||
for _, coordinator in self._automation_turn_coordinators:
|
||||
coordinator.complete(msg, error=asyncio.CancelledError())
|
||||
logger.info("Task cancelled for session {}", session_key)
|
||||
# Preserve partial context from the interrupted turn so
|
||||
# the user does not lose tool results and assistant
|
||||
@@ -1128,8 +1130,8 @@ class AgentLoop:
|
||||
session_key=session_key,
|
||||
metadata=msg.metadata,
|
||||
)
|
||||
self._cron_turns.complete(msg, error=exc)
|
||||
self._local_trigger_turns.complete(msg, error=exc)
|
||||
for _, coordinator in self._automation_turn_coordinators:
|
||||
coordinator.complete(msg, error=exc)
|
||||
finally:
|
||||
# Drain any messages still in the pending queue and re-publish
|
||||
# them to the bus so they are processed as fresh inbound messages
|
||||
@@ -1481,7 +1483,7 @@ class AgentLoop:
|
||||
# message. Mark messages with _command so get_history can filter
|
||||
# them out of LLM context. /new is excluded because it
|
||||
# intentionally clears the session.
|
||||
if raw.lower() != "/new":
|
||||
if cmd_ctx.raw.lower() != "/new":
|
||||
ctx.user_persisted_early = self._persist_user_message_early(
|
||||
ctx.msg, ctx.session, _command=True
|
||||
)
|
||||
|
||||
@@ -948,14 +948,13 @@ def _run_gateway(
|
||||
runtime_events=runtime_events,
|
||||
provider_signature=provider_snapshot.signature,
|
||||
hooks=[TokenUsageHook(timezone_name=config.agents.defaults.timezone)],
|
||||
local_trigger_store=trigger_store,
|
||||
)
|
||||
WebuiTurnCoordinator(
|
||||
bus=bus,
|
||||
sessions=session_manager,
|
||||
schedule_background=lambda coro: agent._schedule_background(coro),
|
||||
).subscribe(runtime_events)
|
||||
agent.local_trigger_store = trigger_store
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.session.keys import session_key_for_channel
|
||||
|
||||
@@ -1302,7 +1301,6 @@ def _run_gateway(
|
||||
asyncio.create_task(
|
||||
run_local_trigger_queue(
|
||||
store=trigger_store,
|
||||
bus=bus,
|
||||
submit_turn=getattr(agent, "submit_local_trigger_turn", None),
|
||||
),
|
||||
name="nanobot-local-triggers",
|
||||
|
||||
@@ -753,11 +753,18 @@ async def cmd_trigger(ctx: CommandContext) -> OutboundMessage:
|
||||
if store is None:
|
||||
store = LocalTriggerStore(workspace)
|
||||
|
||||
from nanobot.session.keys import UNIFIED_SESSION_KEY
|
||||
|
||||
session_key = (
|
||||
ctx.msg.session_key
|
||||
if ctx.key == UNIFIED_SESSION_KEY
|
||||
else ctx.key
|
||||
)
|
||||
trigger = store.create(
|
||||
name=name,
|
||||
channel=ctx.msg.channel,
|
||||
chat_id=ctx.msg.chat_id,
|
||||
session_key=ctx.key,
|
||||
session_key=session_key,
|
||||
sender_id="trigger",
|
||||
origin_metadata=dict(ctx.msg.metadata or {}),
|
||||
)
|
||||
|
||||
@@ -9,8 +9,8 @@ from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.automation_turns import AutomationTurnError
|
||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.triggers.local_session_turns import LOCAL_TRIGGER_META
|
||||
from nanobot.triggers.local_store import LocalTriggerStore
|
||||
from nanobot.triggers.local_types import LocalTrigger, TriggerDelivery
|
||||
@@ -20,14 +20,13 @@ from nanobot.webui.metadata import WEBUI_MESSAGE_SOURCE_METADATA_KEY, WEBUI_TURN
|
||||
async def run_local_trigger_queue(
|
||||
*,
|
||||
store: LocalTriggerStore,
|
||||
bus: MessageBus | None = None,
|
||||
submit_turn: Callable[[InboundMessage], Awaitable[OutboundMessage | None]] | None = None,
|
||||
poll_interval_s: float = 0.5,
|
||||
batch_size: int = 20,
|
||||
) -> None:
|
||||
"""Poll local trigger deliveries and publish them as normal inbound messages."""
|
||||
if bus is None and submit_turn is None:
|
||||
raise ValueError("run_local_trigger_queue requires bus or submit_turn")
|
||||
"""Poll local trigger deliveries and submit them as session turns."""
|
||||
if submit_turn is None:
|
||||
raise ValueError("run_local_trigger_queue requires submit_turn")
|
||||
logger.info("Local trigger queue started")
|
||||
recovered = store.recover_processing_deliveries()
|
||||
if recovered:
|
||||
@@ -46,7 +45,6 @@ async def run_local_trigger_queue(
|
||||
await _deliver_delivery(
|
||||
store,
|
||||
delivery,
|
||||
bus=bus,
|
||||
submit_turn=submit_turn,
|
||||
)
|
||||
store.complete_delivery(delivery)
|
||||
@@ -67,6 +65,21 @@ async def run_local_trigger_queue(
|
||||
delivery.trigger_id,
|
||||
exc,
|
||||
)
|
||||
except AutomationTurnError as exc:
|
||||
error = str(exc) or exc.__class__.__name__
|
||||
store.record_delivery(
|
||||
delivery.trigger_id,
|
||||
status="error",
|
||||
error=error,
|
||||
run_at_ms=delivery.created_at_ms,
|
||||
)
|
||||
store.complete_delivery(delivery)
|
||||
logger.warning(
|
||||
"Trigger: delivery {} for {} reached the agent but failed: {}",
|
||||
delivery.id,
|
||||
delivery.trigger_id,
|
||||
error,
|
||||
)
|
||||
except Exception as exc:
|
||||
error = str(exc) or exc.__class__.__name__
|
||||
retried = store.retry_delivery(delivery, error)
|
||||
@@ -92,8 +105,7 @@ async def _deliver_delivery(
|
||||
store: LocalTriggerStore,
|
||||
delivery: TriggerDelivery,
|
||||
*,
|
||||
bus: MessageBus | None,
|
||||
submit_turn: Callable[[InboundMessage], Awaitable[OutboundMessage | None]] | None,
|
||||
submit_turn: Callable[[InboundMessage], Awaitable[OutboundMessage | None]],
|
||||
) -> None:
|
||||
trigger = store.get(delivery.trigger_id)
|
||||
if trigger is None:
|
||||
@@ -109,12 +121,7 @@ async def _deliver_delivery(
|
||||
metadata=_delivery_metadata(trigger, delivery),
|
||||
session_key_override=trigger.session_key,
|
||||
)
|
||||
if submit_turn is not None:
|
||||
await submit_turn(msg)
|
||||
else:
|
||||
if bus is None:
|
||||
raise RuntimeError("bus unavailable for local trigger delivery")
|
||||
await bus.publish_inbound(msg)
|
||||
await submit_turn(msg)
|
||||
store.record_delivery(
|
||||
trigger.id,
|
||||
status="ok",
|
||||
@@ -129,6 +136,7 @@ def _delivery_metadata(trigger: LocalTrigger, delivery: TriggerDelivery) -> dict
|
||||
"trigger_name": trigger.name,
|
||||
"delivery_id": delivery.id,
|
||||
"created_at_ms": delivery.created_at_ms,
|
||||
"persist_content": _history_content(trigger, delivery),
|
||||
}
|
||||
if trigger.channel == "websocket":
|
||||
metadata.pop(WEBUI_TURN_METADATA_KEY, None)
|
||||
@@ -138,3 +146,8 @@ def _delivery_metadata(trigger: LocalTrigger, delivery: TriggerDelivery) -> dict
|
||||
source["label"] = trigger.name
|
||||
metadata[WEBUI_MESSAGE_SOURCE_METADATA_KEY] = source
|
||||
return metadata
|
||||
|
||||
|
||||
def _history_content(trigger: LocalTrigger, delivery: TriggerDelivery) -> str:
|
||||
label = trigger.name.strip() if trigger.name else trigger.id
|
||||
return f"Local trigger received: {label}\n\n{delivery.content}"
|
||||
|
||||
@@ -14,6 +14,9 @@ LOCAL_TRIGGER_META = "_local_trigger"
|
||||
|
||||
|
||||
def _local_trigger_history_text(trigger: Mapping[str, Any]) -> str:
|
||||
persist_content = trigger.get("persist_content")
|
||||
if isinstance(persist_content, str) and persist_content.strip():
|
||||
return persist_content
|
||||
name = trigger.get("trigger_name")
|
||||
trigger_id = trigger.get("trigger_id")
|
||||
label = name if isinstance(name, str) and name.strip() else trigger_id
|
||||
|
||||
+25
-106
@@ -2,15 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import dataclasses
|
||||
from collections.abc import Awaitable, Callable, Iterable
|
||||
|
||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||
from nanobot.agent.automation_turns import AutomationTurnCoordinator
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.triggers.local_session_turns import local_trigger, local_trigger_delivery_id
|
||||
|
||||
|
||||
class LocalTriggerTurnCoordinator:
|
||||
class LocalTriggerTurnCoordinator(AutomationTurnCoordinator):
|
||||
"""Manage local trigger turns without mixing them into live injections."""
|
||||
|
||||
def __init__(
|
||||
@@ -19,113 +18,33 @@ class LocalTriggerTurnCoordinator:
|
||||
publish_inbound: Callable[[InboundMessage], Awaitable[None]],
|
||||
dispatch: Callable[[InboundMessage], Awaitable[object]],
|
||||
is_running: Callable[[], bool],
|
||||
deferred_queues: dict[str, list[InboundMessage]] | None = None,
|
||||
) -> None:
|
||||
self._publish_inbound = publish_inbound
|
||||
self._dispatch = dispatch
|
||||
self._is_running = is_running
|
||||
self.deferred_queues: dict[str, list[InboundMessage]] = {}
|
||||
self._waiters: dict[str, asyncio.Future[OutboundMessage | None]] = {}
|
||||
self._pending_messages_by_delivery_id: dict[str, InboundMessage] = {}
|
||||
|
||||
async def submit(self, msg: InboundMessage) -> OutboundMessage | None:
|
||||
"""Submit a local trigger turn and wait for its session response."""
|
||||
delivery_id = local_trigger_delivery_id(msg.metadata)
|
||||
if not delivery_id:
|
||||
raise ValueError("local trigger turn metadata must include a delivery_id")
|
||||
if delivery_id in self._waiters:
|
||||
raise RuntimeError(f"local trigger delivery {delivery_id!r} is already pending")
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
future: asyncio.Future[OutboundMessage | None] = loop.create_future()
|
||||
self._waiters[delivery_id] = future
|
||||
self._pending_messages_by_delivery_id[delivery_id] = msg
|
||||
try:
|
||||
if self._is_running():
|
||||
await self._publish_inbound(msg)
|
||||
else:
|
||||
await self._dispatch(msg)
|
||||
return await future
|
||||
finally:
|
||||
self._waiters.pop(delivery_id, None)
|
||||
self._pending_messages_by_delivery_id.pop(delivery_id, None)
|
||||
|
||||
def should_defer(
|
||||
self,
|
||||
msg: InboundMessage,
|
||||
*,
|
||||
session_key: str,
|
||||
active_session_keys: Iterable[str],
|
||||
) -> bool:
|
||||
return local_trigger(msg.metadata) is not None and session_key in active_session_keys
|
||||
|
||||
def defer_if_active(
|
||||
self,
|
||||
msg: InboundMessage,
|
||||
*,
|
||||
session_key: str,
|
||||
active_session_keys: Iterable[str],
|
||||
) -> bool:
|
||||
"""Defer a local trigger turn when its target session is already active."""
|
||||
if not self.should_defer(
|
||||
msg,
|
||||
session_key=session_key,
|
||||
active_session_keys=active_session_keys,
|
||||
):
|
||||
return False
|
||||
pending_msg = msg
|
||||
if session_key != msg.session_key:
|
||||
pending_msg = dataclasses.replace(
|
||||
msg,
|
||||
session_key_override=session_key,
|
||||
)
|
||||
self.defer(session_key, pending_msg)
|
||||
return True
|
||||
|
||||
def complete(
|
||||
self,
|
||||
msg: InboundMessage,
|
||||
*,
|
||||
response: OutboundMessage | None = None,
|
||||
error: BaseException | None = None,
|
||||
) -> None:
|
||||
delivery_id = local_trigger_delivery_id(msg.metadata)
|
||||
if not delivery_id:
|
||||
return
|
||||
future = self._waiters.get(delivery_id)
|
||||
if future is None or future.done():
|
||||
return
|
||||
if error is not None:
|
||||
future.set_exception(error)
|
||||
else:
|
||||
future.set_result(response)
|
||||
|
||||
def defer(self, session_key: str, msg: InboundMessage) -> None:
|
||||
self.deferred_queues.setdefault(session_key, []).append(msg)
|
||||
super().__init__(
|
||||
publish_inbound=publish_inbound,
|
||||
dispatch=dispatch,
|
||||
is_running=is_running,
|
||||
turn_id=lambda msg: local_trigger_delivery_id(msg.metadata),
|
||||
pending_id=_local_trigger_id,
|
||||
should_defer_turn=_should_defer_local_trigger_turn,
|
||||
missing_id_error="local trigger turn metadata must include a delivery_id",
|
||||
duplicate_id_error=lambda delivery_id: (
|
||||
f"local trigger delivery {delivery_id!r} is already pending"
|
||||
),
|
||||
deferred_queues=deferred_queues,
|
||||
)
|
||||
|
||||
def pending_trigger_ids_for_session(self, session_key: str) -> set[str]:
|
||||
"""Return local triggers waiting for or running in *session_key*."""
|
||||
trigger_ids: set[str] = set()
|
||||
for msg in self.deferred_queues.get(session_key, []):
|
||||
trigger_id = _local_trigger_id(msg)
|
||||
if trigger_id:
|
||||
trigger_ids.add(trigger_id)
|
||||
for msg in self._pending_messages_by_delivery_id.values():
|
||||
if msg.session_key != session_key:
|
||||
continue
|
||||
trigger_id = _local_trigger_id(msg)
|
||||
if trigger_id:
|
||||
trigger_ids.add(trigger_id)
|
||||
return trigger_ids
|
||||
return self.pending_ids_for_session(session_key)
|
||||
|
||||
async def publish_next_deferred(self, session_key: str) -> bool:
|
||||
queue = self.deferred_queues.get(session_key)
|
||||
if not queue:
|
||||
return False
|
||||
msg = queue.pop(0)
|
||||
if not queue:
|
||||
self.deferred_queues.pop(session_key, None)
|
||||
await self._publish_inbound(msg)
|
||||
return True
|
||||
|
||||
def _should_defer_local_trigger_turn(
|
||||
msg: InboundMessage,
|
||||
session_key: str,
|
||||
active_session_keys: Iterable[str],
|
||||
) -> bool:
|
||||
return local_trigger(msg.metadata) is not None and session_key in active_session_keys
|
||||
|
||||
|
||||
def _local_trigger_id(msg: InboundMessage) -> str | None:
|
||||
|
||||
@@ -134,6 +134,7 @@ def test_persist_local_trigger_turn_uses_hidden_automation_marker(tmp_path: Path
|
||||
"trigger_name": "PR review",
|
||||
"delivery_id": "tdel_456",
|
||||
"created_at_ms": 1_700_000_000_000,
|
||||
"persist_content": "Local trigger received: PR review\n\nReview PR #4502",
|
||||
}
|
||||
},
|
||||
),
|
||||
@@ -142,8 +143,7 @@ def test_persist_local_trigger_turn_uses_hidden_automation_marker(tmp_path: Path
|
||||
|
||||
assert persisted is True
|
||||
message = session.messages[-1]
|
||||
assert message["content"] == "Local trigger received: PR review"
|
||||
assert "Review PR #4502" not in message["content"]
|
||||
assert message["content"] == "Local trigger received: PR review\n\nReview PR #4502"
|
||||
assert message[AUTOMATION_HISTORY_META] == {
|
||||
"kind": "local_trigger",
|
||||
"trigger_id": "trg_123",
|
||||
@@ -156,6 +156,25 @@ def test_persist_local_trigger_turn_uses_hidden_automation_marker(tmp_path: Path
|
||||
assert message["trigger_delivery_id"] == "tdel_456"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_with_bot_suffix_does_not_persist_command(tmp_path: Path) -> None:
|
||||
loop = _make_full_loop(tmp_path)
|
||||
|
||||
response = await loop._process_message(
|
||||
InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="user",
|
||||
chat_id="chat-1",
|
||||
content="/new@nanobot_bot",
|
||||
)
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.content == "New session started."
|
||||
session = loop.sessions.get_or_create("websocket:chat-1")
|
||||
assert session.messages == []
|
||||
|
||||
|
||||
def test_clean_generated_title_strips_reasoning_tags() -> None:
|
||||
assert clean_generated_title("<think>reasoning</think> WebUI polish") == "WebUI polish"
|
||||
assert clean_generated_title("Title: <think> The user said hello") == ""
|
||||
|
||||
@@ -2044,6 +2044,7 @@ def test_gateway_local_trigger_queue_submits_agent_turns(
|
||||
class _FakeAgentLoop:
|
||||
@classmethod
|
||||
def from_config(cls, config, bus=None, **extra):
|
||||
seen["agent_from_config_kwargs"] = extra
|
||||
return cls(**extra)
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
@@ -2093,8 +2094,11 @@ def test_gateway_local_trigger_queue_submits_agent_turns(
|
||||
cli_commands._run_gateway(config, health_server_enabled=False)
|
||||
|
||||
agent = seen["agent"]
|
||||
agent_kwargs = seen["agent_from_config_kwargs"]
|
||||
kwargs = seen["local_trigger_queue_kwargs"]
|
||||
assert kwargs["bus"] is bus
|
||||
assert "local_trigger_store" in agent_kwargs
|
||||
assert kwargs["store"] is agent_kwargs["local_trigger_store"]
|
||||
assert "bus" not in kwargs
|
||||
assert kwargs["submit_turn"] is agent.submit_local_trigger_turn
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import pytest
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.command.builtin import build_help_text, register_builtin_commands
|
||||
from nanobot.command.router import CommandContext, CommandRouter
|
||||
from nanobot.session.keys import UNIFIED_SESSION_KEY
|
||||
from nanobot.triggers.local_store import LocalTriggerStore
|
||||
|
||||
|
||||
@@ -45,6 +46,37 @@ async def test_trigger_command_creates_session_bound_local_trigger(tmp_path: Pat
|
||||
assert f"nanobot trigger {trigger.id} \"message\"" in response.content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_command_binds_inbound_session_when_unified_session_is_active(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
router = CommandRouter()
|
||||
register_builtin_commands(router)
|
||||
store = LocalTriggerStore(tmp_path)
|
||||
loop = SimpleNamespace(workspace=tmp_path, local_trigger_store=store)
|
||||
msg = InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="user",
|
||||
chat_id="chat-1",
|
||||
content="/trigger PR review",
|
||||
session_key_override="websocket:chat-1:thread-a",
|
||||
)
|
||||
ctx = CommandContext(
|
||||
msg=msg,
|
||||
session=None,
|
||||
key=UNIFIED_SESSION_KEY,
|
||||
raw="/trigger PR review",
|
||||
loop=loop,
|
||||
)
|
||||
|
||||
response = await router.dispatch(ctx)
|
||||
|
||||
assert response is not None
|
||||
trigger = store.list_for_session("websocket:chat-1:thread-a")[0]
|
||||
assert trigger.session_key == "websocket:chat-1:thread-a"
|
||||
assert store.list_for_session(UNIFIED_SESSION_KEY) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_command_without_name_returns_usage_only(tmp_path: Path) -> None:
|
||||
router = CommandRouter()
|
||||
|
||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.automation_turns import AutomationTurnError
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.triggers.local_runner import run_local_trigger_queue
|
||||
from nanobot.triggers.local_store import LocalTriggerStore, TriggerDisabledError
|
||||
@@ -77,7 +78,7 @@ def test_recover_processing_deliveries_requeues_claimed_delivery(tmp_path: Path)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_trigger_queue_publishes_bound_inbound_message(tmp_path: Path) -> None:
|
||||
async def test_local_trigger_queue_submits_bound_inbound_message(tmp_path: Path) -> None:
|
||||
store = LocalTriggerStore(tmp_path)
|
||||
trigger = store.create(
|
||||
name="PR review",
|
||||
@@ -87,18 +88,18 @@ async def test_local_trigger_queue_publishes_bound_inbound_message(tmp_path: Pat
|
||||
origin_metadata={"webui": True, WEBUI_TURN_METADATA_KEY: "old-turn"},
|
||||
)
|
||||
store.enqueue(trigger.id, "Review PR #4502")
|
||||
published: list[InboundMessage] = []
|
||||
submitted: list[InboundMessage] = []
|
||||
|
||||
class _Bus:
|
||||
async def publish_inbound(self, msg: InboundMessage) -> None:
|
||||
published.append(msg)
|
||||
async def _submit_turn(msg: InboundMessage):
|
||||
submitted.append(msg)
|
||||
return None
|
||||
|
||||
task = asyncio.create_task(
|
||||
run_local_trigger_queue(store=store, bus=_Bus(), poll_interval_s=0.01)
|
||||
run_local_trigger_queue(store=store, submit_turn=_submit_turn, poll_interval_s=0.01)
|
||||
)
|
||||
try:
|
||||
for _ in range(100):
|
||||
if published:
|
||||
if submitted:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
finally:
|
||||
@@ -106,8 +107,8 @@ async def test_local_trigger_queue_publishes_bound_inbound_message(tmp_path: Pat
|
||||
with suppress(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
assert len(published) == 1
|
||||
msg = published[0]
|
||||
assert len(submitted) == 1
|
||||
msg = submitted[0]
|
||||
assert msg.channel == "websocket"
|
||||
assert msg.chat_id == "chat-1"
|
||||
assert msg.sender_id == "trigger"
|
||||
@@ -120,6 +121,10 @@ async def test_local_trigger_queue_publishes_bound_inbound_message(tmp_path: Pat
|
||||
"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
|
||||
@@ -227,6 +232,52 @@ async def test_local_trigger_queue_requeues_when_submitted_turn_is_interrupted(
|
||||
await task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_trigger_queue_does_not_retry_completed_agent_failure(
|
||||
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()
|
||||
raise AutomationTurnError("model failed")
|
||||
|
||||
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)
|
||||
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"))
|
||||
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,
|
||||
@@ -240,19 +291,19 @@ async def test_local_trigger_queue_recovers_processing_delivery_on_start(
|
||||
)
|
||||
store.enqueue(trigger.id, "Review PR #4591")
|
||||
assert len(store.claim_deliveries()) == 1
|
||||
published: list[InboundMessage] = []
|
||||
submitted: list[InboundMessage] = []
|
||||
|
||||
class _Bus:
|
||||
async def publish_inbound(self, msg: InboundMessage) -> None:
|
||||
published.append(msg)
|
||||
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, bus=_Bus(), poll_interval_s=0.01)
|
||||
run_local_trigger_queue(store=restarted, submit_turn=_submit_turn, poll_interval_s=0.01)
|
||||
)
|
||||
try:
|
||||
for _ in range(100):
|
||||
if published:
|
||||
if submitted:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
finally:
|
||||
@@ -260,7 +311,7 @@ async def test_local_trigger_queue_recovers_processing_delivery_on_start(
|
||||
with suppress(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
assert len(published) == 1
|
||||
assert published[0].content == "Review PR #4591"
|
||||
assert published[0].metadata["_local_trigger"]["trigger_id"] == trigger.id
|
||||
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() == []
|
||||
|
||||
Reference in New Issue
Block a user