Skip to content

feat(scheduler): push scheduled task results to IM channels via a notification outbox - #4843

Closed
GaoTianyuanhr wants to merge 14 commits into
bytedance:mainfrom
GaoTianyuanhr:feat/scheduled-task-im-notification
Closed

GaoTianyuanhr wants to merge 14 commits into
bytedance:mainfrom
GaoTianyuanhr:feat/scheduled-task-im-notification

Conversation

@GaoTianyuanhr

@GaoTianyuanhr GaoTianyuanhr commented Aug 16, 2026 •

Copy link
Copy Markdown
Contributor

`markdown
Fixes #4254

Why

Scheduled tasks run headlessly: their results only land in a web UI thread, so
unless the user happens to watch the browser, a completed (or failed) run goes
unnoticed. This PR implements the design agreed on in #4254: results are pushed
to the task owner's bound IM identities through a durable outbox and an async
delivery worker — no synchronous IM calls in the completion hook.

What changed

  • When channel connections are enabled, a scheduled run reaching a terminal
    state enqueues one notification delivery per connected channel identity of
    the task owner (run_completed on success, run_failed on error;
    user-initiated interrupts stay silent).
  • A new NotificationDeliveryWorker polls due deliveries and pushes a bounded
    markdown summary through the channel's proactive send path. WeCom implements
    it via the WS client's send_message with errcode checking; providers
    without proactive push record a failure instead of silently dropping.
  • Completed-run notifications include the agent's final answer as a bounded
    Result: line, read from runs.last_ai_message at delivery time
    (best-effort: a lookup failure degrades to the skeleton text and never
    blocks the delivery). Failed runs keep the error-only skeleton.
  • Failed deliveries retry with exponential backoff (60s, doubling, capped at
    15min, 5 attempts), then settle as failed. Execution status is written
    first and is never shadowed by delivery problems.
  • New notification_deliveries table (migration 0013, auto-applied at
    startup) with an idempotency key on (task_run_id, event, provider, target)
    so hook replays never double-send.
  • No default behavior change: everything activates only when
    channel_connections.enabled is true; no new config keys, no new
    dependencies.

Surface area

  • Frontend UI
  • [√] Backend API — no new endpoints; scheduler completion hook + delivery worker under backend/app, outbox persistence in deerflow.persistence
  • Agents / LangGraph
  • Sandbox
  • Skills
  • Dependencies
  • Default behavior change
  • Docs / tests / CI only

Validation

  • ruff check + ruff format --check clean on every changed file.
  • Test-driven throughout. In the dev container: outbox repository (11),
    scheduled task service incl. enqueue hook (31), delivery worker incl. WeCom
    push and result-summary enrichment (19), plus the full channel + scheduler
    regression batch (441) — all green.
  • Schema parity between create_all and the new migration covered by the
    existing bootstrap regression tests (HEAD anchor bumped to 0013).
  • Live end-to-end runs against a real WeCom bot:
    • v1 skeleton push: completion produced the outbox row below and the
      notification arrived in under one second:

      field value
      event run_completed
      provider / target wecom / bound user
      status / attempts sent / 0
      created_at → sent_at 08:16:25.24 → 08:16:26.13
    • Result-summary push: after a gateway restart, a completed scheduled run
      delivered the same skeleton plus the run's final assistant text as a
      Result: line in WeCom.

AI assistance

Tool(s) used: Lingma (Qoder IDE AI coding assistant)

How you used it: AI drafted the implementation and tests under my
direction (TDD: tests first per slice); I reviewed every line, ran all
validation, and performed the live end-to-end verification myself.

  • [√] I've read and understand every line of this change and take responsibility for it — it's not unreviewed AI output.

@CLAassistant

CLAassistant commented Aug 16, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@github-actions github-actions Bot added area:backend Gateway / runtime / core backend under backend/ needs-validation Touches front/back contract surface; needs real-path validation risk:high High risk: backend API, agents, sandbox, auth, deps, CI size/XL PR changes 700+ lines labels Aug 16, 2026
…k IM push

New notification_deliveries table and repository (migration 0012) backing
the outbox pattern for issue bytedance#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.
…r IM channels

Closes the loop for issue bytedance#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.
@GaoTianyuanhr
GaoTianyuanhr force-pushed the feat/scheduled-task-im-notification branch from c410904 to 9b8974b Compare August 16, 2026 08:50
…ions

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.

@willem-bd willem-bd left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall this is a well-structured outbox implementation: enqueue/send separation, a real idempotency key, execution/delivery status isolation, bounded notification text, and thorough tests. Findings below, ranked. The first two are durability/concurrency gaps in the outbox itself; the third is an encoding regression in an existing test file.

Also worth a look (no inline thread): notification_deliveries rows are never cleaned up — one sent/failed row per run per connected channel accumulates forever; a retention sweep would be a cheap follow-up.

Comment thread backend/app/scheduler/service.py Resolved
…ability

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.
@GaoTianyuanhr

Copy link
Copy Markdown
Contributor Author

@willem-bd All 5 comments have been addressed. Ready for re-review when you have a chance. Thanks!

@willem-bd willem-bd left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up review at 7305e11. Verified all five round-1 findings are properly fixed: per-row crash isolation plus stale-sending reconciliation in the worker, the RETURNING-based claim that closes the multi-worker double-send window (with a deterministic race test), the UTF-8 BOM/mojibake fully restored in test_scheduled_task_service.py, the manual-trigger suppression guard, and attempt-free parking for channel-down failures. The comments below are the residual suggestions: the WeCom "not connected" path still burns the counted retry budget (the one outage shape the parking fix does not reach), the worker's stop() is unbounded while the adjacent channel-service shutdown is deliberately bounded, and the new user-facing IM-push behavior ships without the README/docs note the repo's documentation policy asks for.

Comment thread backend/app/channels/wecom.py Outdated
retry on rejection instead of treating an ACK-less call as success.
"""
if not self._ws_client:
raise RuntimeError("WeCom channel is not connected")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] A registered-but-disconnected WeCom channel still burns the counted retry budget — this is the one outage shape the new parking fix doesn't reach.

When the channel object exists but its WS client is down, _deliver resolves it as "running" and send_notification raises here, which goes through mark_failed(..., count_attempt=True): 5 attempts over ~15 minutes of backoff, then the row settles failed permanently. That is exactly the "WeCom reconnect outlasts the window" case the round-1 thread named, but the count_attempt=False parking only triggers when get_channel returns None — and get_channel is a plain dict lookup (self._channels.get(name)), so a stopped/disconnected channel that stays registered still comes back non-None (Channel.is_running exists but isn't consulted; the docstring even says "running channel instance").

Two cheap options: raise a dedicated ChannelUnavailable-style exception here (and from base.send_notification's connectivity preconditions) that _deliver catches with count_attempt=False, or check channel.is_running in _deliver before sending. Either way the not-connected local condition gets the same park semantics as channel-object-missing, instead of silently exhausting the budget.

if self._task is None:
return
self._stop.set()
await self._task

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] stop() awaits the poller task with no bound, while the adjacent channel-service shutdown is bounded specifically "to prevent worker hang".

stop() sets the event and awaits self._task, but the loop only observes _stop between iterations: if a delivery is stuck inside channel.send_notification (a WS stall that never errors, or _send_with_retry's sleeps compounding across a batch), shutdown blocks the lifespan until it resolves. Since this worker is stopped right before the bounded stop_channel_service() in the gateway lifespan, one hung send now stalls the whole shutdown path the bounding was added to protect. Consider asyncio.wait_for(self._task, timeout=...) with a cancel-and-await fallback, mirroring the neighboring shutdown discipline (the scheduler service's own stop() has the same shape, but it doesn't sit in front of external I/O the way this worker does).

notification_connection_repo = None
notification_repo = None
connection_config = getattr(startup_config, "channel_connections", None)
if connection_config is not None and getattr(connection_config, "enabled", False):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] This is a user-facing behavior change gated here, but no README/docs update ships in the PR.

With channel_connections.enabled: true, scheduled-task outcomes now silently push to every connected IM identity of the task owner — users have no way to discover this from the docs, nor that manual "run now" and interrupts stay silent, that a channel outage parks deliveries while platform rejections retry only ~15 minutes before settling failed, and that only WeCom currently implements the proactive path (other providers will log failures). The repo's documentation policy (AGENTS.md) asks for a README/docs paragraph in the same change set for user-facing changes; a short note under the scheduled-tasks section covering the gating and the failure semantics would cover it.

@WillemJiang
WillemJiang requested a review from RykerFeng August 17, 2026 11:42

@RykerFeng RykerFeng left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deep review — the outbox design, execution/delivery separation, idempotency key, atomic multi-worker claim, and crash isolation are all solid and well tested. My main concern is one reliability guarantee that doesn't actually hold for the only provider that implements the push (WeCom). Details inline; the first comment is the important one.

Comment on lines +164 to +171
channel = self._resolve_channel(provider)
if inspect.isawaitable(channel):
channel = await channel
if channel is None:
# Channel outage is not the delivery's fault: park the row
# without consuming its retry budget so it survives an
# hours-long outage and delivers once the channel returns.
await self._delivery_repo.mark_failed(delivery_id, error=f"channel '{provider}' is not running", count_attempt=False)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Key finding: the "channel-outage failures don't consume the retry budget" guarantee doesn't hold for WeCom.

This count_attempt=False branch is the only path that protects the retry budget, and it fires only when resolve_channel returns None — i.e. the channel is disabled/stopped/never registered. But a WeCom WebSocket disconnect does not deregister the channel: WeComChannel._on_ws_disconnected only logs ("SDK will attempt to reconnect") and does not null _ws_client or remove the channel from ChannelService. So during a WS outage:

  1. get_channel("wecom") still returns the live channel object (not None), so we skip this branch;
  2. send_notification's if not self._ws_client guard doesn't trip (the client object is still there, only the transport is down);
  3. send_message on the dead connection fails → retried 3× by _send_with_retry → raises → caught below and marked failed with the default count_attempt=True.

So the most common real outage (channel running, transport flapping) takes the budget-burning path. With backoff 60→120→240→480s and max_attempts=5, ~15 minutes of WS downtime settles the notification to failed and drops it — directly contradicting the "an hours-long outage cannot exhaust retries" claim in the PR description, on the one provider that actually delivers.

Suggestion: distinguish connectivity errors from explicit platform rejections and route connectivity failures through count_attempt=False too (e.g. have send_notification raise a typed "not connected / transport error" that the worker parks without counting).

text_markdown=render_notification_text(enriched),
)
except Exception as exc:
await self._delivery_repo.mark_failed(delivery_id, error=str(exc))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the branch a WeCom WS disconnect actually lands on (see the key-finding comment above): the send raises, and mark_failed is called with the default count_attempt=True, consuming a retry. A transient transport outage therefore burns the budget instead of being parked. Consider inspecting exc (or a typed exception from send_notification) and passing count_attempt=False for connectivity-class failures.

Comment on lines +197 to +220
async def send_notification(self, *, target: str, text_markdown: str) -> None:
"""Proactively push markdown to a bound WeCom identity (issue #4254).

Goes through ``WSClient.send_message`` (no inbound frame required);
the platform ACK's ``errcode`` is checked so the delivery outbox can
retry on rejection instead of treating an ACK-less call as success.
"""
if not self._ws_client:
raise RuntimeError("WeCom channel is not connected")
body = {"msgtype": "markdown", "markdown": {"content": text_markdown}}
ack = await self._send_with_retry(
lambda: self._ws_client.send_message(target, body),
max_retries=3,
log_prefix="[WeCom]",
operation_name="notification send",
)
errcode = None
if isinstance(ack, dict):
errcode = ack.get("errcode")
ack_body = ack.get("body")
if errcode is None and isinstance(ack_body, dict):
errcode = ack_body.get("errcode")
if errcode not in (None, 0):
raise RuntimeError(f"WeCom send_message rejected with errcode={errcode}")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

send_notification collapses two very different failures into the same generic RuntimeError: (a) transport down / WS disconnected (transient, not the delivery's fault) and (b) platform rejected the message via errcode (a real, budget-worthy failure). The worker can't tell them apart, so both count against the retry budget. To make the outage protection above effective, raise a distinguishable error (or return a status) for the not-connected / transport-failure case so the worker can park it with count_attempt=False. Note if not self._ws_client only catches a fully torn-down client, not a live client whose WebSocket has dropped.

if self._task is None:
return
self._stop.set()
await self._task

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Secondary: stop() does a bare await self._task with no upper bound, while the gateway deliberately bounds channel shutdown with asyncio.wait_for(stop_channel_service(), timeout=_SHUTDOWN_HOOK_TIMEOUT_SECONDS). If stop() is called mid-batch, this awaits the whole in-flight batch, each delivery of which can spend seconds inside _send_with_retry (retries + 1s/2s sleeps). Consider wrapping the join in wait_for for symmetry with the channel-service shutdown, so a slow/hung send can't stall process exit.

@RykerFeng RykerFeng left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few more follow-ups from a deeper pass — one usability issue that directly undercuts the feature's goal (A), plus two architecture-consistency notes (B, C). None block merge, but A is worth folding in alongside the WeCom reliability fix.

Comment thread backend/app/scheduler/service.py Outdated
except Exception:
logger.exception("[Scheduler] failed to list channel connections for notifications")
return
payload = {"run_status": terminal_status, "error": error, "task_id": task_id}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(A) The notification shows only the raw task UUID, not the task title — this undercuts the feature's own goal. The payload here carries task_id (an opaque hex UUID) but not the human-readable title, so the rendered IM message reads Task: \a1b2c3d4…`` and the user can't tell which task completed. That contradicts the PR's stated motivation ("so the IM push is actually useful headlessly").

The fix is nearly free: handle_run_completion has already loaded the full row via task = await self._task_repo.get(...) a few lines above, and scheduled_tasks has a title column (String(255)). Add it to the payload, e.g.:

payload = {"run_status": terminal_status, "error": error, "task_id": task_id, "task_title": task.get("title")}

and have render_notification_text prefer task_title over the id in the Task: line.

Comment thread backend/app/gateway/app.py Outdated
Comment on lines +371 to +376
notification_delivery_worker = NotificationDeliveryWorker(
delivery_repo=notification_repo,
resolve_channel=delivery_channel_service.get_channel,
resolve_run_summary=resolve_run_summary,
)
await notification_delivery_worker.start()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(B) The delivery worker's poll interval is hard-coded (defaults to 5s) and not wired to config, unlike its sibling pollers. The scheduler is constructed with poll_interval_seconds=startup_config.scheduler.poll_interval_seconds and the MCP task worker with mcp_tasks_config.poll_interval_seconds, but NotificationDeliveryWorker(...) here passes no poll_interval_seconds, so it silently takes the 5s default. That means it issues two DB queries (reset_stale_sending_rows + claim_due_deliveries) every 5s forever, even when the outbox is empty — permanent idle load, and a break from the "poll cadence is configurable" convention the other two pollers follow. Consider passing a config-driven interval (reusing the scheduler's is fine).

Comment thread backend/app/gateway/app.py Outdated
Comment on lines +350 to +360
if notification_repo is not None:
# Delivery side of the outbox (issue #4254): polls due
# rows and pushes them through the owning channel's
# proactive send path. Only started when the enqueue side
# is wired and a channel service exists to resolve
# providers against.
from app.channels.service import get_channel_service
from app.scheduler.notification_delivery import NotificationDeliveryWorker

delivery_channel_service = get_channel_service()
if delivery_channel_service is not None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(C) Enqueue and delivery have asymmetric activation conditions, leaving a "write-only" hole. The worker only starts when notification_repo is not None and get_channel_service() is not None (this block). But the enqueue side (ScheduledTaskService._enqueue_run_notifications) activates purely on both repos being present. So in a config where channel_connections.enabled=true but no channel service is running, the scheduler keeps writing pending rows into notification_deliveries that no worker will ever drain — they accumulate silently. Suggest aligning the two sides (gate enqueue on the same condition, or at least log a startup warning when the outbox is wired but no worker/channel service exists).

@RykerFeng RykerFeng left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One necessity/bloat note on a newly added method.

await session.refresh(row)
return self._to_dict(row)

async def list_by_task_run(self, task_run_id: str) -> list[dict[str, Any]]:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

list_by_task_run is new in this PR but has no production caller — it's only referenced by its own unit test. For contrast, every sibling repo's list_* method is wired to something real: ScheduledTaskRunRepository.list_by_task / ScheduledTaskRepository.list_by_user / list_by_user_and_thread back routes in routers/scheduled_tasks.py, and ChannelConnectionRepository.list_connections backs both the channel-connections router and the scheduler hook. This one backs nothing.

Since the outbox has no read/query surface yet (no router, no service reads it), the method is speculative. Suggest dropping it (and its test) until an actual consumer exists — or, if it's meant to back a planned deliveries-status endpoint, a one-line note saying so would make the intent clear. Not a blocker, just flagging unused surface area added in this change.

@RykerFeng RykerFeng left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two more necessity/over-generality notes (non-blocking).

# channel may have been disabled or disconnected after the outbox row
# was written, and the row stays retryable for when it comes back.
channel = self._resolve_channel(provider)
if inspect.isawaitable(channel):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Over-general sync/async dual path. Both _resolve_channel (here) and _resolve_run_summary are await-ed through an inspect.isawaitable(...) guard so they accept either sync or async callables. But each injection point is fixed at wiring time, not variable at runtime: production get_channel is always sync (ChannelService.get_channel), and production resolve_run_summary is always async (the closure in app.py). So of the two guards, the summary one matches its real (async) resolver, while this channel one only ever takes the sync branch — and the async branch here isn't covered by any test (they all inject lambdas). The isawaitable cost is negligible, so this isn't urgent, but it's generality with no current consumer; a fixed contract per resolver (async summary, sync channel) would be simpler to read. Flagging as speculative surface rather than something that must change.

Comment on lines +77 to +80
poll_interval_seconds: int = 5,
batch_size: int = 10,
resolve_run_summary: SummaryResolver | None = None,
stale_sending_timeout_seconds: int = _STALE_SENDING_TIMEOUT_SECONDS,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These three tunables are never set in production — all defaults. poll_interval_seconds, batch_size, and stale_sending_timeout_seconds are constructor knobs, but the only instantiation (app.py) passes none of them, so they always take the defaults and are exercised solely by test injection. Test-injectable params are fine, but combined with the earlier note that the poll interval isn't wired to config, this reads as half-finished configurability: the knobs look tunable but nothing production-side tunes them. Suggest either wiring poll_interval_seconds (at least) to a config value, or trimming the ones that exist purely for tests if you don't intend to expose them.

@RykerFeng

Copy link
Copy Markdown
Collaborator

This PR adds a user-facing capability (scheduled-task outcomes pushed to bound IM identities) and new architecture (the notification_deliveries outbox table, migration 0013, and the NotificationDeliveryWorker), but git diff --name-only shows no docs were touched — no README.md, no AGENTS.md, no config.example.yaml, no CHANGELOG.

The root AGENTS.md Cross-Cutting Conventions make this a same-change-set requirement:

Documentation update policy — keep docs in sync with code: update README.md for user-facing changes and the relevant AGENTS.md for development/architecture changes in the same change set.

Concretely, I'd expect at least:

backend/AGENTS.md — document the new outbox table + delivery worker in the persistence/migrations and scheduler/channels sections, and note the new Channel.send_notification contract (default raises; only WeCom implements it).
README.md — a short note that scheduled-task results are delivered to bound IM channels, and that the behavior activates only when channel_connections.enabled is true.
Consider a CHANGELOG entry as well, per the repo's change-tracking convention.
Since CI won't catch this, flagging so it can be added before merge to stay consistent with the documented policy.

GaoTianyuanhr and others added 2 commits August 17, 2026 23:32
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>
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>
@github-actions github-actions Bot added the area:docs Documentation and Markdown only label Aug 17, 2026
@GaoTianyuanhr

Copy link
Copy Markdown
Contributor Author

Thanks @willem-bd and @18062706139fcz for the follow-up review ? addressed across e225b2c and a1e2f65.

Reliability (WeCom / retry budget)

  • Added ChannelUnavailable for local connectivity failures (distinct from platform rejections).
  • WeCom send_notification now raises ChannelUnavailable when the client is missing / transport fails; errcode rejections still count as attempts.
  • Delivery worker parks with count_attempt=False on ChannelUnavailable, and also when get_channel returns None or channel.is_running is false.
  • Track _ws_transport_up on disconnect / successful send so known disconnects probe once instead of burning the 3? retry/sleep path.

Shutdown

  • NotificationDeliveryWorker.stop() is bounded (wait_for + cancel fallback, ~5s).
  • Gateway lifespan also wraps worker stop with the same shutdown timeout used for the channel service.

Usability / wiring

  • (A) Payload now includes task_title; IM text prefers the title over the raw UUID.
  • (B) Delivery worker poll interval is wired to scheduler.poll_interval_seconds.
  • (C) Enqueue is disabled when channel_connections is on but no channel service is running (avoids a write-only outbox), with a startup warning.

Docs

  • backend/AGENTS.md ? outbox table, delivery worker, and Channel.send_notification contract (WeCom-only proactive push today).
  • app/channels/AGENTS.md + persistence/migrations/AGENTS.md ? matching detail for the channel contract and migration 0013.
  • README.md / README_zh.md / CHANGELOG.md / config.example.yaml / root AGENTS.md / CONFIGURATION.md ? user-facing activation notes.

Cleanup

  • Dropped unused list_by_task_run (no production caller).

Tests: notification delivery worker + repository coverage (run in the gateway container). Ready for another look when you have a chance. Thanks!

@willem-bd willem-bd left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round-3 review at a1e2f65. Everything from rounds 1-2 is verified fixed at this head: the WeCom transport-outage path now raises ChannelUnavailable (parked via count_attempt=False, _ws_transport_up tracking), stop() is bounded with a cancel fallback, the README/README_zh/CHANGELOG/AGENTS.md/config docs shipped, task_title reaches the rendered push, the worker's poll interval is wired to scheduler.poll_interval_seconds, the enqueue side is disabled when no channel service is running (no write-only outbox), and list_by_task_run is gone. Encoding in test_scheduled_task_service.py is clean again. No new critical or important issues found; two residual suggestions below (unbounded parking, and untested lifespan wiring).

# Flat re-poll interval for rows parked because the owning channel is not
# running. These failures do not consume the retry budget, so the row can
# wait out an hours-long channel outage and still deliver once it returns.
_CHANNEL_DOWN_RETRY_SECONDS = 900

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] Parked rows can never reach a terminal state — a permanently absent channel retries forever.

count_attempt=False parking is unbounded in both count and time: unlike counted failures, a parked row has no path to failed. The transient-outage case is exactly right, but consider the permanent one — a provider whose channel is removed from config.yaml (or fails to start) while other channels keep the service alive: get_channel(provider) returns None forever, so every future scheduled run for owners still connected to that provider enqueues a row that retries every 15 minutes for the life of the deployment. With no read surface on notification_deliveries (yet), that backlog is invisible apart from claim churn in the logs.

Suggestion: bound parking by row age — e.g. once created_at is older than a day or two, finalize the row as failed with last_error noting the channel never returned — or keep a separate cap on parked attempts. That preserves the outage-survival property the README documents while giving permanently-gone channels a settled, observable terminal state.

# Stop the notification delivery worker BEFORE the channel service: it
# sends through running channels, so letting channels die first would
# turn every in-flight delivery into a spurious failure/retry.
if getattr(app.state, "notification_delivery_worker", None) is not None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The new lifespan wiring — both the startup gating and this shutdown block — ships without any test coverage.

Neither branch of the gating (repos nulled with the warning when no channel service is running; the worker started only when both sides are wired) nor this stop-before-channels block is exercised by any test. That gating is what enforces the "no write-only outbox" invariant the backend AGENTS.md note now documents, and this block is the reason a worker in front of external IM I/O gets the same bounded-shutdown discipline as the channel service — both are exactly the kind of invariant a later refactor could silently break.

tests/test_gateway_lifespan_shutdown.py already drives the real lifespan with patched channel services, so both are cheap to pin there: e.g. assert no NotificationDeliveryWorker is started (and the warning fires) when channel_connections.enabled is true but start_channel_service yields no service, and that the worker's stop() is awaited before stop_channel_service() on shutdown.

@RykerFeng RykerFeng left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up on the round-2 fix (e225b2c). The ChannelUnavailable split fixes the WS-drop budget-burn I flagged earlier — verified. One new edge the broad except introduces, noted inline.

Comment thread backend/app/channels/wecom.py Outdated
Comment on lines +238 to +242
except Exception as exc:
# Dead/flapping transport: not the delivery's fault. Park the
# outbox row until the SDK reconnects instead of burning retries.
self._ws_transport_up = False
raise ChannelUnavailable(f"WeCom transport unavailable: {exc}") from exc

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New edge introduced by this fix: the blanket except Exception -> ChannelUnavailable can turn a deterministic send error into an immortal outbox row.

The transport-outage fix is correct and I confirmed it resolves the earlier WS-drop budget-burn. But this catch-all funnels every non-ChannelUnavailable exception from send_message into ChannelUnavailable, which the worker parks with count_attempt=False (never counted, retried every ~15min forever). That's right for transport/connectivity failures — but it also swallows deterministic failures that aren't the transport's fault and will never succeed on retry: e.g. the SDK raising ValueError/TypeError on a malformed target or body, a serialization error, or an SDK-version mismatch. Those become undying rows that re-attempt every 15 minutes indefinitely and never settle to failed.

Note the asymmetry: a platform rejection surfaced as an errcode does count against the budget (the RuntimeError below), but the same class of permanent error surfaced as a raised exception now does not. So whether a permanent failure is bounded depends on how the SDK happens to report it.

Suggestion: narrow the wrap to connectivity-class exceptions (e.g. ConnectionError/TimeoutError/OSError-family, or whatever the aibot SDK raises on a dead socket) and let genuinely unexpected exceptions fall through as counted failures — so a deterministic error still drains to failed instead of parking forever. Low severity (needs the SDK to report a deterministic error as a raise rather than an errcode), but it's a gap this narrowing opened up.

@RykerFeng

Copy link
Copy Markdown
Collaborator

Discussion — do scheduled-task IM notifications need content redaction before egress? @willem-bd flagging this for your judgment, since it's a policy call rather than a clear defect.

What goes out. render_notification_text puts two pieces of run content on the wire to the external IM platform:

  • success → Result: {result_summary} sourced from runs.last_ai_message (the agent's final answer, truncated to 1000 chars)
  • failure → Error: {error} sourced from record.error (truncated to 500 chars)

Between read and send, the only transform is _truncate — no redaction/scrub/masking.

Why it might matter. Scheduled runs are headless and periodic; their prompts/outputs can touch internal data (hostnames, paths, customer data, connection strings in a traceback, token fragments in an error). Once pushed to a third-party IM, it's effectively irreversible (platform-side caching/indexing/retention), and there's no human in the loop the way an interactive reply has.

Honest caveat (why I'm calling it a discussion, not a bug). The normal channel reply path doesn't apply generic secret-scrubbing to agent content either, so the underlying "agent output may contain sensitive text" risk isn't unique to this PR. Two things still make this path worth a deliberate decision:

  1. It's automatic/unattended and targets a bound identity, not the active session.
  2. The Error: line is a genuinely new egress — the normal manager flow only logs record.error, it doesn't forward it to IM. So error-text exposure is largely introduced here.

For context, the repo already treats "filter before egress" as a real concern in places (e.g. buzz.py's _HIDDEN_CONTEXT_MARKERS blocking hidden-context wrappers, with a note that a leak there is permanent), and config/metadata redaction exists in secret_context.py — but neither covers this message body.

Options if you decide it's worth addressing (ordered by cost): (a) on failure, send only status + task/run id and let the user open the UI for the error detail; (b) run result_summary/error through a secret scan before send; (c) a config toggle for "include run content in notifications". Equally fine to consciously accept the current behavior — just raising it so it's a decision rather than an oversight.

@RykerFeng RykerFeng left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per the discussion above (redaction of run content in IM notifications) — noting this has been decided as in-scope, here is the concrete change request, anchored to the two egress points.

Comment on lines +60 to +70
error = payload.get("error")
if error:
lines.append(f"Error: {_truncate(str(error))}")
else:
lines = ["**Scheduled task completed**", f"Task: `{task_label}`"]
# The result summary belongs to a successful outcome only: on failed
# runs a partial answer would be misleading, so the error line above
# stays the sole detail.
summary = payload.get("result_summary")
if isinstance(summary, str) and summary.strip():
lines.append(f"Result: {_truncate(summary.strip(), limit=_SUMMARY_TEXT_LIMIT)}")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Change requested (decided in the discussion thread): redact run content before it leaves for the external IM platform.

Both outbound lines carry raw run content with only _truncate applied — no scrub/mask:

  • Error: {error} (line 62) from record.error
  • Result: {result_summary} (line 70) from runs.last_ai_message

Scheduled runs are headless/periodic and can surface internal data (hostnames, paths, connection strings in a traceback, token fragments), and once pushed to a third-party IM it's effectively irreversible. The Error: line in particular is a new egress this feature introduces — the normal manager flow only logs record.error, it doesn't forward it to IM.

Ask: run error and result_summary through a redaction pass before rendering. The repo already has secret utilities in deerflow.runtime.secret_context (config/metadata-oriented) and a content-filter precedent in buzz.py (_HIDDEN_CONTEXT_MARKERS) — reuse/extend rather than inventing a new mechanism. If a full content scrub is out of scope for this PR, the minimal acceptable version is to drop the raw Error: body (send status + task/run id only, user opens the UI for detail), since that's the highest-risk / lowest-value line. Please also cover it with a test asserting a seeded secret does not appear in render_notification_text output.

@WillemJiang

Copy link
Copy Markdown
Collaborator

@GaoTianyuanhr please fix the lint error.

@WillemJiang

Copy link
Copy Markdown
Collaborator

@GaoTianyuanhr there are some conflict with the main branch, do you mind resolve them?

Windows git could not detach HEAD for rebase, so this resolves the PR
conflicts via merge. Renumber the scheduled-task IM outbox migrations to
0014/0015 so they chain after 0013_mcp_task_notifications.
@GaoTianyuanhr

Copy link
Copy Markdown
Contributor Author

Thanks @WillemJiang — conflicts with main are resolved and pushed (8ec3671).

Windows git on this machine could not detach HEAD for a rebase, so this is a merge of latest main. The scheduled-task IM outbox migrations are now 0014_notification_deliveries and 0015_notification_delivery_parked_attempts, chaining after 0013_mcp_task_notifications. Persistence bootstrap tests (83) passed locally.

@WillemJiang

Copy link
Copy Markdown
Collaborator

@GaoTianyuanhr, please fix the lint error in the latest change.

…ion 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.
@GaoTianyuanhr

Copy link
Copy Markdown
Contributor Author

Thanks @WillemJiang — lint is fixed in 678a4ce (ruff format on tests/test_notification_delivery_worker.py).

The Postgres unit-test errors were Alembic version_num VARCHAR(32) truncating 0015_notification_delivery_parked_attempts; the revision id is now 0015_parked_attempts.

Renumber notification outbox migrations to 0016/0017 after main 0014_managed_subagents and 0015_scheduled_task_enqueue. Resolve merge conflicts in AGENTS docs, persistence bootstrap tests, and config examples while keeping scheduled-task IM push (issue bytedance#4254).
@GaoTianyuanhr

Copy link
Copy Markdown
Contributor Author

@WillemJiang @18062706139fcz — latest \origin/main\ is merged and pushed (\980c4b0).

Merge / migrations

  • Synced \origin/main\ (\641a414) into this branch.
  • Notification outbox migrations renumbered to \

Update migration 0015 test HEAD expectation to 0017_parked_attempts and add queue_timeout_seconds to the lifespan notification worker mock scheduler config.
@GaoTianyuanhr

Copy link
Copy Markdown
Contributor Author

@WillemJiang @18062706139fcz - CI follow-up fix pushed in 9de2cfe.

Two post-merge backend-unit-tests failures, both test-only:

  1. test_migration_0015_scheduled_task_enqueue - bootstrap_schema() now upgrades to HEAD 0017_parked_attempts; updated the version assertion.
  2. test_lifespan_starts_notification_worker_when_enqueue_and_channel_are_wired - main added scheduler.queue_timeout_seconds; added it to the lifespan mock config so scheduled-task init no longer aborts before the notification worker starts.

Both tests pass locally. Thanks!

@WillemJiang

Copy link
Copy Markdown
Collaborator

@GaoTianyuanhr please resolve the conflicts with the main branch.

Renumber notification outbox migrations to 0017/0018 after main 0016_subagent_batches. Update bootstrap HEAD expectations to 0018_parked_attempts.
@GaoTianyuanhr

Copy link
Copy Markdown
Contributor Author

@WillemJiang @18062706139fcz - Conflicts with main are resolved and pushed in 4482d84.

Main added 0016_subagent_batches, so the notification outbox migrations are renumbered to 0017_notification_deliveries and 0018_parked_attempts. Bootstrap HEAD expectations updated accordingly.

Local tests: migration/bootstrap suite + notification worker + gateway lifespan (82 passed). Thanks!

sunshine-lang added a commit to sunshine-lang/deer-flow that referenced this pull request Aug 29, 2026
The PR's migration-coordination comment states each migration file
carries the note; the file did not. Adds it: numbering was generated
against main head 0016 alongside bytedance#5078 and bytedance#4843; whoever merges first
keeps the slot, the others renumber on rebase (revision/down_revision
plus the bootstrap head assertions).
WillemJiang pushed a commit that referenced this pull request Aug 29, 2026
…5041)

* feat(auth): add personal access tokens for programmatic API access (#4849)

Backend-first implementation of the PAT contract from #4849: show-once
dfp_ tokens bound to their owning user (AUTH_SOURCE_PAT,
is_internal=false), digest-only storage (migration 0017), strict
credential precedence (invalid Bearer is a 401, never cookie fallback),
CSRF double-submit skipped only for Bearer requests while
auth-endpoint origin checks still run, scopes intersecting the authz
route permissions, session-auth-only PAT management and password
changes, and throttled best-effort last_used_at stamps.

* fix(auth): harden PAT scope boundary and schema parity from adversarial review

Independent review of the initial draft found: (1) scopes only constrained
the threads/runs permission axis while admin routes treated a PAT as its
(possibly admin) owner — is_admin_user now rejects PAT callers outright
since no scope grants admin capability; (2) the model declared a column
UNIQUE constraint while migration 0017 created a named unique index, so
downgrade failed on create_all-bootstrapped DBs — both now use the named
unique index; (3) auth-disabled mode is an operator override and now stays
ahead of the Bearer check so a stray Authorization header cannot 401 an
E2E sandbox; plus wiring the previously-unused constants, bounding the
last_used_at stamp cache, and four new tests (middleware-level expiry,
expires_in_days, admin-capability rejection with session control, and the
auth-disabled precedence).

* docs(api): document personal access tokens for programmatic API access

* fix(auth): close PAT security boundaries from review (default-deny routes, extension admin suppression)

P1-1: scope intersection only constrains @require_permission routes, so
undecorated mutation routes (DELETE /api/memory, POST /api/agents, Lark
credential switching, channel config) accepted a PAT holding a single read
scope. AuthMiddleware now enforces a default-deny route policy in
auth/pat.py: PAT requests are admitted only to the thread/run lifecycle
routes the v1 scopes govern; everything else answers 403 regardless of
scopes. Session-cookie callers are unaffected.

P1-2: the extension principal resolver projected is_admin/roles from the
raw system_role, so an admin-owned PAT passed
deerflow_extension_api.require_admin on contributed routes despite the
documented no-admin guarantee. The projection is now PAT-aware and
suppresses every admin signal for PAT callers, mirroring
deps.is_admin_user.

Both fixes carry regression tests (route outside policy 403 + session
control; production resolver admin suppression), and API.md documents the
default-deny boundary.

* fix(auth): enforce PAT scopes on stateless run entry and harden decorator

Follow-up hardening from an independent audit of the P1 fixes:

- POST /api/runs/stream and /api/runs/wait were the only allowlisted run
  entrypoints without @require_permission, so a threads:read-only PAT
  could still start runs (same bug class as P1-1, now closed): both now
  carry @require_permission("runs", "create"). POST /api/threads and
  POST /api/threads/search gain threads:write / threads:read for the
  same reason. Authorization-disabled deployments see no change (the
  permission set resolves to all permissions).
- require_permission now binds the wrapped signature to locate a
  positionally-passed request before injecting the test stub, fixing
  'got multiple values for argument' on direct positional unit-test
  calls.
- API.md: the intro PAT example used GET /api/models, which the new
  default-deny policy 403s — replaced with GET /api/threads; the
  default-deny route list now spells out method sets.

Regression test: threads:read-only PAT is 403 on the decorated stateless
entry while a runs:create PAT passes.

* fix(auth): address review P2s (empty Authorization header, PAT name trimming, API example)

- CSRFMiddleware treats an explicitly empty Authorization header as
  present (is None), so an invalid credential always reaches
  AuthMiddleware's uniform 401 instead of a CSRF 403 that varies by
  method/CSRF state. Regression: empty-header request dies at auth.
- PATCreateRequest strips the name and rejects whitespace-only values
  before token generation; created names are stored trimmed.
- API.md intro PAT example now uses the implemented
  POST /api/threads/search endpoint (GET /api/threads does not exist).
- AGENTS.md trimmed back under the guidance soft budget after the
  upstream merge.

* fix(auth): tighten PAT route policy to implemented methods only

The allowlist admitted GET /api/threads, a method no router implements.
Pre-authorizing a dead method weakens the default-deny boundary: a
future GET collection route added without a permission decorator would
become PAT-reachable without an explicit policy change. Restrict the
rule to POST, fix the stale GET description in API.md's PAT
constraints, and document the default-deny boundary accurately in the
gateway AGENTS.md guidance (only the threads/runs allowlist is
PAT-reachable; every other authenticated route 403s PAT callers).

Audited every remaining rule against the mounted routers: all other
method+path entries map to real routes. Regression:
test_pat_policy_does_not_pre_authorize_unimplemented_methods.

* test(auth): guarantee the negative digest test mutates the token

token[:-1] + "X" is identical to the original whenever the generated
token already ends in X (1/62), making the negative digest assertion
fail intermittently. Choose the replacement character based on the
existing tail so the mutated token always differs.

* fix(auth): require runs:cancel for cancel-then-stream requests

stream_existing_run is gated at runs:read so action-less stream joins
work with read-only credentials, but its ?action=interrupt|rollback
branch cancels the run — a separate permission. A runs:read-only PAT
passed both the PAT route policy and the route decorator and could
interrupt or roll back an active run, bypassing the runs:cancel scope.

Decorators cannot express query-parameter-conditional permissions, so
the check lives in require_cancel_permission_when_action(), applied at
the top of the handler. Regression drives the real helper through the
production middleware: runs:read-only PAT + action is 403, the same
token joins action-less, runs:read+cancel passes, session control
unaffected.

* docs(changelog): add the PAT feature entry

* docs(readme): add personal access tokens section

Repo documentation-update policy requires user-facing features to
update README.md in the same changeset; the PAT feature previously
touched only backend/docs/API.md and the gateway AGENTS.md.

* fix(auth): require runs:cancel for mutating multitask strategies

All five run-creation entrypoints were gated only by runs:create, but
RunCreateRequest.multitask_strategy accepts interrupt/rollback and
start_run forwards it to create_or_reject, which terminates an
already-active run. A runs:create-only PAT could therefore kill an
existing run through a create request, bypassing runs:cancel.

Decorators cannot express body-parameter-conditional permissions, and
per-route checks leave the same hole for the next entrypoint, so the
gate lives in start_run itself — the single choke point every
run-creation path (HTTP routes and internal launchers) flows through.
Regenerate launches pass multitask_strategy="reject" and are
unaffected; requests without a stamped auth context (internal/test
compositions) skip the gate.

The check is the shared authz.require_cancel_permission_if primitive;
require_cancel_permission_when_action now delegates to it, so every
request dimension that carries cancel capability (query action, body
strategy) flows through one gate.

Regression drives the real middleware stack: runs:create-only PAT +
interrupt/rollback is 403 with the exact detail, reject (explicit and
default) stays available, runs:create+cancel passes, session control
unaffected; a source anchor pins the gate inside start_run.

* fix(runs): keep observer joins from applying creator cancel-on-disconnect

sse_consumer's finally block applied the record's on_disconnect=cancel
policy on ANY consumer's disconnect. The join surfaces (GET /join and
the action-less GET/POST stream join) feed it the existing RunRecord,
so anyone with thread read access — including a runs:read-only PAT —
could cancel a locally-owned running run simply by closing the SSE
connection, without runs:cancel. The policy expresses the creator's
intent for their own connection; an observer's disconnect must never
be read as that intent.

sse_consumer gains apply_on_disconnect (default True). The two join
surfaces pass False; the creating endpoints (thread-scoped and
stateless create-and-stream) keep the creator semantics unchanged.
wait_for_run_completion needs no change: its callers are creator-side
or post-explicit-cancel paths only.

Regression exercises a real generator close — the same machinery
Starlette drives on client disconnect — against the production
sse_consumer: creator stream disconnect cancels, observer join
disconnect does not; a wiring anchor pins both join call sites and the
creator defaults. API.md documents the cancel-capability constraint
(this fix plus the action/strategy gates) in PAT Constraints.

* test(auth): pin the multitask gate behaviorally; state wait invariant

Independent adversarial review of the round-5 fixes found the P1-a
regression only mirror-pinned: the source anchor could be satisfied by
a comment, and deleting the gate from start_run would not fail the
suite. This drives the production start_run directly — a create-only
auth context gets 403 with the exact detail for interrupt, and a
reject request with no cancel permission at all proceeds past the gate
(never a permission 403).

Also documents wait_for_run_completion's creator-side invariant
(every caller is the creating endpoint or post-explicit-cancel) so a
future observer wiring thinks twice before reusing it — the one-caller-
away variant of the observer-disconnect P1.

* docs(changelog): correct the PAT entry's digest and route-policy description

The entry said HMAC digests (the implementation stores SHA-256 digests,
as documented in API.md and pinned by the repository tests) and claimed
the route policy admits 'implemented stateless endpoints' (it admits
the thread/run lifecycle routes, narrowing further by scopes). Also
notes the cancel-capability gate now covering action and multitask
strategies.

* fix(auth): enumerate the PAT runs route policy per implemented subroute

The runs subtree rule was a GET|POST /runs(/.*)? wildcard — it
pre-authorized every current and future subroute under /runs, including
methods the router never implemented (e.g. GET /runs/stream), which is
the same latent default-deny weakening the threads collection rule was
tightened for: a future route added under /runs would become
PAT-reachable without an explicit policy change.

The wildcard is replaced with six segment-precise rules covering exactly
the 14 implemented method+path combinations; the {run_id} slot
necessarily matches any single segment, so the POST-only collection
names (stream, wait, regenerate, edit-regenerate) are excluded from the
GET run-id rule via negative lookahead — no dead method stays
pre-authorized. Behavior for implemented routes is unchanged.

test_pat_runs_policy_admits_exactly_the_mounted_routes derives the
expected set from the mounted thread_runs router instead of a
hand-maintained list: every implemented GET/POST route under /runs must
be admitted, routes in this router outside the subtree stay denied, and
representative unimplemented neighbors are denied — so adding a route
under /runs now fails CI until it is explicitly allowlisted, and a
removed route leaves a dead rule visible. API.md's PAT constraints list
the enumerated routes and drops a feedback mention that belonged to the
stateless /api/runs axis.

* docs(migration): add the 0017 renumbering coordination note to 0017

The PR's migration-coordination comment states each migration file
carries the note; the file did not. Adds it: numbering was generated
against main head 0016 alongside #5078 and #4843; whoever merges first
keeps the slot, the others renumber on rebase (revision/down_revision
plus the bootstrap head assertions).

* fix(auth): pad base62 tokens to a fixed 43-char width

int.from_bytes discards leading zero bytes, so the unpadded encoder
returned a variable-length body — empty for all-zero input, and shorter
than 40 characters for any draw below 62**39 (~1 in 14.5M), leaving
test_generate_pat_token_format probabilistically flaky and the token
body without stable width (review round 6, P3).

_base62 now left-pads with "0" to _base62_width(len(data)) — the exact
integer digit count (62^43 > 2^256 > 62^42, so 43 for 32 bytes). The
format test asserts the exact fixed width instead of a probabilistic
floor, and a new unit test pins the all-zero, leading-zero-byte, and
max-value edges deterministically.
@GaoTianyuanhr

Copy link
Copy Markdown
Contributor Author

@WillemJiang @18062706139fcz - Friendly check-in on this PR.

Conflicts with main were resolved in 4482d84 (notification migrations renumbered to 0017/0018 after 0016_subagent_batches). Local migration/bootstrap + notification tests passed at that head.

I noticed main has moved ahead again since then, and the fork CI workflows on 4482d84 still show action_required. Happy to sync main once more and push if that would help - just let me know.

Thanks for the earlier reviews!

@WillemJiang

Copy link
Copy Markdown
Collaborator

@GaoTianyuanhr Sorry for the delay. Please resolve the conflicts so we can merge the PR.

@Totoro-qaq

Copy link
Copy Markdown
Collaborator

@GaoTianyuanhr main has moved about 640 commits since your last sync, so I brought this branch up to date to help get it unblocked. Your commits are untouched; main is merged on top and my changes are separate commits. The result is Totoro-qaq:carry/pr-4843-im-notification, and it fast-forwards from your head 4482d849.

What sits on top of your work:

  • Migrations: renumbered to 0027_notification_deliveries / 0028_parked_attempts, after main's 0026.
  • Completion hook: handle_run_completion keeps main's atomic complete_run (fix(scheduler): reconcile stuck once tasks from committed run outcome #5035) and enqueues after it, only when the outcome was recorded.
  • Startup order: fix(scheduler): reconcile stuck once tasks from committed run outcome #5035 moved the channel service start to after the scheduler. That merged without a conflict, but the outbox wiring then looked for the channel service before it existed, so the feature switched itself off at every startup while the tests stayed green. The enqueue side is now wired with the scheduler and the delivery worker starts after the channel service.
  • WeCom: the SDK raises a plain RuntimeError on a closed socket, which spent the retry budget. send_notification now checks is_connected first, so a disconnect parks the row.
  • Docs: changelog entry moved to Unreleased (and mirrored in CHANGELOG_zh.md); two AGENTS.md notes moved into app/channels/AGENTS.md because the guidance size check failed.

Checked on a real Gateway (DeepSeek, no IM channel configured): a scheduled once run wrote its outbox row and the worker parked it; a manual trigger wrote none. Not checked: an actual push to WeCom, since I have no WeCom bot.

@WillemJiang would it be easier to push this onto the PR branch (maintainer edits are enabled), so it lands as #4843 under the author's name? If a separate PR is simpler for you, I can open one that keeps these commits.

@Totoro-qaq

Copy link
Copy Markdown
Collaborator

Update: the carried branch is now its own PR, #6135, opened as a separate PR after checking with @WillemJiang (I cannot push to this fork). Your 14 commits are in it unchanged, the description keeps the history of this PR, and I have asked for Co-authored-by: GaoTianyuanhr in the squash message.

@GaoTianyuanhr thank you for the design and the implementation; the outbox, the idempotency key and the parking logic are all yours, and they held up in a real Gateway without changes. If you are back, please review #6135 or tell me if you would rather carry it yourself and I will close mine.

WillemJiang pushed a commit that referenced this pull request Oct 1, 2026
…ification outbox (#6135)

* 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>
@WillemJiang

Copy link
Copy Markdown
Collaborator

The issue has been addressed by #6135

@WillemJiang WillemJiang closed this Oct 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:backend Gateway / runtime / core backend under backend/ area:docs Documentation and Markdown only needs-validation Touches front/back contract surface; needs real-path validation risk:high High risk: backend API, agents, sandbox, auth, deps, CI size/XL PR changes 700+ lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feat] 支持定时任务执行结果主动推送到钉钉、飞书等 IM 渠道

6 participants