mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
* feat(persistence): add notification delivery outbox for scheduled task IM push New notification_deliveries table and repository (migration 0012) backing the outbox pattern for issue #4254: idempotent enqueue keyed on task_run_id plus event plus provider plus target, atomic claim of due rows, and exponential-backoff retry state moving pending to sending to sent or failed. Execution status stays in scheduled_task_runs; delivery status lives here. * feat(scheduler): enqueue and deliver scheduled-task notifications over IM channels Closes the loop for issue #4254. On run completion the scheduler writes outbox rows for the task owner's connected channel identities (success and failure events; interrupts stay silent), and a new NotificationDeliveryWorker polls due rows and pushes them through the channel's proactive send path. Channel gains send_notification; WeCom implements it via the WS client's send_message with errcode checking, the base class raises for providers without proactive push. Delivery is best-effort and never shadows execution status: enqueue and send failures are logged and recorded in the outbox, which retries with backoff. Channel liveness is re-checked at delivery time, and the worker stops before the channel service on shutdown. Feature activates only when channel connections are enabled; no new dependencies. Verified end to end against a live WeCom bot: scheduled run completion produced an outbox row that was delivered in under one second. * feat(scheduler): include scheduled run result summary in IM notifications The v1 notification only carried the run outcome skeleton (status, task id, run id). Completed runs now include the agent's final answer so the IM push is actually useful headlessly. - The delivery worker accepts an optional resolve_run_summary(run_id, user_id) resolver; for run_completed events it enriches the rendered text with a "Result:" line (bounded to 1000 chars). The lookup is best-effort: resolver failures or missing summaries degrade to the skeleton text and never block or fail the delivery. - The summary is read at delivery time from runs.last_ai_message (the convenience column written on run completion), not snapshotted into the outbox payload; retried deliveries re-read the freshest value and the outbox row payload stays skeleton-only. The claimed outbox row is never mutated in place. - Gateway wiring scopes the read to the outbox row's owner_user_id so a stale row cannot pull another user's run content. - Failed runs keep the error-only skeleton: a partial answer on a failed run would be misleading. Tests: 9 new worker cases (rendering incl. truncation, sync/async resolvers, failure/missing fallbacks, no resolver call for failed runs). Full scheduled + outbox + channels regression green (441 passed), ruff check + format clean. * fix(scheduler): address review feedback on notification delivery reliability Review follow-ups for the scheduled-task IM notification outbox: 1. Crash recovery for "sending" rows: claim now stamps updated_at and the worker reconciles rows stuck in "sending" past a 10-minute lease back to "pending" before every claim. Per-row try/except isolates a crashing delivery from the rest of the claimed batch. 2. Double-send race under multiple workers: claim returns exactly the rows its own UPDATE...RETURNING flipped (guarded by status='pending'), so a rival worker's concurrent claim can no longer be handed out twice. 3. Restore non-ASCII comments and drop the BOM accidentally introduced in test_scheduled_task_service.py. 4. Manual "run now" completions no longer enqueue IM notifications; the user is already watching the UI. Missing trigger metadata fails safe towards notifying. 5. Channel-outage failures no longer consume the retry budget: they are parked with count_attempt=False on a flat 15-minute backoff so an hours-long outage cannot exhaust retries and drop the notification. Tests: repository 16, worker 21, scheduled-task service 31; targeted regression across notification/scheduler/channel suites: 511 passed. * fix(scheduler): address round-2 review on IM notification delivery Park WeCom transport/outage failures without burning retries, bound worker stop, include task titles, align enqueue with delivery, and document the IM push behavior. Co-authored-by: Cursor <cursoragent@cursor.com> * docs(scheduler): complete IM notification docs and drop unused list API Add backend/channels/migrations AGENTS notes, CHANGELOG and config.example hints for the outbox push path, and remove the unused list_by_task_run repository method flagged in review. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scheduler): address round-3 review on IM notification delivery Cap channel-down parking by row age and parked attempts, redact IM egress content, narrow WeCom transport exceptions, add lifespan wiring tests, and run ruff format so lint-backend passes. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scheduler): redact entire PEM blocks in IM egress text The previous BEGIN-only pattern left key material and the END marker in notifications. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(persistence): align 0014 parked_attempts with bootstrap schema tests Backfill existing rows with a temporary server default, then drop it so create_all matches Alembic, and move HEAD anchors to 0014. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(persistence): format notification tests and shorten alembic revision id ruff format --check failed on test_notification_delivery_worker.py. 0015_notification_delivery_parked_attempts exceeds alembic_version.version_num (VARCHAR 32) and truncates on Postgres CI; use 0015_parked_attempts instead. * test: fix post-merge CI failures after migration renumber Update migration 0015 test HEAD expectation to 0017_parked_attempts and add queue_timeout_seconds to the lifespan notification worker mock scheduler config. * fix(persistence): chain the notification outbox migrations after 0026 Main's head moved from 0016 to 0026_mcp_task_lease_tokens, so the two outbox revisions are renumbered and re-parented: - 0017_notification_deliveries -> 0027_notification_deliveries - 0018_parked_attempts -> 0028_parked_attempts Only the revision ids, parents and the migration guide change. * fix(scheduler): adapt the notification hook and its docs to main - tests: the completion tests assert on the atomic complete_run record; new cases cover a completion that was not recorded, a task deleted meanwhile, a failed task read, and the outbox being off. - AGENTS.md: drop the root note and shorten the backend note to a pointer; the two notes pushed two effective guidance chains past the 96 KiB limit. - CHANGELOG: move the entry from the released 2.1.0 section to Unreleased and add its link references. * test(persistence): move the chain-head pin to the outbox migrations test_migration_0026 pinned 0026 as the head, so it failed once the outbox revisions chained after it. It now checks chain membership like the older migration tests, and a new test file owns the head pin, checks the parents and the 32-character revision id limit, and runs the upgrade and downgrade. * fix(gateway): wire scheduled-task notifications after the channel service starts The outbox wiring sat in the scheduler block and asked for the running channel service there. #5035 moved the channel service start to after the scheduler, and the two changes merged without a conflict, so on every startup the lookup returned None and the feature disabled itself with the "write-only outbox" warning. The lifespan tests patched the lookup to a constant and kept passing. The wiring now runs in its own step after the channel service start. The scheduler is built as on main, and ScheduledTaskService gains attach_notification_outbox() so the enqueue side is enabled once the delivery worker is running. The guard, the worker and the shutdown order are unchanged. Tests: the lifespan helper can model the real accessor (None until the channel service has started); a new case fails without this fix. * fix(scheduler): enqueue from scheduler start and keep the push when the title read fails Follow-up to the startup-order fix, from review. - The enqueue side is wired with the scheduler again (it only needs the durable table), and only the delivery worker waits for the channel service. Attaching it later left a window after the scheduler started in which a finished run wrote no outbox row. If the channel service is missing or the worker cannot start, the enqueue side is detached, so the outbox still never becomes write-only. - A failed task read after complete_run no longer drops the notification: the title is optional and the message falls back to the task id. A task deleted meanwhile still stays silent. * fix(channels): park WeCom notifications while the SDK socket is closed The WeCom SDK raises a plain RuntimeError when its socket is not open, and send_notification only mapped ConnectionError, TimeoutError and OSError to ChannelUnavailable. A disconnect therefore spent the counted retry budget and settled the row as failed after about 15 minutes, although the docs say transport outages park without consuming retries. send_notification now asks the client's is_connected first. The new test uses a real, never-connected SDK client: it raised RuntimeError after three retries before, and parks now. * docs(scheduler): describe which outcomes notify; mirror the changelog entry - README (en/zh) and CONFIGURATION.md: only runs that finish as success or failed notify; occurrences that end without a finished run (launch error, queue timeout, restart recovery) do not. Parking lasts up to about a day. - CHANGELOG: cite the issue as text like the rest of the file, and add the entry and link reference to CHANGELOG_zh.md. - AGENTS.md: drop the backend pointer so this branch adds nothing to the guidance chains closest to their size limit; the scheduler-side rules and the lifespan order now live in app/channels/AGENTS.md. * docs(changelog): cite #6135 next to #4843 for the notification outbox entry * fix(scheduler,channels): re-check the IM binding at delivery time; park WeCom transport errors, count its rejections Review round 1 on #6135. The delivery worker now takes resolve_connections (the Gateway wires ChannelConnectionRepository.list_connections) and, before the channel liveness check and the summary read, requires the row's owner to still hold a connected (provider, external_account_id). A disconnected target is finalized with mark_failed(terminal=True); a lookup failure or an unexpected result shape parks the row. Terminal outbox rows ignore later status writes. WeCom: the SDK raises RuntimeError from send_message for a socket that is not open and for a dropped connection, but also for a platform rejection ("Reply ack error: errcode=..."). Only that prefix keeps counting; other RuntimeErrors, ConnectionError/TimeoutError/OSError and websockets' ConnectionClosed park the row as ChannelUnavailable. * fix(channels): treat a WeCom ACK rejection as proof the transport is up; pin the rejection prefix through the retry branch and the real connection-row shape --------- Co-authored-by: GaoTianyuanhr <75788039+GaoTianyuanhr@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: GaoTianyuanhr <GaoTianyuanhr@users.noreply.github.com> Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
831 lines
28 KiB
Python
831 lines
28 KiB
Python
"""Tests for the notification delivery worker (issue #4254).
|
|
|
|
The worker consumes the notification outbox written by the scheduled-task
|
|
completion hook and pushes each delivery through the owning channel's
|
|
proactive send path. It must never touch execution state.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from datetime import UTC, datetime
|
|
|
|
import pytest
|
|
|
|
from app.channels.base import ChannelUnavailable
|
|
from app.channels.message_bus import MessageBus
|
|
from app.channels.wecom import WeComChannel
|
|
from app.scheduler.notification_delivery import NotificationDeliveryWorker, redact_egress_text, render_notification_text
|
|
|
|
|
|
class FakeDeliveryRepo:
|
|
def __init__(self, rows=None):
|
|
self.rows = list(rows or [])
|
|
self.claims = []
|
|
self.sent = []
|
|
self.failed = []
|
|
self.terminal_failures = []
|
|
self.resets = []
|
|
self.events = []
|
|
|
|
async def reset_stale_sending_rows(self, *, now, timeout):
|
|
self.resets.append((now, timeout))
|
|
self.events.append("reset")
|
|
return 0
|
|
|
|
async def claim_due_deliveries(self, *, now, limit):
|
|
claimed, self.rows = self.rows[:limit], self.rows[limit:]
|
|
self.claims.append((now, limit))
|
|
self.events.append("claim")
|
|
return claimed
|
|
|
|
async def mark_sent(self, delivery_id):
|
|
self.sent.append(delivery_id)
|
|
|
|
async def mark_failed(self, delivery_id, *, error=None, count_attempt=True, terminal=False):
|
|
self.failed.append((delivery_id, error, count_attempt))
|
|
if terminal:
|
|
self.terminal_failures.append((delivery_id, error))
|
|
|
|
|
|
class FakeChannel:
|
|
def __init__(self, *, fail=False, unavailable=False, running=True):
|
|
self.sent = []
|
|
self.fail = fail
|
|
self.unavailable = unavailable
|
|
self.is_running = running
|
|
|
|
async def send_notification(self, *, target, text_markdown):
|
|
if self.unavailable:
|
|
raise ChannelUnavailable("channel transport down")
|
|
if self.fail:
|
|
raise RuntimeError("platform rejected the push")
|
|
self.sent.append((target, text_markdown))
|
|
|
|
|
|
def _delivery_row(
|
|
*,
|
|
delivery_id="delivery-1",
|
|
event="run_completed",
|
|
provider="wecom",
|
|
target="GaoZhiChao",
|
|
payload=None,
|
|
):
|
|
return {
|
|
"id": delivery_id,
|
|
"task_id": "task-1",
|
|
"task_run_id": "task-run-1",
|
|
"run_id": "run-1",
|
|
"event": event,
|
|
"provider": provider,
|
|
"target": target,
|
|
"owner_user_id": "user-1",
|
|
"status": "sending",
|
|
"payload": payload if payload is not None else {"run_status": "success", "error": None, "task_id": "task-1"},
|
|
}
|
|
|
|
|
|
def _make_worker(repo, channel=None, resolve_run_summary=None, resolve_connections=None):
|
|
channels = {}
|
|
if channel is not None:
|
|
channels["wecom"] = channel
|
|
return NotificationDeliveryWorker(
|
|
delivery_repo=repo,
|
|
resolve_channel=lambda provider: channels.get(provider),
|
|
poll_interval_seconds=5,
|
|
resolve_run_summary=resolve_run_summary,
|
|
resolve_connections=resolve_connections,
|
|
)
|
|
|
|
|
|
def _connection(*, provider="wecom", external_account_id="GaoZhiChao", status="connected", owner_user_id="user-1"):
|
|
return {
|
|
"id": f"conn-{provider}-{external_account_id}",
|
|
"owner_user_id": owner_user_id,
|
|
"provider": provider,
|
|
"external_account_id": external_account_id,
|
|
"status": status,
|
|
}
|
|
|
|
|
|
def test_render_run_completed_text_includes_task_and_run():
|
|
text = render_notification_text(_delivery_row())
|
|
|
|
assert "completed" in text.lower()
|
|
assert "task-1" in text
|
|
assert "run-1" in text
|
|
|
|
|
|
def test_render_prefers_task_title_over_id():
|
|
text = render_notification_text(_delivery_row(payload={"run_status": "success", "error": None, "task_id": "task-1", "task_title": "Daily digest"}))
|
|
|
|
assert "Daily digest" in text
|
|
task_line = next(line for line in text.splitlines() if line.startswith("Task:"))
|
|
assert "task-1" not in task_line
|
|
|
|
|
|
def test_render_run_failed_text_does_not_forward_raw_error():
|
|
secret = "sk-" + ("x" * 40)
|
|
text = render_notification_text(
|
|
_delivery_row(
|
|
event="run_failed",
|
|
payload={"run_status": "failed", "error": secret, "task_id": "task-1"},
|
|
)
|
|
)
|
|
|
|
assert "failed" in text.lower()
|
|
assert secret not in text
|
|
assert "workspace" in text.lower()
|
|
assert "Error:" not in text
|
|
|
|
|
|
def test_render_truncates_unbounded_error_text():
|
|
text = render_notification_text(
|
|
_delivery_row(
|
|
event="run_failed",
|
|
payload={"run_status": "failed", "error": "x" * 5000, "task_id": "task-1"},
|
|
)
|
|
)
|
|
|
|
assert len(text) < 1000
|
|
assert "Error:" not in text
|
|
|
|
|
|
def test_redact_egress_text_scrubs_seeded_secret():
|
|
secret = "sk-" + ("S" * 40)
|
|
redacted = redact_egress_text(f"answer used {secret} here")
|
|
|
|
assert secret not in redacted
|
|
assert "[redacted]" in redacted
|
|
|
|
|
|
def test_redact_egress_text_scrubs_entire_pem_block():
|
|
pem_body = "ABCDEFSECRETKEYBODY"
|
|
pem = "-----BEGIN PRIVATE KEY-----\n" + pem_body + "\n-----END PRIVATE KEY-----"
|
|
redacted = redact_egress_text(f"key follows\n{pem}\nend")
|
|
|
|
assert pem_body not in redacted
|
|
assert "BEGIN PRIVATE KEY" not in redacted
|
|
assert "END PRIVATE KEY" not in redacted
|
|
assert "[redacted]" in redacted
|
|
|
|
|
|
def test_render_redacts_entire_pem_block_in_result_summary():
|
|
pem_body = "ABCDEFSECRETKEYBODY"
|
|
pem = "-----BEGIN PRIVATE KEY-----\n" + pem_body + "\n-----END PRIVATE KEY-----"
|
|
text = render_notification_text(_delivery_row(payload={"run_status": "success", "error": None, "task_id": "task-1", "result_summary": pem}))
|
|
|
|
assert pem_body not in text
|
|
assert "END PRIVATE KEY" not in text
|
|
assert "[redacted]" in text
|
|
|
|
|
|
def test_render_redacts_secret_in_result_summary():
|
|
secret = "ghp_" + ("a" * 36)
|
|
text = render_notification_text(_delivery_row(payload={"run_status": "success", "error": None, "task_id": "task-1", "result_summary": f"token={secret}"}))
|
|
|
|
assert secret not in text
|
|
assert "[redacted]" in text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_once_delivers_claimed_row_and_marks_sent():
|
|
repo = FakeDeliveryRepo([_delivery_row()])
|
|
channel = FakeChannel()
|
|
worker = _make_worker(repo, channel)
|
|
|
|
await worker.run_once(now=datetime.now(UTC))
|
|
|
|
assert repo.sent == ["delivery-1"]
|
|
assert repo.failed == []
|
|
assert len(channel.sent) == 1
|
|
target, text = channel.sent[0]
|
|
assert target == "GaoZhiChao"
|
|
assert "task-1" in text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_once_marks_failed_when_channel_is_not_running():
|
|
repo = FakeDeliveryRepo([_delivery_row()])
|
|
worker = _make_worker(repo, channel=None)
|
|
|
|
await worker.run_once(now=datetime.now(UTC))
|
|
|
|
assert repo.sent == []
|
|
assert len(repo.failed) == 1
|
|
delivery_id, error, count_attempt = repo.failed[0]
|
|
assert delivery_id == "delivery-1"
|
|
assert "wecom" in error
|
|
# A channel that is simply not running must not burn the retry budget:
|
|
# the row comes back on a flat long backoff until the channel returns.
|
|
assert count_attempt is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_once_parks_when_registered_channel_reports_not_running():
|
|
repo = FakeDeliveryRepo([_delivery_row()])
|
|
worker = _make_worker(repo, FakeChannel(running=False))
|
|
|
|
await worker.run_once(now=datetime.now(UTC))
|
|
|
|
assert repo.sent == []
|
|
_, error, count_attempt = repo.failed[0]
|
|
assert "not running" in error
|
|
assert count_attempt is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_once_parks_without_counting_channel_unavailable():
|
|
repo = FakeDeliveryRepo([_delivery_row()])
|
|
worker = _make_worker(repo, FakeChannel(unavailable=True))
|
|
|
|
await worker.run_once(now=datetime.now(UTC))
|
|
|
|
assert repo.sent == []
|
|
delivery_id, error, count_attempt = repo.failed[0]
|
|
assert delivery_id == "delivery-1"
|
|
assert "transport down" in error
|
|
assert count_attempt is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_once_sends_when_target_is_still_connected():
|
|
repo = FakeDeliveryRepo([_delivery_row()])
|
|
channel = FakeChannel()
|
|
seen = []
|
|
|
|
async def resolve_connections(owner_user_id):
|
|
seen.append(owner_user_id)
|
|
return [_connection(), _connection(provider="slack", external_account_id="U1")]
|
|
|
|
worker = _make_worker(repo, channel, resolve_connections=resolve_connections)
|
|
|
|
await worker.run_once(now=datetime.now(UTC))
|
|
|
|
assert seen == ["user-1"]
|
|
assert repo.sent == ["delivery-1"]
|
|
assert repo.failed == []
|
|
assert repo.terminal_failures == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_once_drops_delivery_whose_target_was_disconnected():
|
|
# The owner unbound the identity after the row was enqueued (the row can
|
|
# wait up to a day in parking). The push must not go out, and there is
|
|
# nothing to wait for: the row is finalized, not parked or retried.
|
|
repo = FakeDeliveryRepo([_delivery_row()])
|
|
channel = FakeChannel()
|
|
worker = _make_worker(repo, channel, resolve_connections=lambda owner: [_connection(status="revoked")])
|
|
|
|
await worker.run_once(now=datetime.now(UTC))
|
|
|
|
assert channel.sent == []
|
|
assert repo.sent == []
|
|
assert repo.terminal_failures == [("delivery-1", "target is no longer a connected wecom identity of its owner")]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_once_ignores_another_owners_binding_of_the_same_target():
|
|
# The resolver is called with the row's own owner, and a returned row that
|
|
# names a different owner is skipped too, so a resolver that is not
|
|
# owner-scoped still cannot make another user's connected binding of the
|
|
# same external account count for this delivery.
|
|
repo = FakeDeliveryRepo([_delivery_row()])
|
|
channel = FakeChannel()
|
|
calls = []
|
|
|
|
def resolve_connections(owner_user_id):
|
|
calls.append(owner_user_id)
|
|
return [_connection(owner_user_id="user-OTHER")]
|
|
|
|
worker = _make_worker(repo, channel, resolve_connections=resolve_connections)
|
|
|
|
await worker.run_once(now=datetime.now(UTC))
|
|
|
|
assert calls == ["user-1"]
|
|
assert channel.sent == []
|
|
assert repo.sent == []
|
|
assert len(repo.terminal_failures) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_once_parks_when_connection_lookup_returns_an_unexpected_shape():
|
|
# A dict or a str is iterable and would "find" nothing; that is a lookup
|
|
# problem to park on, not proof that the binding is gone.
|
|
repo = FakeDeliveryRepo([_delivery_row()])
|
|
channel = FakeChannel()
|
|
worker = _make_worker(repo, channel, resolve_connections=lambda owner: {"items": [_connection()]})
|
|
|
|
await worker.run_once(now=datetime.now(UTC))
|
|
|
|
assert channel.sent == []
|
|
delivery_id, error, count_attempt = repo.failed[0]
|
|
assert "could not verify" in error
|
|
assert count_attempt is False
|
|
assert repo.terminal_failures == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_once_drops_a_disconnected_target_even_while_the_channel_is_down():
|
|
# Disconnecting a provider revokes its connections and stops its channel
|
|
# together. The binding check runs first, so the row is finalized instead
|
|
# of parking for a day behind the channel-down branch.
|
|
repo = FakeDeliveryRepo([_delivery_row()])
|
|
worker = _make_worker(repo, FakeChannel(running=False), resolve_connections=lambda owner: [_connection(status="revoked")])
|
|
|
|
await worker.run_once(now=datetime.now(UTC))
|
|
|
|
assert repo.sent == []
|
|
assert len(repo.terminal_failures) == 1
|
|
assert all(count_attempt is True for _, _, count_attempt in repo.failed) # no park entry was written
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_once_parks_when_connection_lookup_fails():
|
|
# A store error must neither drop the row nor send against unknown intent.
|
|
repo = FakeDeliveryRepo([_delivery_row()])
|
|
channel = FakeChannel()
|
|
|
|
async def resolve_connections(owner_user_id):
|
|
raise RuntimeError("database unavailable")
|
|
|
|
worker = _make_worker(repo, channel, resolve_connections=resolve_connections)
|
|
|
|
await worker.run_once(now=datetime.now(UTC))
|
|
|
|
assert channel.sent == []
|
|
assert repo.sent == []
|
|
delivery_id, error, count_attempt = repo.failed[0]
|
|
assert delivery_id == "delivery-1"
|
|
assert "could not verify" in error
|
|
assert count_attempt is False
|
|
assert repo.terminal_failures == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_once_checks_the_connection_before_resolving_the_summary():
|
|
# The run content must not be read for a push that will not be sent.
|
|
repo = FakeDeliveryRepo([_delivery_row()])
|
|
channel = FakeChannel()
|
|
summary_calls = []
|
|
|
|
async def resolve_run_summary(run_id, user_id):
|
|
summary_calls.append(run_id)
|
|
return "final answer"
|
|
|
|
worker = _make_worker(repo, channel, resolve_run_summary=resolve_run_summary, resolve_connections=lambda owner: [])
|
|
|
|
await worker.run_once(now=datetime.now(UTC))
|
|
|
|
assert summary_calls == []
|
|
assert channel.sent == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_once_marks_failed_when_send_raises():
|
|
repo = FakeDeliveryRepo([_delivery_row()])
|
|
worker = _make_worker(repo, FakeChannel(fail=True))
|
|
|
|
await worker.run_once(now=datetime.now(UTC))
|
|
|
|
assert repo.sent == []
|
|
delivery_id, error, count_attempt = repo.failed[0]
|
|
assert delivery_id == "delivery-1"
|
|
assert "platform rejected" in error
|
|
# A real send error does count against the retry budget.
|
|
assert count_attempt is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_cancels_when_poller_exceeds_timeout():
|
|
repo = FakeDeliveryRepo([])
|
|
worker = NotificationDeliveryWorker(
|
|
delivery_repo=repo,
|
|
resolve_channel=lambda _provider: None,
|
|
poll_interval_seconds=60,
|
|
stop_timeout_seconds=0.05,
|
|
)
|
|
|
|
async def _hang_forever():
|
|
await asyncio.Event().wait()
|
|
|
|
worker._stop.clear()
|
|
worker._task = asyncio.create_task(_hang_forever())
|
|
|
|
await worker.stop()
|
|
|
|
assert worker._task is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_once_without_due_rows_is_noop():
|
|
repo = FakeDeliveryRepo([])
|
|
worker = _make_worker(repo, FakeChannel())
|
|
|
|
await worker.run_once(now=datetime.now(UTC))
|
|
|
|
assert repo.sent == []
|
|
assert repo.failed == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Crash recovery (review follow-up): a row exploding mid-delivery must not
|
|
# strand the rest of the batch in "sending", and rows orphaned between claim
|
|
# and the final status write come back through the stale reset.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_once_recovers_stale_sending_rows_before_claiming():
|
|
repo = FakeDeliveryRepo([_delivery_row()])
|
|
worker = _make_worker(repo, FakeChannel())
|
|
now = datetime.now(UTC)
|
|
|
|
await worker.run_once(now=now)
|
|
|
|
assert repo.events == ["reset", "claim"]
|
|
assert repo.resets[0][0] == now
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_once_isolates_crashes_so_rest_of_batch_still_delivers():
|
|
"""Replay the reviewer's worst case: row one crashes even its own
|
|
mark_failed (e.g. the DB blip that killed the status write), which used
|
|
to abort the loop and strand every following claimed row in
|
|
``sending``."""
|
|
|
|
class ExplodingRepo(FakeDeliveryRepo):
|
|
async def mark_failed(self, delivery_id, **kwargs):
|
|
if delivery_id == "delivery-1":
|
|
raise RuntimeError("db gone")
|
|
await super().mark_failed(delivery_id, **kwargs)
|
|
|
|
class TargetFailChannel(FakeChannel):
|
|
async def send_notification(self, *, target, text_markdown):
|
|
if target == "GaoZhiChao":
|
|
raise RuntimeError("platform rejected")
|
|
await super().send_notification(target=target, text_markdown=text_markdown)
|
|
|
|
repo = ExplodingRepo(
|
|
[
|
|
_delivery_row(delivery_id="delivery-1"),
|
|
_delivery_row(delivery_id="delivery-2", target="bob"),
|
|
]
|
|
)
|
|
worker = _make_worker(repo, TargetFailChannel())
|
|
|
|
await worker.run_once(now=datetime.now(UTC))
|
|
|
|
# Row one is left for the stale reset to recover; row two must not be
|
|
# sacrificed with it.
|
|
assert repo.sent == ["delivery-2"]
|
|
assert repo.failed == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_stop_lifecycle():
|
|
repo = FakeDeliveryRepo([])
|
|
worker = _make_worker(repo, FakeChannel())
|
|
|
|
await worker.start()
|
|
await worker.stop()
|
|
# Idempotent stop mirrors the scheduler service contract.
|
|
await worker.stop()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Result summary enrichment (issue #4254): completed runs carry the agent's
|
|
# final answer into the IM push; failed runs keep the error-only skeleton.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_render_includes_result_summary_for_completed_runs():
|
|
text = render_notification_text(_delivery_row(payload={"run_status": "success", "error": None, "task_id": "task-1", "result_summary": "The answer is 42"}))
|
|
|
|
assert "The answer is 42" in text
|
|
|
|
|
|
def test_render_truncates_long_result_summary():
|
|
text = render_notification_text(_delivery_row(payload={"run_status": "success", "error": None, "task_id": "task-1", "result_summary": "x" * 5000}))
|
|
|
|
# Bounded on the wire: 999 chars of summary + the ellipsis marker.
|
|
result_line = next(line for line in text.splitlines() if line.startswith("Result: "))
|
|
assert len(result_line) == len("Result: ") + 1000
|
|
assert result_line.endswith("\u2026")
|
|
|
|
|
|
def test_render_ignores_result_summary_for_failed_runs():
|
|
text = render_notification_text(
|
|
_delivery_row(
|
|
event="run_failed",
|
|
payload={"run_status": "failed", "error": "boom", "task_id": "task-1", "result_summary": "partial answer"},
|
|
)
|
|
)
|
|
|
|
assert "partial answer" not in text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delivery_enriches_completed_run_with_summary():
|
|
row = _delivery_row()
|
|
repo = FakeDeliveryRepo([row])
|
|
channel = FakeChannel()
|
|
seen = []
|
|
|
|
def resolver(run_id, user_id):
|
|
seen.append((run_id, user_id))
|
|
return "The answer is 42"
|
|
|
|
worker = _make_worker(repo, channel, resolve_run_summary=resolver)
|
|
|
|
await worker.run_once(now=datetime.now(UTC))
|
|
|
|
assert seen == [("run-1", "user-1")]
|
|
assert repo.sent == ["delivery-1"]
|
|
assert "The answer is 42" in channel.sent[0][1]
|
|
# The claimed outbox row itself must not be mutated in place.
|
|
assert "result_summary" not in row["payload"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delivery_supports_async_summary_resolver():
|
|
repo = FakeDeliveryRepo([_delivery_row()])
|
|
channel = FakeChannel()
|
|
|
|
async def resolver(run_id, user_id):
|
|
return "async answer"
|
|
|
|
worker = _make_worker(repo, channel, resolve_run_summary=resolver)
|
|
|
|
await worker.run_once(now=datetime.now(UTC))
|
|
|
|
assert repo.sent == ["delivery-1"]
|
|
assert "async answer" in channel.sent[0][1]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delivery_skips_resolver_for_failed_runs():
|
|
repo = FakeDeliveryRepo([_delivery_row(event="run_failed", payload={"run_status": "failed", "error": "boom", "task_id": "task-1"})])
|
|
channel = FakeChannel()
|
|
called = []
|
|
|
|
def resolver(run_id, user_id):
|
|
called.append(run_id)
|
|
return "should not appear"
|
|
|
|
worker = _make_worker(repo, channel, resolve_run_summary=resolver)
|
|
|
|
await worker.run_once(now=datetime.now(UTC))
|
|
|
|
assert called == []
|
|
assert repo.sent == ["delivery-1"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delivery_falls_back_to_skeleton_when_resolver_raises():
|
|
repo = FakeDeliveryRepo([_delivery_row()])
|
|
channel = FakeChannel()
|
|
|
|
def resolver(run_id, user_id):
|
|
raise RuntimeError("db unavailable")
|
|
|
|
worker = _make_worker(repo, channel, resolve_run_summary=resolver)
|
|
|
|
await worker.run_once(now=datetime.now(UTC))
|
|
|
|
# A summary lookup failure must never block the notification itself.
|
|
assert repo.sent == ["delivery-1"]
|
|
assert repo.failed == []
|
|
assert "task-1" in channel.sent[0][1]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delivery_sends_skeleton_when_summary_is_missing():
|
|
repo = FakeDeliveryRepo([_delivery_row()])
|
|
channel = FakeChannel()
|
|
worker = _make_worker(repo, channel, resolve_run_summary=lambda run_id, user_id: None)
|
|
|
|
await worker.run_once(now=datetime.now(UTC))
|
|
|
|
assert repo.sent == ["delivery-1"]
|
|
text = channel.sent[0][1]
|
|
assert "task-1" in text
|
|
assert "Result:" not in text
|
|
|
|
|
|
class FakeWsClient:
|
|
def __init__(self, *, errcode=0):
|
|
self.calls = []
|
|
self.errcode = errcode
|
|
|
|
async def send_message(self, chatid, body):
|
|
self.calls.append((chatid, body))
|
|
return {"errcode": self.errcode, "errmsg": "ok"}
|
|
|
|
|
|
def _wecom_channel(ws_client=None):
|
|
channel = WeComChannel(bus=MessageBus(), config={})
|
|
channel._ws_client = ws_client
|
|
channel._running = True
|
|
channel._ws_transport_up = ws_client is not None
|
|
return channel
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wecom_send_notification_pushes_markdown():
|
|
ws_client = FakeWsClient()
|
|
channel = _wecom_channel(ws_client)
|
|
|
|
await channel.send_notification(target="GaoZhiChao", text_markdown="**done**")
|
|
|
|
assert len(ws_client.calls) == 1
|
|
chatid, body = ws_client.calls[0]
|
|
assert chatid == "GaoZhiChao"
|
|
assert body == {"msgtype": "markdown", "markdown": {"content": "**done**"}}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wecom_send_notification_raises_on_platform_error():
|
|
ws_client = FakeWsClient(errcode=853000)
|
|
channel = _wecom_channel(ws_client)
|
|
|
|
with pytest.raises(RuntimeError, match="853000"):
|
|
await channel.send_notification(target="GaoZhiChao", text_markdown="**done**")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wecom_send_notification_raises_when_not_connected():
|
|
channel = _wecom_channel(None)
|
|
channel._running = True
|
|
|
|
with pytest.raises(ChannelUnavailable, match="not connected"):
|
|
await channel.send_notification(target="GaoZhiChao", text_markdown="**done**")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wecom_send_notification_parks_when_the_sdk_socket_is_closed():
|
|
# The real SDK client raises a plain RuntimeError when its socket is not open,
|
|
# which would count against the retry budget instead of parking the row.
|
|
aibot = pytest.importorskip("aibot")
|
|
ws_client = aibot.WSClient(aibot.WSClientOptions(bot_id="bot", secret="secret"))
|
|
assert ws_client.is_connected is False
|
|
channel = _wecom_channel(ws_client)
|
|
|
|
with pytest.raises(ChannelUnavailable, match="not connected"):
|
|
await channel.send_notification(target="GaoZhiChao", text_markdown="**done**")
|
|
|
|
assert channel._ws_transport_up is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wecom_send_notification_wraps_transport_errors_as_unavailable():
|
|
class BrokenWsClient:
|
|
async def send_message(self, chatid, body):
|
|
raise ConnectionError("websocket closed")
|
|
|
|
channel = _wecom_channel(BrokenWsClient())
|
|
channel._ws_transport_up = False # single probe; no retry/sleep storm
|
|
|
|
with pytest.raises(ChannelUnavailable, match="transport unavailable"):
|
|
await channel.send_notification(target="GaoZhiChao", text_markdown="**done**")
|
|
|
|
assert channel._ws_transport_up is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wecom_send_notification_parks_on_the_sdk_runtime_error():
|
|
# The SDK's send path raises plain RuntimeError for a closed socket, a
|
|
# connection dropped with replies pending and a full reply queue; none is
|
|
# a ConnectionError/OSError. With is_connected still (stale) True this used
|
|
# to escape the ChannelUnavailable wrapping and count against the budget.
|
|
class StaleSocketWsClient:
|
|
is_connected = True
|
|
|
|
def __init__(self):
|
|
self.calls = 0
|
|
|
|
async def send_message(self, chatid, body):
|
|
self.calls += 1
|
|
raise RuntimeError("WebSocket not connected, unable to send data")
|
|
|
|
ws_client = StaleSocketWsClient()
|
|
channel = _wecom_channel(ws_client)
|
|
channel._ws_transport_up = False # single probe; no retry/sleep storm
|
|
|
|
with pytest.raises(ChannelUnavailable, match="transport unavailable"):
|
|
await channel.send_notification(target="GaoZhiChao", text_markdown="**done**")
|
|
|
|
assert ws_client.calls == 1
|
|
assert channel._ws_transport_up is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wecom_send_notification_counts_the_sdk_ack_rejection():
|
|
# The SDK never returns a non-zero errcode ACK as a dict: _handle_reply_ack
|
|
# turns it into RuntimeError("Reply ack error: ...") from send_message. The
|
|
# socket is fine, so this must count against the budget, not park.
|
|
class RejectingWsClient:
|
|
is_connected = True
|
|
|
|
async def send_message(self, chatid, body):
|
|
raise RuntimeError("Reply ack error: errcode=60111, errmsg=invalid userid")
|
|
|
|
channel = _wecom_channel(RejectingWsClient())
|
|
channel._ws_transport_up = False # single probe; no retry/sleep storm
|
|
|
|
with pytest.raises(RuntimeError, match="errcode=60111"):
|
|
await channel.send_notification(target="GaoZhiChao", text_markdown="**done**")
|
|
|
|
# The ACK came back on this socket, so the transport is known to be up.
|
|
assert channel._ws_transport_up is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wecom_send_notification_rejection_prefix_survives_the_retry_branch(monkeypatch):
|
|
# With the transport believed up, the send runs under _send_with_retry; the
|
|
# prefix check relies on the helper re-raising the SDK's own exception
|
|
# object rather than wrapping it.
|
|
import app.channels.base as base_module
|
|
|
|
async def no_sleep(_delay):
|
|
return None
|
|
|
|
monkeypatch.setattr(base_module.asyncio, "sleep", no_sleep)
|
|
|
|
class RejectingWsClient:
|
|
is_connected = True
|
|
|
|
def __init__(self):
|
|
self.calls = 0
|
|
|
|
async def send_message(self, chatid, body):
|
|
self.calls += 1
|
|
raise RuntimeError("Reply ack error: errcode=60111, errmsg=invalid userid")
|
|
|
|
ws_client = RejectingWsClient()
|
|
channel = _wecom_channel(ws_client)
|
|
assert channel._ws_transport_up is True
|
|
|
|
with pytest.raises(RuntimeError, match="^Reply ack error: errcode=60111"):
|
|
await channel.send_notification(target="GaoZhiChao", text_markdown="**done**")
|
|
|
|
assert ws_client.calls == 3
|
|
assert channel._ws_transport_up is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wecom_send_notification_parks_on_the_socket_layer_close_exception():
|
|
websockets_exceptions = pytest.importorskip("websockets.exceptions")
|
|
websockets_frames = pytest.importorskip("websockets.frames")
|
|
|
|
class ClosingWsClient:
|
|
is_connected = True
|
|
|
|
async def send_message(self, chatid, body):
|
|
close = websockets_frames.Close(1006, "abnormal closure")
|
|
raise websockets_exceptions.ConnectionClosedError(close, None)
|
|
|
|
channel = _wecom_channel(ClosingWsClient())
|
|
channel._ws_transport_up = False # single probe; no retry/sleep storm
|
|
|
|
with pytest.raises(ChannelUnavailable, match="transport unavailable"):
|
|
await channel.send_notification(target="GaoZhiChao", text_markdown="**done**")
|
|
|
|
assert channel._ws_transport_up is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wecom_send_notification_propagates_deterministic_errors():
|
|
class InvalidTargetWsClient:
|
|
async def send_message(self, chatid, body):
|
|
raise ValueError("invalid target")
|
|
|
|
channel = _wecom_channel(InvalidTargetWsClient())
|
|
|
|
with pytest.raises(ValueError, match="invalid target"):
|
|
await channel.send_notification(target="GaoZhiChao", text_markdown="**done**")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_binding_check_reads_the_real_connection_repository_shape(tmp_path):
|
|
# The worker matches on the dict keys the real repository emits; a key
|
|
# rename there would otherwise fail every delivery silently.
|
|
from deerflow.persistence.channel_connections import ChannelConnectionRepository
|
|
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
|
|
|
|
await init_engine("sqlite", url=f"sqlite+aiosqlite:///{tmp_path / 'conn.db'}", sqlite_dir=str(tmp_path))
|
|
try:
|
|
conn_repo = ChannelConnectionRepository(get_session_factory())
|
|
bound = await conn_repo.upsert_connection(owner_user_id="user-1", provider="wecom", external_account_id="GaoZhiChao")
|
|
repo = FakeDeliveryRepo([_delivery_row()])
|
|
worker = _make_worker(repo, FakeChannel(), resolve_connections=conn_repo.list_connections)
|
|
|
|
assert await worker._target_still_connected(_delivery_row()) is True
|
|
# Another owner's row never counts for this delivery.
|
|
assert await worker._target_still_connected({**_delivery_row(), "owner_user_id": "user-2"}) is False
|
|
|
|
assert await conn_repo.disconnect_connection(connection_id=bound["id"], owner_user_id="user-1") is True
|
|
assert await worker._target_still_connected(_delivery_row()) is False
|
|
finally:
|
|
await close_engine()
|