fix(trigger): recover interrupted deliveries

This commit is contained in:
chengyongru
2026-07-02 13:32:46 +08:00
committed by Xubin Ren
parent 55b550ee01
commit 1ed2c9a213
7 changed files with 133 additions and 14 deletions
+6
View File
@@ -81,6 +81,12 @@ 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.
Trigger deliveries are stored in the workspace until the gateway consumes them.
If the gateway exits after claiming a delivery but before marking it complete,
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.
For longer or generated content, omit the message argument and pipe stdin:
```bash
+7
View File
@@ -135,6 +135,13 @@ nanobot trigger trg_8K4P2Q9X "Review PR #4502"
Keep `nanobot gateway` running so the message can be delivered to the linked
chat/session.
The command writes to a workspace-local durable queue. If `nanobot gateway` is
not running yet, the message waits in that workspace. If the gateway exits after
claiming a delivery but before completing it, 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.
Use stdin when another local process generates the message:
```bash
+4 -1
View File
@@ -147,7 +147,10 @@ External triggers are also session-bound, but they do not have their own
schedule. Create one from the target chat with `/trigger <name>`, then call
`nanobot trigger <id> "<message>"` when a local script or external service wants
nanobot to respond in that session. Webhook servers, third-party auth, and
event-to-message formatting stay outside nanobot.
event-to-message formatting stay outside nanobot. Trigger deliveries are stored
in the workspace until the gateway consumes them and are requeued on gateway
restart if processing was interrupted. Delivery is at-least-once, so external
systems should tolerate repeated trigger messages.
## Where to Go Next
+5
View File
@@ -132,6 +132,11 @@ If a GitHub webhook, CI system, or another service should wake nanobot up, keep
that webhook/service outside nanobot and have it call the trigger command with
the final message.
Trigger deliveries use the same workspace as the gateway. They survive gateway
restarts and are requeued if the process exits before marking a delivery
complete. This is an at-least-once local queue, so repeated delivery is possible
after an interrupted process.
For recurring background checks that should stay quiet unless there is something
useful to report, use the protected heartbeat job by editing `HEARTBEAT.md`
instead of creating a chat automation.
+6
View File
@@ -25,6 +25,12 @@ async def run_external_trigger_queue(
) -> None:
"""Poll local trigger deliveries and publish them as normal inbound messages."""
logger.info("External trigger queue started")
recovered = store.recover_processing_deliveries()
if recovered:
logger.warning(
"Trigger: recovered {} interrupted delivery file(s) from processing",
recovered,
)
while True:
deliveries = store.claim_deliveries(limit=batch_size)
if not deliveries:
+39 -13
View File
@@ -19,6 +19,7 @@ from nanobot.triggers.types import ExternalTrigger, TriggerDelivery, TriggerRunR
_TRIGGER_ID_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
_MAX_RUN_HISTORY = 20
_MAX_DELIVERY_ATTEMPTS = 10
_PROCESSING_RECOVERY_ERROR = "delivery was recovered from interrupted processing"
class TriggerStoreError(RuntimeError):
@@ -194,6 +195,26 @@ class ExternalTriggerStore:
claimed.append(delivery)
return claimed
def recover_processing_deliveries(self) -> int:
"""Requeue deliveries left in processing by an interrupted gateway."""
self._ensure_dirs()
recovered = 0
with self._lock:
for path in sorted(self.processing_dir.glob("*.json")):
try:
data = json.loads(path.read_text(encoding="utf-8"))
delivery = TriggerDelivery.from_dict(
data.get("delivery", data),
path=path,
)
except Exception:
logger.exception("Trigger: failed to parse processing delivery {}", path)
self._move_bad_delivery_unlocked(path)
continue
if self._retry_delivery_unlocked(delivery, _PROCESSING_RECOVERY_ERROR):
recovered += 1
return recovered
def complete_delivery(self, delivery: TriggerDelivery) -> None:
"""Delete a claimed delivery after it is handled."""
if delivery.path is None:
@@ -208,19 +229,7 @@ class ExternalTriggerStore:
return False
self._ensure_dirs()
with self._lock:
if delivery.attempts + 1 >= _MAX_DELIVERY_ATTEMPTS:
delivery.attempts += 1
delivery.last_error = error
failed = self.failed_dir / delivery.path.name
self._atomic_write(failed, json.dumps(_delivery_payload(delivery), ensure_ascii=False))
delivery.path.unlink(missing_ok=True)
return False
delivery.attempts += 1
delivery.last_error = error
target = self.inbox_dir / delivery.path.name
self._atomic_write(target, json.dumps(_delivery_payload(delivery), ensure_ascii=False))
delivery.path.unlink(missing_ok=True)
return True
return self._retry_delivery_unlocked(delivery, error)
def record_delivery(
self,
@@ -298,6 +307,23 @@ class ExternalTriggerStore:
with suppress(OSError):
os.replace(path, target)
def _retry_delivery_unlocked(self, delivery: TriggerDelivery, error: str) -> bool:
if delivery.path is None:
return False
if delivery.attempts + 1 >= _MAX_DELIVERY_ATTEMPTS:
delivery.attempts += 1
delivery.last_error = error
failed = self.failed_dir / delivery.path.name
self._atomic_write(failed, json.dumps(_delivery_payload(delivery), ensure_ascii=False))
delivery.path.unlink(missing_ok=True)
return False
delivery.attempts += 1
delivery.last_error = error
target = self.inbox_dir / delivery.path.name
self._atomic_write(target, json.dumps(_delivery_payload(delivery), ensure_ascii=False))
delivery.path.unlink(missing_ok=True)
return True
@staticmethod
def _atomic_write(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
+66
View File
@@ -49,6 +49,33 @@ def test_enqueue_rejects_disabled_trigger(tmp_path: Path) -> None:
store.enqueue(trigger.id, "Review PR #4502")
def test_recover_processing_deliveries_requeues_claimed_delivery(tmp_path: Path) -> None:
store = ExternalTriggerStore(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 ExternalTriggerStore(tmp_path).claim_deliveries() == []
restarted = ExternalTriggerStore(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_external_trigger_queue_publishes_bound_inbound_message(tmp_path: Path) -> None:
store = ExternalTriggerStore(tmp_path)
@@ -99,3 +126,42 @@ async def test_external_trigger_queue_publishes_bound_inbound_message(tmp_path:
assert stored.last_status == "ok"
assert stored.last_run_at_ms is not None
assert store.claim_deliveries() == []
@pytest.mark.asyncio
async def test_external_trigger_queue_recovers_processing_delivery_on_start(
tmp_path: Path,
) -> None:
store = ExternalTriggerStore(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
published: list[InboundMessage] = []
class _Bus:
async def publish_inbound(self, msg: InboundMessage) -> None:
published.append(msg)
restarted = ExternalTriggerStore(tmp_path)
task = asyncio.create_task(
run_external_trigger_queue(store=restarted, bus=_Bus(), poll_interval_s=0.01)
)
try:
for _ in range(100):
if published:
break
await asyncio.sleep(0.01)
finally:
task.cancel()
with suppress(asyncio.CancelledError):
await task
assert len(published) == 1
assert published[0].content == "Review PR #4591"
assert published[0].metadata["_external_trigger"]["trigger_id"] == trigger.id
assert restarted.claim_deliveries() == []