71 Commits

Author SHA1 Message Date
Hyeonsang Cho
48949482c5
fix(channels): give DingTalk AI cards the channel token, not the SDK fetch (#7597)
* fix(channels): give DingTalk AI cards the channel token, not the SDK fetch

AICardReplier's async card calls read dingtalk_client.get_access_token()
inline, and dingtalk-stream refreshes it with requests.post and no
timeout. Card creation and card streaming both run on the Gateway loop,
so the first card and every SDK refresh after it (about every two hours)
blocked the loop for the whole token request, without bound if DingTalk
stalled.

Hand the replier a _CardTokenClient that serves the channel's own
httpx token (10s timeout), set before card creation and before every
streamed update. A token failure while streaming now raises into send's
existing sampleMarkdown fallback instead of being skipped silently.

Pin it with a strict blocking-I/O anchor over a real DingTalkStreamClient
and AICardReplier, a gate meta-check, and unit tests for token rotation
and failure. LocalHttpServer also records each request's method, path
and headers.

* docs(changelog): reference #7597 for the DingTalk AI card token fetch

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-28 10:50:30 +08:00
Hyeonsang Cho
77d0fc9d69
fix(channels): resolve reply artifacts off the event loop (#7594)
* fix(channels): resolve reply artifacts off the event loop

_handle_chat_on_thread and _handle_streaming_chat called
_prepare_artifact_delivery inline. For every presented file it resolves
the virtual path (realpath), resolves the outputs root, and checks the
file with is_file and stat, all on the Gateway loop that also serves
every other channel and request.

Run it through asyncio.to_thread on both reply paths, and anchor both
through the real manager under the strict blocking-I/O gate.

* docs(changelog): reference #7594 for the off-loop reply artifact resolution

* docs(changelog): drop the duplicate #7580 link definition in the zh changelog

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-27 14:55:19 +08:00
Hyeonsang Cho
137c721d2b
fix(channels): write each Feishu thread alias once per streamed reply (#7593)
* fix(channels): write each Feishu thread alias once per streamed reply

Every outbound to a Feishu running card re-sent set_thread_id for all of
the reply's aliases (source message, card, topic, root, parent and Feishu
thread ids), although only updated_at changed after the first chunk.
That was about five store writes per streamed chunk: a full store.json
rewrite under the store lock each, or a database upsert.

Remember the aliases written for each in-flight reply and skip them on
non-final chunks. A failed write is not remembered, so the next chunk
retries it, and the final outbound still writes every alias, so the
store ends up as before.

* docs(changelog): reference #7593 for the Feishu streamed thread alias writes

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-27 10:56:31 +08:00
Totoro
1b88e17e77
fix(channels): report failed attachment delivery (#7532)
* fix(channels): report failed attachment delivery

* refactor(channels): clarify attachment feedback helpers

---------

Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-26 15:39:39 +08:00
lisry-1
46ccfab71a
Track DingTalk stream message tasks to avoid dangling tasks (#7429)
* Track DingTalk stream message tasks to avoid dangling tasks

The stream loop handed each frame to client.background_task() via a
bare asyncio.create_task(...) whose result was dropped. asyncio only
keeps a weak reference to a task once it awaits, so the task could be
garbage-collected mid-execution (silently dropping a message) and any
exception it raised was never observed.

Keep a strong reference to each handler task and discard it via a done
callback, matching the background-task tracking used elsewhere (e.g.
the subagent executor and the MCP session pool).

* fix(channels): observe DingTalk stream task failures

---------

Co-authored-by: lisry-1 <lisry-1@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-25 16:15:15 +08:00
Felix Shi
737d50f625
fix(channels): retry the runtime config file lock instead of failing after ~10s on Windows (#6888)
* fix(channels): retry the runtime config file lock instead of failing after ~10s on Windows

The runtime channel config sidecar lock (#6844) used Windows' blocking msvcrt LK_LOCK mode, which gives up after roughly ten seconds and surfaces cross-worker contention as Permission denied instead of serializing the read-modify-write cycles it exists to serialize, while fcntl.flock(LOCK_EX) waits on POSIX. Retry the non-blocking acquisition (LK_NBLCK + 0.2s) on the Windows fallback, mirroring the extensions manager's lock (#6020), and bind fcntl/msvcrt at module level so the fallback branch is testable.

Tests: new backend/tests/test_channel_runtime_config_store_lock.py (5 tests: retry cadence via fake msvcrt, sidecar byte creation, POSIX flock preservation, direct Windows regression through the sys.modules seam, real cross-thread contention). 3 of them fail on the unmodified base (one with OSError 36 raised by the blocking call itself) and all pass with the fix. Related suites: store peers + connections router 86 passed / 1 skipped; blocking_io + encoding + channels 498 passed / 2 skipped. Ruff format and check clean on both files. Windows 11, Python 3.12 (uv-managed venv).

* fix(channels): share contention retries and diagnose prolonged lock waits

* docs(channels): shorten guidance to satisfy lint budget

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-25 11:05:04 +08:00
ShiYuan Xiong
b3b1424799
fix(dingtalk): validate access token expiry (#7450)
* fix(dingtalk): validate access token expiry

* fix(dingtalk): reject token lifetimes within refresh margin

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-25 08:57:28 +08:00
Qiu
a674132eed
fix(channels): recover from undecodable thread-binding state (#7233)
Fixes bytedance/deer-flow#7232.

Co-authored-by: Jun516 <174629376+Jun516@users.noreply.github.com>
2026-10-21 21:56:12 +08:00
Qiu
8b68832f32
fix(channels): release standalone DingTalk upload sandboxes (#7203)
Refs bytedance/deer-flow#3296.

Co-authored-by: Jun516 <174629376+Jun516@users.noreply.github.com>
2026-10-21 10:53:49 +08:00
Nan Gao
085924a3d0
fix(channels): run each IM message once across instances, restarts and late redeliveries (#7104)
* fix(channels): run each IM message once across instances, restarts and late redeliveries

Inbound IM messages were deduplicated only by the 10-minute inbound dedupe
store (#7103). Discord direct messages had no key at all, a Discord thread
reply got an instance-dependent key, a Discord thread was created before
dedupe ran, a redelivery past the window started a second run, and two
instances racing on a conversation's first messages bound it to two
threads.

- Discord messages are keyed (discord, guild_id or "dm", posting channel,
  message_id); the posting channel is the same on every instance.
- MessageBus.claim_inbound lets an adapter record the manager's dedupe key
  before a side effect; Discord claims it on the Gateway loop before
  _create_thread, marks the published message dedupe_claimed, and gives the
  claim back when the message is never committed.
- runs.wait / runs.stream / runs.create send an Idempotency-Key derived from
  the dedupe key, and keyed human message ids are uuid5 of it (a uuid4 per
  attempt made every keyed retry a 409). A reused run's status-only /wait or
  stream gap is resolved to this message's own turn via threads.get_state;
  409s about the message's own keyed run keep the key instead of reading as
  a busy thread; an ended reused GitHub run gets no follow-up watcher.
- A conversation's first thread binding is claimed insert-if-absent
  (claim_thread_id on channel_conversations and channel_thread_bindings);
  the loser adopts the winner and deletes the empty thread it minted.
  /new and /agent use still rebind.

Docs: IM_CHANNEL_CONNECTIONS.md, CONFIGURATION.md, channels AGENTS.md,
CHANGELOG (en/zh).

* fix(channels): give back Discord dedupe claims for messages never processed

Review round 1 on #7104. A Discord claim records the manager's dedupe key
before the message is queued, so every path that drops the message unrun
must release it, or a peer refuses the redelivery for the rest of the TTL:

- The hand-off task releases the claim on cancellation too (stop() drains it
  while it resolves the connection identity), via await_drained so the
  cancellation still propagates.
- ChannelManager._discard_pending_inbound releases the key of a claimed
  message that shutdown drops from the queue, after the grace period and on
  a cancelled shutdown.
- The adapter tracks claims until they are committed. A release refused
  because stop() already closed cross-loop intake, or a hand-off cancelled
  before it ran, leaves the claim tracked, and stop() releases it last.

* fix(gateway): keep IM run idempotency stable when /new rebinds the conversation

Review round 2 on #7104. The Gateway scoped every Idempotency-Key by owner
and thread. /new rebinds an IM conversation to a fresh thread, so a late
redelivery of a message admitted on the old thread was requested on the new
one, got a different scoped key and started a second run.

- Keys from trusted internal callers (the IM channel manager) are scoped by
  owner only (`internal-run:` prefix); browser and API keys keep the
  owner-and-thread scope (`http-run:`).
- A key that resolves to a run of another thread or user raises
  RunIdempotencyKeyMismatch, a RuntimeError subclass so in-process launchers
  see the same type, and start_run answers it with 409 "Idempotency-Key
  already used with a different request" instead of a 500. The channel
  manager already treats that 409 as the message's admitted run.

Docs: API.md, IM_CHANNEL_CONNECTIONS.md, gateway AGENTS.md (reworded to stay
under its budget), CHANGELOG (en/zh).

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-19 22:57:19 +08:00
SumEn
fee0cd0c18
fix(wechat): isolate downloaded attachments before inbound ingestion (#7055)
* fix(wechat): isolate downloaded attachments before inbound ingestion

* docs(channels): preserve the existing guide heading

* fix(wechat): release owned inbound attachment staging

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-19 16:31:48 +08:00
Nan Gao
8d5f5fd0a8
feat(channels): share encrypted runtime channel config across Gateway instances (#6988)
* feat(channels): share encrypted runtime channel config across Gateway instances

IM bot credentials entered in the web UI lived in each host's plaintext
channels/runtime-config.json, and the router cached the merged config on
app.state for the life of the process. With several Gateway instances a
save or disconnect served by an instance that does not hold the
provider's leader lease never reached the transport holder: it kept the
old credentials until it next started the channel, and a disconnect
could leave the old bot connected.

- New shared table channel_runtime_configs (migration 0044): one row per
  provider with the config block encrypted under DEER_FLOW_CREDENTIALS_KEY,
  a plaintext disconnect tombstone, and a version bumped by every save.
- SqlChannelRuntimeConfigStore replaces the JSON file for sqlite/postgres
  (memory keeps the file); it is synchronous on app_sync_sqlalchemy_url
  because its callers already run in worker threads.
- The router re-reads the store on every request instead of caching the
  merged config, and refuses (503) to save credentials without a key.
- The leader-lease supervisor compares the versions on each renewal tick
  and restarts or stops the holder's transport when a provider's config
  changed; a takeover reads a disconnect instead of reviving cached
  credentials.
- The first start imports an existing runtime-config.json (rows already
  in the table win) and deletes the plaintext file.

* docs(changelog): reference #6988 for the shared runtime channel config

* ci: rerun checks after #6999 fixed the frontend guidance budget on main

* fix(channels): start a held transport that is down when its runtime config changes

When the lease holder's transport was down (a start that failed on the
old credentials), applying a peer-saved config only updated the cached
config, and the transport started on the next lease round: two renewal
ticks instead of the one the docs promise. The apply step now starts it
on the held lease right away, with the lease round's backoff bookkeeping.
2026-10-18 11:10:31 +08:00
Totoro
c994826d06
feat(channels): generalize group notifications and add opt-in task/goal IM notices (#6903)
* feat(channels): generalize IM group targets and add task/goal run notifications

- Generalize proactive group notification capability (proactive_group_notifications), binding validation, and scheduled task group selection across Feishu, QQ, and WeCom while keeping WeChat personal-only.
- Add QQ group active notification delivery via POST /v2/groups/{group_openid}/messages (msg_type=2) and WeCom group active notification delivery via WS Client.sendMessage(chatid, body) with bot_id:group:chatid workspace isolation.
- Generalize notification outbox sources via migration 0043_runtime_im_notices (source_kind, source_key, run_id, nullable task_id/task_run_id) with backward-compatible SQLite/PostgreSQL upgrades.
- Add opt-in ordinary task & goal run IM notifications (run_completed, run_failed, human_input_requested) on the shared outbox, delivery worker, renderer, and settings UI.

* fix(notifications): address review feedback and group/runtime notification edge cases

* test(migrations): update CURRENT_HEAD to 0043_runtime_im_notices across migration chain tests

* fix(channels): preserve group binding names and legacy authorization

Carry owner-chosen group names through existing connect state, keep private bindings out of legacy group fallback, and restore completion metadata retries. Cover real binding and worker finalization paths and synchronize notification docs.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-15 10:30:55 +08:00
Nan Gao
33b8e84f11
fix(channels): keep IM runs alive when the creating request disconnects (#6876)
* fix(channels): keep IM runs alive when the creating request disconnects

The channel manager holds a `runs.wait` / `runs.stream` request open for
the whole run, and that request dies with the Gateway instance that
received the IM message: a leader hand-off, a rolling restart, a crash.
The Gateway cancels a run whose creating request disconnects unless the
request asked for `on_disconnect: continue`, so every IM run in flight
was cancelled on failover.

Channel runs now ask for `continue`, like scheduled-task and MCP-task
runs already do. The run finishes on the Gateway executing it and its
thread state is persisted, so the answer stays readable in the web UI
even though the IM reply is lost with the old transport. The key is set
after the fire-and-forget branch because the SDK's `runs.create` has no
`on_disconnect` parameter.

Docs: CONFIGURATION.md and the Helm/deployment guides no longer list
"failover cancels IM runs" as a known limit; IM_CHANNEL_CONNECTIONS.md
and the channels AGENTS.md describe the policy.

* docs(changelog): link #6876

* fix(channels): keep the inbound dedupe key once a run may be executing

With `on_disconnect: continue` a run outlives the manager worker that
requested it, but the worker loop released the inbound dedupe key on
cancellation, so a provider redelivery on the replacement instance
admitted the same event again and executed the turn twice (review on
#6876).

Each inbound message now carries an admission record: `requested`
flips before the run request leaves the manager, `confirmed` once the
Gateway proved it admitted the run (a run id, a stream frame, a
completed response). The generic release paths (worker cancellation,
worker/handler exceptions, a broken stream) keep the key when the run
may continue: after confirmation, on cancellation after the request
was sent, and on post-request transport failures (read timeout, read
error, protocol error). Pre-request failures, Gateway rejections
(busy thread, stale pin, status errors, connection failures) and the
stream's terminal `error` frame still release it so the redelivery
can retry.
2026-10-14 22:07:07 +08:00
FanouZeng-TT
064bb50602
fix(buzz): page a saturated history instead of skipping the oldest (#6792)
* fix(buzz): page a saturated history instead of skipping the oldest

buzz-relay serves at most RELAY_HISTORY_LIMIT (2000) stored events per
subscription, NEWEST-FIRST, even when the filter carries a `since`. A channel
that banked more than the cap across one disconnect therefore had its OLDEST
unread messages neither sent nor marked: the live page delivered the newest
2000, the watermark advanced past them, and the rest were lost -- the one
remaining path in this connector that could skip a message outright.

A chat/backlog page's EOSE now drives a descending `until` walk. When a page
comes back AT the cap it may have older matches held back, so the connector
re-asks with `until` set to that page's oldest `created_at` -- on a SEPARATE
`buzz-backlog-<uuid>` subscription id, never the live one -- and repeats until
a page comes back under the cap or reaches the `since` the live REQ was sent
with, bounded by MAX_BACKLOG_PAGES (50, warned when hit). Delivery is charged
to the subscription that carried it before any gate, so a page's saturation is
measured against what the relay sent, not what the allowlist/mention gates kept.

The walk is strictly additive: the live subscription, its filter, and the
watermark are never touched; a CLOSED for a backlog page only ends that walk;
re-opening the live subscription closes any superseded page rather than
orphaning it; and every failure simply leaves the pre-existing behaviour for the
next reconnect to retry.

* fix(buzz): skip the backlog walk when the channel has no read floor

Reviewer willem-bd noted the descending-until walk has no semantic stop when
`floor is None`: that is exactly a channel's first connect (no stored
watermark) and again after a MAX_CACHED_CHANNELS eviction of its cursor. On
those connects the walk would fetch up to MAX_BACKLOG_PAGES * RELAY_HISTORY_LIMIT
events per channel instead of the newest 2000 the connector used to take.

Require a floor before opening any backlog page. The backlog-loss case this walk
exists for always has a watermark (the cursor survives reconnects and restarts),
so it always has a floor; first connect and an evicted cursor keep the previous
behaviour. Covers the skip with a new test, retargets the page-bound test to a
real walk above its floor, and documents the floor requirement.

* fix(buzz): stop orphaned and stalled backlog walks

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-14 15:59:46 +08:00
Nan Gao
e8c7df9042
feat(channels): arbitrate IM transports with a per-provider leader lease (#6843)
* feat(channels): arbitrate IM transports with a per-provider leader lease

Every Gateway instance connected every enabled IM platform with the same
bot credentials, so channels could not run on more than one instance
(Telegram 409 storms, Discord answering twice, scattered per-process
state). channels.leader_lease was reserved by #6746; this implements it.

- channel_leases table (migration 0041): one row per provider. Acquire is
  one dialect-native upsert decided against the database clock and bumps
  a fencing epoch; renew extends only a live lease at the holder's epoch;
  release sets the expiry to 0 so a peer takes over on its next tick.
- app/channels/leadership.py: the holder's local monotonic deadline
  (send time of the last confirmed renewal + lease_seconds); holds() turns
  false one renewal interval early, so the transport stops at least
  grace_seconds before a peer may connect. DB calls are bounded.
- ChannelService: start fence in _start_channel (no DB call) behind every
  start path; fixed-rate supervisor renews/acquires and runs transport
  starts/stops as per-provider tasks; takeover reloads the provider's
  saved config; failed starts back off; shutdown releases only leases
  whose transport stopped. get_status reports leader/leader_here and the
  holder's transport state on every instance.
- Notification worker leaves rows of providers another instance holds.
- Gate: the enabled=false refusal now recommends auto/true; an explicit
  true with database.backend memory is refused when a channel is enabled.
- Docs, Helm, deployment guides, AGENTS.md and CHANGELOG updated.

* docs(changelog): link the channel leader lease entry to #6843

* fix(channels): see peer-held leases for providers absent from local config

Review on #6843: a provider connected from the web UI on a peer never
enters this instance's channel config, so the notification worker here
still claimed its rows (and parked them for 900s) and GET /api/channels/
reported no leader for it. Every supervision round, and startup, now also
reads the whole lease table once (bounded), and notification exclusions
and status use those observations: a provider whose live lease a peer
holds is left to it even without local config.

Running that read concurrently with renewals exposed that, through
SQLAlchemy's aiosqlite adapter, an UPDATE ... RETURNING racing a read on
another connection can hand back one of its own bind values instead of
the column (stored rows stay correct; plain sqlite3 and raw aiosqlite do
not show it). A lease must never take such a row for a confirmed
renewal, so acquire/renew/release now decide by affected-row count and
acquire reads its epoch back in the same transaction.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-14 13:51:42 +08:00
Totoro
cb90f5dca4
feat(channels): control IM notifications and add WeChat/QQ delivery (#6850)
* feat(channels): control IM notifications and add WeChat/QQ delivery

* fix(scheduler): skip notification preference locks without recipients

---------

Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-14 11:14:48 +08:00
Zhengxu (Joshua) Jin
70e8f8974f
fix(channels): parse Slack and DingTalk allowed_users fail-closed (#6742)
* fix(channels): parse Slack and DingTalk allowed_users fail-closed

Slack and DingTalk still built their allowlists with their own
_normalize_allowed_users: every entry went through str() without
stripping, only list/tuple/set counted as collections, and the gate was
`if self._allowed_users and ...`. A configured list whose entries are all
blank, such as `allowed_users: [$SLACK_USER_ID]` with the variable set to
an empty string, became an empty set and let every sender through.
" U123 " never matched U123, and a frozenset was stringified into one ID.

Parse both with the shared parse_allowed_users helper that Telegram,
WeChat and QQ use, and gate on `is not None`: unset, null, empty
collections and a blank string allow everyone; a scalar is one entry;
strings are stripped and integers keep matching their string form, as
before; booleans, floats, None and mappings are rejected (a mapping as a
whole); a configured value with no valid ID denies every sender and logs
an error. A scalar containing commas or whitespace warns that it stays
one literal ID.

* refactor(channels): share the Slack and DingTalk allowlist coercer and separator warning

Slack and DingTalk had byte-identical ID coercers and their own copies of
the scalar-separator warning. Move the coercer into allowed_users.py as
parse_string_or_int_user_id and opt both channels into
warn_on_scalar_separators, as QQ and WeChat do. Log messages are unchanged.

Pin that a separator-only string such as "," stays one literal ID
(denying everyone else) and still warns.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-14 10:56:31 +08:00
ooiuuii
499fbe1142
fix(telegram): allow deleted reply targets in private chats (#6791)
* fix(telegram): allow deleted reply targets in private chats

* fix(telegram): preserve previews after reply target deletion
2026-10-14 10:32:30 +08:00
Totoro
c5bbaea02f
fix(channels): clean up interrupted Telegram initialization (#6810)
* fix(channels): clean up interrupted Telegram initialization

* fix(channels): preserve Telegram shutdown failures and finalize generators

---------

Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
2026-10-14 06:20:33 +08:00
Nan Gao
5841c8e9b7
fix(channels): make Feishu and DingTalk transports stoppable in-process (#6798)
* fix(channels): make Feishu and DingTalk transports stoppable in-process

FeishuChannel.stop() and DingTalkChannel.stop() could not end their SDK
threads. Both SDKs run on a thread-private event loop that only returned
on a fatal error: lark-oapi's ws.Client.start() parks forever in
run_until_complete(_select()) and has no public stop, and the channel
never kept the client handle; dingtalk-stream's start_forever() is a
while-True loop that also catches CancelledError, and the disconnect()
the channel called does not exist on the SDK. So every stop joined for
5s, raised ChannelStopTimeout, and ChannelService retained the instance
while the platform connection stayed alive on the old process. That
burns the full budget on shutdown and on POST /api/channels/{name}/restart,
and it blocks the per-provider leader lease: a demoted leader could not
release the transport before a peer took over.

Both channels now publish the SDK thread's loop and client under a lock;
stop() schedules a shutdown on that loop and then joins. Feishu disarms
lark's _auto_reconnect before closing the socket and stopping the loop,
because start() otherwise swallows the interrupted run_until_complete in
its reconnect branch and dials again. DingTalk drives its own copy of the
SDK connect loop that re-checks the running flag before every dial, runs
the blocking ticket request off the loop, cancels keepalive with the
socket, and logs the endpoint rather than the ticket-bearing payload. A
shared shutdown_private_loop cancels the SDK's leftover tasks with a
bounded drain and closes the loop; a stop requested before the client
exists never dials. ChannelStopTimeout now only fires for a thread that
genuinely wedges inside an SDK call. Telegram already stopped this way.

Tests: a fake lark ws.Client with the SDK's start() shape (including the
reconnect branch that swallows a bare loop stop), a real local websockets
server that observes the DingTalk close, the stop-before-client race for
both, and a blocking-io anchor that stop() ends a live private loop
without blocking the Gateway loop. All eight were red on main.

* docs(changelog): link the stoppable-transports entry to #6798

* fix(channels): release the socket on a stalled close and bound the DingTalk ticket request

Review follow-ups on #6798 (Totoro-qaq):

Feishu: the bounded _disconnect() wait cancelled websockets' close before
its own close-timeout abort ran, and lark clears _conn in its finally, so
a peer that never answers the close frame left the socket to garbage
collection. Worse, a stop landing while _connect() still held lark's
_lock snapshotted _conn as None. _shutdown_ws now settles the in-flight
dial by taking that lock under one shared close deadline, snapshots the
connection, runs _disconnect() under the same deadline, and aborts the
transport itself (abort_connection_transport in base.py) when the
handshake did not finish, yielding once so connection_lost runs before
loop.stop(). DingTalk's websocket.close() path gets the same abort.

DingTalk: the SDK's open_connection() calls requests.post with no
timeout, and running it in an executor let a stalled ticket endpoint
block interpreter exit (executor workers are joined at shutdown). The
channel now sends the same request through httpx on the stream loop
under a 30s deadline, so stop() cancels it and no worker thread lingers.

Tests drive the real SDK clients against loopback peers: a WebSocket
peer that completes the handshake and never ACKs the close (both
channels assert the connection's socket reports fileno() == -1 right
after stop(); red with the abort disabled), a real websockets server for
the clean path, and a loopback ticket server (ok / 500 / never responds)
with a no-lingering-non-daemon-thread check and a request-shape check.
2026-10-13 22:24:46 +08:00
Totoro
a1e3e75b17
fix(channels): retain Slack connect work through shutdown (#6808)
* fix(channels): retain Slack connect work through shutdown

* fix(channels): distinguish Slack cleanup failures from shutdown deadlines

---------

Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
2026-10-13 22:05:40 +08:00
Grapette.L
9b2151a3a8
fix(buzz): a single mention_free_channels ID was split into characters, a numeric one stopped the connector (#6776)
* fix(buzz): accept a single mention-free channel id

`_mention_free` iterated the configured value directly, so one unquoted
UUID became a set of its characters and a numeric or boolean scalar
raised TypeError while the channel was being constructed. Both shapes are
documented as valid for the sibling Discord connector.

Parse the key through a dedicated helper that follows the Discord
convention: a scalar is a single entry, list entries are kept verbatim,
and an unusable entry never matches so `require_mention` stays in force.

* fix(channels): share scalar-or-list channel ID parsing

* docs(channels): keep guidance within CI size budget

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-13 14:55:49 +08:00
FanouZeng-TT
8319e3384b
fix(telegram): retain loop cleanup across bounded stops (#6772)
* fix(telegram): retain loop cleanup across bounded stops

* fix(telegram): release abandoned cleanup generations

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-13 08:00:41 +08:00
Totoro
5ae95e6a34
feat: add Feishu group notifications and fix mention caret placement (#6762)
* feat(feishu): deliver scheduled-task notifications to bound users

* feat: add Feishu group notifications and fix mention caret placement

* fix(frontend): preserve group targets while capabilities load

---------

Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
2026-10-13 07:17:56 +08:00
xiaodu55
22ddb77f83
docs(agents): trim restated rationale to restore AG002 chain headroom (#6759)
* docs(agents): trim restated rationale to restore AG002 chain headroom

The agents/middlewares AGENTS.md chain crossed the 96 KiB AG002 hard
limit at 3f7a0769 (98441 > 98304 bytes); that push produced no lint-check
run, and later pushes compare equal to their base, so main stayed green
while a full-worktree check_agent_guidance run reports an error. Trim
restated rationale:

- backend/AGENTS.md: the Feishu button-card sentence restated semantics
  app/channels/AGENTS.md already owns (originator-validated, one-time,
  fail-closed clicks); keep the flag visibility and point at the module
  guide.
- agents/middlewares/AGENTS.md: drop the membership-probe rationale
  duplicated by the model_response.py paragraph, the ToolOutputBudget
  ordering narration, and the lead-only append note restated by the
  lead-only middlewares heading.

Chain: 98441 -> 98198 bytes (106 under the hard limit).

* docs(channels): own the Feishu card fail-closed invariant in the module guide

Review follow-up on #6759: the backend/AGENTS.md trim dropped two things
app/channels/AGENTS.md item 7 does not own - the fail-closed-across-workers
guarantee and the owning file's name - so the invariant left every
instruction chain and the pointer dead-ended one level above the
implementation. State both in item 7, paid by dropping the therefore-clause
restated by the single-active-owner invariant above it (the partial unique
index and the losing-writer retry already imply deterministic resolution).

channels/AGENTS.md: 49134 -> 49129 bytes (AG001 headroom 18 -> 23).

---------

Co-authored-by: xiaodu55 <xiaodu55@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-12 22:22:44 +08:00
Zhengxu (Joshua) Jin
47b6755ce9
fix(qq): parse allowed_users with the shared fail-closed allowlist (#6730)
* fix(qq): parse allowed_users with the shared fail-closed allowlist

QQChannel built its allowlist with set(config.get("allowed_users") or []).
A single OpenID, which is what an `allowed_users: $QQ_ALLOWED_USERS`
reference resolves to, became a set of its characters, so the configured
user was silently dropped. A mapping authorized every key, including one
mapped to false, and a numeric scalar raised TypeError while the channel
was being constructed.

Parse it with the shared parse_allowed_users helper that Telegram and
WeChat already use: unset, null, empty collections and a blank string
allow everyone; a scalar OpenID is one entry; entries that are not
non-blank strings (such as numeric QQ account numbers) are dropped with a
warning, and a mapping is rejected as a whole; a configured value with no
valid OpenID denies every sender and logs an error. A scalar containing commas or whitespace warns
that it stays one literal ID. Binding codes still precede the allowlist.

* test(qq): gate group messages by member OpenID in allowlist tests

Every allowlist test sent a C2C frame, and the shared event() helper puts
the sender in both user_openid and member_openid, so a group-path
regression in sender resolution would pass unnoticed. admits() now takes
a kind; group frames carry only member_openid and a group_openid that
differs from the sender. The listed-OpenID test runs for both kinds, and a
new test shows that listing the group's OpenID does not admit its members.

Document that a blank or whitespace-only allowed_users string allows every
sender, like an unset value or an empty list.

* refactor(channels): share scalar allowlist separator warnings

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-12 22:19:11 +08:00
jay
17f774b442
fix(dingtalk): preserve Markdown table cell boundaries (#6741)
* fix(dingtalk): preserve Markdown table cell boundaries

DingTalk's sampleMarkdown fallback re-splits table rows on every pipe, so an
escaped pipe (\|) inside a cell is read as a column separator and every value
after it shifts one column to the left. The strip("|") wrapper also removes an
empty leading or trailing cell, dropping that value's column label.

Split rows on unescaped pipes only, and drop at most one outer border cell per
side, so escaped pipes and empty edge cells stay under the right label.

Adds six parameterized regressions plus one full fallback-adapter regression;
all seven fail on the base commit and pass after the fix.

* docs(dingtalk): document escaped-backslash boundary in table row splitter

Document the residual boundary case flagged in review: the
single-character lookbehind in _split_markdown_table_row cannot
distinguish an escaped pipe (\|) from an escaped backslash followed
by a real separator (\\|), so a cell ending in a literal backslash
absorbs the next cell. Full GFM backslash counting is out of scope
for this helper.
2026-10-12 20:34:42 +08:00
Totoro
4b36e680c3
feat(feishu): deliver scheduled-task notifications to bound users (#6753)
Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
2026-10-12 20:08:16 +08:00
Storm
3f7a076972
feat(channels): Feishu interactive clarification cards (#6670)
* feat(channels): render Feishu option clarifications as button cards

When the agent's ask_clarification carries options, the Feishu channel
now renders the question as an interactive card with one button per
option instead of plain text. This is the outbound half: card
construction plus a process-local pending registry that a later
card-action callback handler will validate clicks against.

- New feishu_card_interaction module owns request extraction, button
  card rendering, and the pending registry (30-minute TTL, matching the
  typed-reply clarification window)
- Manager attaches the clarification's structured human_input payload to
  the final outbound's metadata so channels can render richer
  affordances without touching the Gateway protocol
- Feishu channel renders the button card only when
  card_interaction_enabled is set (default off); free-text, form, and
  over-four-option clarifications keep the plain-text path
- Button cards bypass the markdown wrapper via a raw_card_content flag
  on the card send/patch helpers; plain-text call signatures unchanged

* feat(channels): close the Feishu button-card click loop

The inbound half of interactive clarification cards: a click on an
option button validates, resolves the clarification, and marks the
card in place.

- Register the card-action callback only when card_interaction_enabled
  is set; clicks arrive as WS EVENT frames through the SDK's callback
  processor map, and the synchronous return value becomes the toast ack
- consume_if_originator atomically validates operator identity against
  the pending entry: non-originators get a refusal toast and the entry
  survives; unknown or expired requests are toast-only and dropped
- A validated click consumes once, updates the card to a terminal
  resolved state, and injects a hidden inbound carrying the
  human_input_response so the run resumes on the originating thread
  with Web-card parity
- Manager translates the inbound's human_input_response metadata into
  the human message's additional_kwargs with hide_from_ui set

* docs(channels): document Feishu interactive clarification cards

- README / README_zh: optional card_interaction_enabled setup, calling
  out that card.action.trigger lives under the console's callback
  configuration tab (separate from event subscriptions) and requires
  publishing a new app version
- config.example.yaml: commented flag with the same console caveat
- backend/AGENTS.md: module ownership note for feishu_card_interaction

* fix(channels): close Feishu card-click routing and ack gaps from review

Address review on #6670:

- Carry the routing identity (topic_id, connection_id, owner_user_id)
  on the pending entry and the synthetic inbound, so clicks resolve to
  the clarified thread in topic-scoped chats and pass bound-identity
  admission instead of opening a new thread or being rejected
- Validate option_value against the registered options at consume time:
  an unknown value is treated like a stale card and the entry is kept
- Return a warning toast when a click cannot be dispatched, and flip
  the card to its resolved state only after the inbound is durably in
  the bus — never display a choice that was not recorded
- Drop the dead mark_sent/consume registry APIs and pass the payload's
  own option ids through to the buttons
- Add an end-to-end test through the manager's thread resolution
  pinning that a card answer reuses the clarified thread

* fix(channels): carry workspace_id on the Feishu card-click inbound

The connections-enabled admission path re-reads the binding with the
message's workspace_id, and Feishu connections are bound with
workspace_id = chat_id. The synthetic inbound set connection_id and
owner_user_id but never workspace_id, so the re-read missed and every
click was rejected as unbound after the entry was consumed.

- Set inbound.workspace_id from the entry's chat_id (Feishu's
  chat-as-workspace binding convention)
- Extend the card-click e2e to require_bound_identity=True, driven
  through the channel's real _resolve_card_click production

* fix(channels): stabilize Feishu clarification delivery and routing

Option cards could fail through the real reply SDK, resume another conversation,
or leave a stale text hint after the answer was accepted.

Unify raw-card delivery and persist trusted thread aliases before showing buttons.
Keep protocol request IDs separate from bounded, one-time callback tokens; validate
originators and option pairs, and retire only the accepted card's text/button state.
Treat valid hidden human answers as turn boundaries so old replies and artifacts
do not leak into the next answer. Cover consecutive clarifications and /new routing
through real SDK request models, stores, and the run journal.

Validation: 147 focused tests and 29,421 offline tests passed (405 skipped);
strict blocking-I/O passed 245 tests (1 skipped). Ruff and guidance checks passed.

* fix(channels): preserve clarification context in Feishu cards

Button cards replaced the full clarification text but omitted its background,
leaving users without the context needed to choose an option.

Carry optional context from request metadata and render its original Markdown
before the question. Keep question-only cards for absent, blank, or invalid context.
Cover rendering and all real SDK delivery paths, and update both user guides.

Validation: 156 focused tests, 245 strict blocking-I/O tests, Ruff, and guidance
checks passed. The offline run recorded 29,381 passed, 49 failed, and 405 skipped;
48 import failures reproduced before the fix. All 49 cases passed on both versions
in isolated environments, and the Feishu-to-export order check passed 13 tests.
2026-10-11 23:58:54 +08:00
Nan Gao
6c4c74a504
fix(channels): share chat-to-thread bindings through the database (#6558)
* fix(channels): share chat-to-thread bindings through the database

ChannelManager kept its IM chat-to-thread bindings
(channel_name:chat_id[:topic_id] -> thread_id) in a per-process JSON file,
channels/store.json, loaded once and rewritten whole on every change. With
several Gateway processes a binding created on one replica was invisible to
the others (the next message for the same chat opened a second thread) and
concurrent writers clobbered each other's file.

- New channel_thread_bindings table (migration 0036_channel_thread_bindings)
  and harness package deerflow.persistence.channel_thread_bindings: key PK
  (the legacy composite string), channel_name, chat_id, nullable topic_id,
  thread_id, user_id, epoch created_at/updated_at; index (channel_name,
  chat_id). Dialect upsert keeps created_at; prefix remove and batched
  ON CONFLICT DO NOTHING import.
- app.channels.store.ChannelStore is an async protocol: SqlChannelStore for
  sqlite/postgres, JsonChannelStore (file I/O via asyncio.to_thread) for
  database.backend: memory. resolve_channel_store picks at ChannelService
  construction; ChannelService.start() imports an existing store.json once
  into an empty table and renames it store.json.migrated (a concurrently
  importing peer neither duplicates nor clobbers; a populated table leaves
  the file alone).
- Callers updated: manager, connection_identity.lookup_thread_id, Feishu
  (send() awaits the mapping writer; the synchronous lark callback bridges
  its two store calls to the Gateway loop with a bounded wait), Discord
  (channels_dir).
- Head pins in the 0027_0028/0029/0032/0034/0035 migration tests point at
  the new head; docs (channels and migrations AGENTS.md, CONFIGURATION.md,
  README) and CHANGELOG updated.

* docs(changelog): link the channel thread bindings entry to #6558

* fix(channels): reject only the Gateway loop in the Feishu store bridge

lark-oapi 1.5.5 invokes the message callback synchronously from its async
frame handler, on the private event loop that FeishuChannel._run_ws starts
for the SDK thread. The bridge's guard treated any running loop as unsafe,
so every real inbound message skipped the stored-topic lookup and the
pending-mapping restore: a reply whose parent card was mapped but whose
root was not fell back to the unknown root, and could consume an
unrelated pending clarification.

Only a wait issued from the Gateway loop itself (the loop that executes
the submitted coroutine) is refused now; the SDK's own loop may block for
the bounded wait, as that thread blocked on the JSON rewrite before.
Tests drive _on_message from a worker thread running its own loop, the
production shape, for both the lookup and the write path, and pin the
Gateway-loop refusal.

* fix(channels): defer unresolved Feishu lookups and harden the store.json import

Review follow-ups on the chat-to-thread binding store (#6558):

- Import: a legacy entry whose key, channel_name, chat_id, topic_id,
  thread_id or user_id exceeds its column width is skipped and counted
  with the malformed ones instead of failing the whole batch on
  PostgreSQL (SQLite silently accepted the same row, so the backends
  diverged on one file). The corrupt-file warning now says what each
  caller does: the JSON store starts fresh, the import is skipped and
  the file kept.
- Feishu bridge: store calls run on the Gateway loop under a 3s
  asyncio.wait_for (the SDK loop also services ws keepalives; the old
  10s bound could stall it per message), and the lark-thread wait is
  that budget plus 1s. A deadline or store error is StoreLookupUnresolved,
  never a miss: _on_message no longer routes such a reply onto its
  unknown root (which created a second thread and later overwrote the
  parent alias). The message joins a per-chat FIFO drained on the
  Gateway loop with backoff for up to 30s, then is dropped with a
  warning. _on_message is split into parse / resolve / dispatch so the
  deferred path dispatches the same way; later messages for the chat
  queue behind the deferred one.

* fix(tests): expect follow_redirects in the Jina client kwargs after #5808

#5808 made JinaClient pass follow_redirects=True to httpx.AsyncClient and
updated test_jina_client.py, but test_jina_response_limit.py and
test_jina_retry_after.py still pin the exact client kwargs to proxy and
trust_env, so every backend-unit-tests shard that lands on them fails on
main (the backend unit-test workflow only runs on pull requests, which is
why the push to main did not surface it). Update the two expectations to
include the new keyword.

(cherry picked from commit d5aa23156ce19ce36d95df03953442f83ae6d050)

* Revert "fix(tests): expect follow_redirects in the Jina client kwargs after #5808"

This reverts commit 5bee00bfb09c65a30f77fb4df47a1f82c47ecef7.

* fix(channels): bound deferred Feishu routing by the inbound queue capacity

Review follow-up on #6558: a store outage let every subsequent message for
a chat join the deferred queue with no capacity check (the bus reservation
was only taken after routing succeeded), and each queued message started a
fresh 30s retry budget when it reached the head, so a burst could be
retained for N x 30s and grow without bound.

- Intake is reserved BEFORE routing for every store-backed message
  (bus.reserve_inbound is thread-safe and already used from SDK threads),
  so deferred and live messages share the one configured bound,
  channels.inbound_queue_maxsize; a message that cannot be admitted is
  dropped at once with a warning, never queued or routed as a miss.
- The reservation travels with the message into dispatch (committed by
  _prepare_inbound), is released on the batch path (the flush reserves at
  publish time), on drop after the budget, on a failed deferral, on
  dispatch failure and for every leftover when the drain task is cancelled
  by stop().
- The retry budget runs from the message's arrival: a queued message whose
  budget is spent when it reaches the head is dropped without a lookup.
- The bounded resolver returns the unresolved outcome instead of raising
  it through the tracked task, which logged an ERROR for an expected,
  handled store timeout.
- Upstream's new blocking-io test constructs the store by its old class
  name; it now uses JsonChannelStore.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-10 14:00:00 +08:00
Storm
061a7dfc14
feat(channels): select the model per conversation via /model (#6534)
* feat(channels): select the model per conversation via /model

IM channels could list models with /models but had no way to choose one:
the model for a conversation came only from channel/session config or the
server default, so a user who needed a stronger or cheaper model for one
task had to reconfigure the whole channel or move to the web UI.

- /model <name> pins a model to the current conversation, validated
  against the caller-visible /api/models list (list membership doubles
  as the authorization check), persisted in thread metadata so it
  survives a Gateway restart and applies from the next message in
  place — unlike /agent use, which must start a new conversation.
- The pin is written to all three runtime carriers (run context,
  configurable, context) after the session layers merge, so it wins
  over channel/session configured models; like any explicitly
  requested model it also takes precedence over a Custom Agent's
  configured model.
- /model (no args) reports the effective model and its source through
  one resolver that mirrors the run merge order (default -> channel ->
  user, context carrier over configurable, both session-config
  carriers scanned); /model default clears the pin; /models names the
  pinned model in a footer.
- Pin lookups degrade instead of failing messages: the status query
  reports "Current model: unknown" on a lookup failure (matching the
  /models footer), and the reuse-thread preload leaves the in-process
  cache untouched so the next turn retries a transient threads.get.
- Reserve "model" in the slash-skill reserved-name contract (backend,
  contract fixture, frontend), register the Telegram handler, and
  document the command in /help, README, and README_zh.

* fix(channels): fence model pin cache and mirror runtime model resolution

Review follow-up for /model:

- Serialize cold pin loads with persist+cache publication under a
  per-manager lock, so a slow threads.get that captured pre-pin
  metadata can never overwrite a concurrently committed selection
  (commands are not run-serialized).
- _resolve_configured_model_name now replicates the run path's shallow
  session merges and applies the real runtime precedence —
  config.context (preserved by build_run_config, unbeatable by the
  setdefault-filled body context) > top-level context > configurable —
  instead of scanning per-layer carriers; a later layer replacing a
  nested carrier wholesale no longer leaks a stale value into the
  status reply.
- /api/models resolves the trusted bound owner for internal channel
  calls and filters the list with the owner's role, so /model
  selection gates on the same principal that run admission admits for
  that account.

Tests: pin-write-vs-in-flight-load race, status compared against the
real merged runtime config (parametrized across carriers),
nested-carrier replacement, and owner-role filtering with the real
RBAC provider; each verified red by reverting exactly its guard.

* fix(channels): share model resolvers with the run path and harden the pin lifecycle

The /model status reply re-implemented the session-layer merge and the
Gateway carrier precedence, and it ignored the custom-agent model
fallback — on a thread pinned to a custom agent whose config sets
``model:`` it reported "server default" while the run actually used
the agent's model. Review also found the pin lock spanned a network
call and a stale pin bricked its conversation behind the generic
error fallback.

- Extract ``_select_carried_model_name`` as the single carrier-precedence
  rule; the status reply reuses the run path's exact ``_merge_dicts``
  layer shapes, so a future layering change has one place to touch.
- Extract ``_resolve_message_assistant_id`` / ``_resolve_assistant_id``
  and share the assistant precedence chain between the run path and
  the status reply.
- Fall back to the resolved custom agent's configured ``model`` when no
  session layer carries one, mirroring the agent factory's
  ``_resolve_model_name(requested or agent_model_name)``; the reply now
  distinguishes "(from channel configuration)" and "(from agent
  configuration)".
- Run the cold ``threads.get`` in ``_load_thread_model`` outside
  ``_model_pin_lock``; only cache publication takes the lock, with a
  post-GET re-check so an in-flight /model write still wins over the
  older snapshot.
- Detect the run-admission allowlist rejection in ``_handle_message``
  and recover: clear the stale pin (durable write plus cache under the
  lock) and reply with the reset and the /model recovery instead of
  the generic internal-error fallback.
- Document /model and the ``channel_model_name`` pin semantics in
  backend/app/channels/AGENTS.md.

* fix(channels): fence stale-pin recovery and cover streaming admission failures

Review round 4 found the stale-pin recovery itself unsafe and
incomplete:

- Recovery cleared whatever pin was current when the delayed
  admission rejection arrived, so a rejection for an old pin could
  erase a replacement selection committed mid-flight (and tell the
  user the new model was unavailable). ``_stale_pin_recovery`` now
  takes the failed run's own selection (``expected_pin``, read from
  its run context) and clears only while that selection is still
  current under the lock; a replaced or already-reset selection gets
  a non-destructive reply instead.
- Feishu/Telegram/DingTalk streaming turns never reached the recovery
  hook because ``_handle_streaming_chat`` swallows start_run's 400
  into its terminal-error reply. The recovery now runs in the
  streaming terminal-error path too, delivered as the single final
  outbound with the same dedupe cleanup; blocking, streaming, and
  fire-and-forget run-creation paths all handle the rejection at the
  site that knows the submitted selection.

* docs(channels): fit the /model entry within the agent-guidance size budget

The base file sits one byte under the AGENTS.md hard limit, so
documenting /model requires net-zero growth: keep the command-inventory
addition and a compact pin-semantics sentence, and losslessly tighten
the surrounding prose (the file was already at the limit).

* fix(channels): apply the review suggestions on /model replies

Three non-blocking findings from the latest review:

- The /models pin footer decorated the failure string too, advertising a
  pin next to an error that lists nothing. The command now fetches the
  listing through ``_fetch_gateway_json`` and appends the footer only on
  a successful fetch.
- The /model status reply labeled every session-layer model "from
  channel configuration", misreporting user- and default-layer models.
  The label is now "session configuration" for every session layer.
- Stale-pin recovery replies read as if the turn went through, but the
  run was rejected before starting and the triggering message never
  reached the agent. All recovery variants now end with an explicit
  "Your message was not processed — please send it again."

* fix(channels): recover stale model pins on the follow-up drain path

The drain is the fourth run-creation path and the only one without
stale-pin recovery: a /model pin that left the allowlist while follow-ups
were buffered made every drained runs.create fail admission, requeueing
the batch into the same 400 on every cycle with no user-facing recovery —
contradicting the documented "stale pins self-clear" invariant.

- Compute expected_pin from the drain's run context (mirroring
  _handle_chat_on_thread, initialized None since _resolve_run_params is
  itself inside the guarded try) and run _stale_pin_recovery on drain
  failure before requeueing; the pin self-clears and the requeued batch
  succeeds on the next cycle.
- Pass the caller-authoritative thread_id into _stale_pin_recovery:
  every call site already holds it, so recovery never re-derives it from
  the message — a store-mapping rewrite between dispatch and recovery
  can never clear the wrong conversation's pin, and the lookup's
  failure mode (and its guard) disappears entirely.
- Return StalePinRecovery(text, resolved) so requeueing callers can
  tell a cleared pin from a repeat failure: the drain logs INFO when
  the next cycle is free of this rejection and WARNING when recovery
  could not remove it (persist failed, or the selection came from
  session configuration, which still carries it) — no silent infinite
  requeue loop. Drain logs carry structured facts only: the reply text
  ends with a "please resend" note that would double-process buffered
  messages retried automatically (GitHub, the only buffering channel,
  sends log-only anyway).
- Reword the config-carried recovery reply: with no pin held, the
  rejected selection came from session configuration — name that source
  and the fix instead of claiming a default takeover that would not
  happen.
- Correct the _load_thread_model lock docstring (writes deliberately
  hold _model_pin_lock across threads.update) and read the agent config
  for /model status off the event loop via asyncio.to_thread, with a
  Blockbuster-gated blocking_io regression test.
- Pin the under-lock re-check in _stale_pin_recovery with a concurrency
  test (a /model committed during the lock-wait window wins; recovery
  persists nothing), mutation-verified — the drain is the only caller
  running on a background task without the serialized-run lock, so it is
  the path most exposed to that window.

* Revert "fix(channels): recover stale model pins on the follow-up drain path"

This reverts commit 0b4652c689afb56791e5e670901c97a4bf44a156. The drain
recovery work moves to a dedicated PR stacked on this branch, keeping #6534
scoped to the /model feature itself.

* fix(channels): recover stale model pins on the follow-up drain path

The drain is the fourth run-creation path and the only one without
stale-pin recovery: a /model pin that left the allowlist while follow-ups
were buffered made every drained runs.create fail admission, requeueing
the batch into the same 400 on every cycle with no user-facing recovery —
contradicting the documented "stale pins self-clear" invariant.

- Compute expected_pin from the drain's run context (mirroring
  _handle_chat_on_thread, initialized None since _resolve_run_params is
  itself inside the guarded try) and run _stale_pin_recovery on drain
  failure before requeueing; the pin self-clears and the requeued batch
  succeeds on the next cycle.
- Pass the caller-authoritative thread_id into _stale_pin_recovery:
  every call site already holds it, so recovery never re-derives it from
  the message — a store-mapping rewrite between dispatch and recovery
  can never clear the wrong conversation's pin, and the lookup's
  failure mode (and its guard) disappears entirely.
- Return StalePinRecovery(text, resolved) so requeueing callers can
  tell a cleared pin from a repeat failure: the drain logs INFO when
  the next cycle is free of this rejection and WARNING when recovery
  could not remove it (persist failed, or the selection came from
  session configuration, which still carries it) — no silent infinite
  requeue loop. Drain logs carry structured facts only: the reply text
  ends with a "please resend" note that would double-process buffered
  messages retried automatically (GitHub, the only buffering channel,
  sends log-only anyway).
- Reword the config-carried recovery reply: with no pin held, the
  rejected selection came from session configuration — name that source
  and the fix instead of claiming a default takeover that would not
  happen.
- Correct the _load_thread_model lock docstring (writes deliberately
  hold _model_pin_lock across threads.update) and read the agent config
  for /model status off the event loop via asyncio.to_thread, with a
  Blockbuster-gated blocking_io regression test.
- Pin the under-lock re-check in _stale_pin_recovery with a concurrency
  test (a /model committed during the lock-wait window wins; recovery
  persists nothing), mutation-verified — the drain is the only caller
  running on a background task without the serialized-run lock, so it is
  the path most exposed to that window.

(cherry picked from commit 0b4652c689afb56791e5e670901c97a4bf44a156)

* fix(channels): apply the third review round on /model command replies

- Delete the dead kind == "models" branch in _fetch_gateway: /models has
  rendered the listing inline since the pin-footer fix, so the helper's
  copy was unreachable and had already diverged (unguarded m["name"], no
  pin footer). The internal-auth-header test now drives the surviving
  /memory kind.
- Guard the inline /models listing comprehension like the
  _handle_model_command sibling (isinstance(m, Mapping) and
  isinstance(m.get("name"), str)), so a malformed payload entry can no
  longer turn the command into the generic internal-error reply;
  regression: test_models_command_skips_malformed_entries.
- Drop the unused user parameter from _resolve_bound_owner_for_model_list:
  the resolved owner comes solely from the trusted header, and the
  parameter implied the caller's own principal shaped the filter.

* fix(channels): apply the fourth review round on the /model lifecycle

- Preserve fresh model pins across cache eviction: a cold threads.get
  racing a successful /model write could still publish its stale
  snapshot after clear-on-pressure evicted the fresh entry — the
  in-cache re-check cannot see an eviction. The model-pin cache now
  carries a write generation; a load whose generation moved during the
  GET refetches once instead of publishing over the committed
  selection. Regression:
  test_fresh_model_pin_survives_cache_eviction_during_cold_load
  (blocked GET, write, eviction, then the refetch wins),
  mutation-verified.
- Resolve context-carried agents before reporting their model: /model
  status now mirrors the run path's effective-agent rule — explicit
  message/thread choice overwrites every carrier, then the merged
  carriers' agent_name (new _select_carried_agent_name, sharing the
  model-name precedence via _select_carried_value), then the
  session-layer assistant_id that only setdefaults into context. A
  session with context.agent_name=coder now reports coder-model instead
  of server default, and assistant_id=writer alongside
  context.agent_name=coder reports coder-model instead of writer-model.
  Regressions: context-carried, context-beats-assistant_id, and
  thread-pin-beats-context status tests.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-10 09:45:17 +08:00
NanPan
479067ea8c
fix(channels): fence manager startup across completed shutdown (#6521)
* fix(channels): fence service startup after concurrent shutdown

* test(channels): pin delayed manager startup after stop

* fix(channels): avoid duplicate manager drain during shutdown

* test(channels): name regression for shutdown generation

* test(channels): remove ambiguous epoch-named regression

* fix(channels): drain late manager start during in-flight shutdown

* test(channels): cover manager startup during in-flight channel drain

* fix(channels): serialize startup and stale cleanup

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-10 07:43:05 +08:00
LingYi
1c3b20b1bf
fix(channels): preserve inbound files on concurrent uploads (#6476)
* fix(channels): preserve inbound files on concurrent uploads

Claim IM attachment destinations exclusively and retry filename collisions.
Preserve default replacement behavior and Gateway conversion-link compatibility.

* fix(channels): claim embedded upload destinations exclusively

Retry competing publications in Feishu and DingTalk without replacing their bytes.
Cover mounted and remote sandbox paths, and keep returned filenames consistent.

---------

Signed-off-by: LingYi <lingyi01525@gmail.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-09 20:56:26 +08:00
Hyeonsang Cho
b842e06be1
fix(channels): send the CSRF pair on IM /goal writes (#6537)
* fix(channels): send the CSRF pair on IM /goal writes

`/goal <objective>` and `/goal clear` from an IM channel sent their
PUT/DELETE to the Gateway with only the internal auth headers. The
Gateway's CSRF middleware does not exempt internal auth, so with auth
enabled (the default) both writes were rejected with 403 and the channel
replied "Failed to set goal." / "Failed to clear goal.".

`_goal_request` now sends the same double-submit cookie/header pair as
the channel's LangGraph SDK client; both build it from one helper,
`ChannelManager._csrf_headers()`.

* docs(changelog): reference #6537

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-09 17:58:09 +08:00
NanPan
62c54238d2
fix(channels): fence restart during service shutdown (#6503)
* fix(channels): fence restart during service shutdown

* test(channels): fence restart during service stop

* test(channels): exercise restart after shutdown channel snapshot

* docs(channels): document service shutdown publication fence

* fix(channels): keep agent guidance within CI hard budget

* fix(channels): fence publication after shutdown completes

* test(channels): cover resume after completed service shutdown

* test(channels): mark direct-start fixtures as active services

* test(channels): mark direct-start fixtures as active services

* style(channels): format shutdown regression test

* fix(channels): drain channel startup completing after service stop

* test(channels): ensure late channel startup is drained after stop

* style(tests): format channel shutdown regression

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-08 22:29:49 +08:00
NanPan
1a88f29bf7
fix(channels): snapshot outbound listeners per message (#6502)
* fix(channels): snapshot outbound listeners per message

* test(channels): cover listener registration during dispatch

* docs(channels): document outbound listener admission snapshot

* docs(channels): keep guidance under size limit

* docs(channels): clarify outbound snapshot unsubscribe semantics

* test(channels): pin outbound unsubscribe snapshot semantics

* docs(channels): keep outbound snapshot guidance within size limit

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-08 20:22:28 +08:00
Totoro
35cdcab1c5
feat(scheduled-tasks): signals, honest notices and fair queueing for server-started runs (RFC #6340, 2/2) (#6393)
* feat(scheduler): lifecycle rules, goal/limit parity and coded errors for scheduled tasks

- Migration 0031: nullable scheduled_tasks.unmet_streak_after_seq (internal
  streak boundary) and stop_condition (the user's stop rule, own column).
- Stop rule is normalized (stop_rule.py) and appended only at launch; the
  stored prompt never contains it. Wording adapts when scheduler.tool_enabled
  is off.
- Willem 1: page-created tasks get goal evaluation, <standing_notes>, the
  previous-run reference and own-schedule stop; request_stop and the
  scheduled capability no longer require an origin conversation.
- Repository: single reactivation choke point raising
  ScheduledTaskLimitsExhausted; streak boundary moves on goal/prompt/stop
  condition/note changes (not on resume); goal-check failures neither count
  nor reset the streak; pause of a finished task returns "finished"; trials
  on a paused task keep the host pause marker.
- New repository reads: automatic_runs_used_for, active_run_status_for,
  list_manageable_from_thread, thread_relation/thread_run on thread tasks,
  task_ids_for_thread, run_number, and run_number/total_tokens/summary on
  run history rows.
- REST: goal/max_runs/end_at/stop_condition on create and PATCH (null
  clears, naive end_at = task timezone), resume recomputes from now with an
  optional renewal body, once_time_passed, task_finished, limits_exhausted,
  scheduler_not_running on create, trigger returns outcome/existing/thread_id,
  task responses add automatic_runs_used and active_run_status.
- Coded errors {code, message, params} for routes, shared validation and the
  chat capability (scheduled_task_errors.py), pinned by
  contracts/scheduled_task_errors_contract.json; goal notes contract v2 with
  check_failure_codes, scheduled_origin_key and host_run_errors (host_notes.py).
- /api/features adds scheduled_tasks {available, running, tool_enabled,
  min_interval_seconds}; ScheduledTaskService.is_running.
- Docs: six README scheduler sections, API.md scheduled tasks section
  (breaking error shape), CONFIGURATION.md, AGENTS notes, CHANGELOGs.
- Tests: migration 0031, stop rule, error contract, run listing, lifecycle
  over HTTP, repository, service and capability cases; updated tests for the
  new error shape, finished-task pause and head revision.

* feat(scheduler): conversation update/resume, run-conversation authority and readable launches

- schedule_task: update and resume actions, stop_condition and clear_fields
  parameters, plain parameter types (value rules come back as coded results),
  rewritten docstrings for schedule_task and stop_scheduled_task.
- Capability: update/resume share the REST validation and error codes;
  interactive turns in a task's run conversation may manage that task;
  scheduled runs keep only own-stop; grants require a running scheduler.
- Results: compact JSON-safe task views with local times, display card/text,
  last run in list, timezone_source/browser_timezone, now_local; no ISO, user,
  lease or origin fields; stop_instructions removed.
- Timezone: explicit > browser (context.client_timezone, validated in
  start_run and never forwarded to the run config) > not needed for intervals
  and offset run_at > timezone_required.
- Trial gate accepts natural direct requests after acknowledgements and
  particles; trial results report thread_id and existing.
- Scheduled launches: stable message id scheduled-<task_run_id>, server-owned
  deerflow_scheduled_origin metadata with the user-language parts, run thread
  title for fresh-thread tasks.
- Worker records goal continuations in the verdict; lead prompt language rule
  covers stored task text and scheduled runs.
- Docs: six READMEs, CONFIGURATION, API, runtime/tools AGENTS, CHANGELOGs.

* feat(frontend): scheduled-task core types, formatting, errors and glossary

- throwGatewayApiError now throws GatewayApiError (status, code, params,
  rawMessage); message stays the string detail or detail.message
- scheduled-task types gain stop_condition, automatic_runs_used,
  active_run_status, origin/notes, run_number/total_tokens/summary, thread
  relation, trigger result, renewal and scheduled origin; isTaskBusy and
  hasActiveRun helpers
- api: fetchScheduledTask, resume with optional renewal body, typed trigger
  result and update payload with null clears
- hooks: polling at 3 s while a run is active (list, thread list, run
  history), useScheduledTask with live gating and final 404, useInView,
  localized error toasts; run history refreshes tasks when a run finishes
- new pure modules: errors.ts (contract code to localized copy), format.ts
  (relative task times, viewer time, display zone), status.ts, actions.ts,
  run-error.ts; describeTaskSchedule in cron.ts; goal-outcome adds unchecked
  goal checks, describeTaskOutcome and parseStopRunId
- features: scheduled_tasks block with defaults when missing
- buildRunContext sends client_timezone
- titleOfThread takes a localized untitled label; breadcrumb names scheduled
  tasks and agents; reference chips use the localized untitled label
- schedule input: shared browserTimeZone, labelled selects, aria-pressed
  type toggles, interval minimum from the hint template
- i18n: all PR1 scheduled-task keys in en-US and zh-CN, glossary fixes,
  recipe instructions moved into i18n
- e2e mocks follow the new task, trigger, resume, thread-task and features
  shapes; expectNoRawIdentifiers helpers for e2e and DOM tests
- tests for errors, format, cron, status, actions, goal outcome, run errors,
  hooks, run context, thread titles and features; scheduled e2e copy updated

* feat(frontend): list-first scheduled tasks page with outcomes and editable goals

- page.tsx is now a thin composition of components/workspace/scheduled-tasks:
  status tabs (All/Active/Paused/Finished) and search over the task list,
  ?task_id= deep link with router.replace on selection, ?thread_id= chip
  with Show all, empty state, scheduler-off notice and unavailable state
- task list rows show the schedule in words and one status line (next run,
  running now, paused by agent, auto-paused, finished by its cap)
- task detail: status badge, actions from availableActions with reasons,
  More actions menu (Duplicate, Copy task ID, Delete), outcome notice,
  Runs / Stops when / Each run's goal / Does / Notes from chat / History,
  origin chat link; success toasts for every action
- run rows: run number or Trial run, summary, goal and assumption badges,
  continuations, tokens, Open chat link; raw errors and the evaluator
  reason only behind Details
- create/edit/duplicate dialog with goal, stop condition, safety caps and
  advanced agent/context options; PATCH sends only changed fields, null
  clears a cap
- renew dialog raises or removes caps and resumes in one request; a
  limits_exhausted Resume opens it
- removed i18n keys the old page used; added feedback.resumedNoTime
- recorded en-3 and zh-1 page fixtures; scheduled e2e specs rewritten for
  the new page; DOM tests for the detail and the form dialog
- CHANGELOG entries and the frontend AGENTS.md scheduled-task note

* feat(frontend): live schedule card, run conversation provenance and header entry

- schedule_task results (create, update, pause, resume, trial, delete) become
  an assistant:scheduled-task message group rendered as a live card: schedule,
  next run, stop condition with the safety cap, goal, where results go, Run
  once now, Pause/Resume and Open task; the card polls the task only while it
  is on screen and shows deleted, scheduler-off and limits-exhausted states
- one card per task per assistant turn; the card group is not a turn boundary
  for usage, branching, editing, outline or skill attribution
- a human message with deerflow_scheduled_origin stays a human group with
  scheduledOrigin and renders as a "Scheduled run / Trial run" block with the
  task instructions, stop condition and notes collapsed; the launched text is
  never shown and the message is not editable
- readable step labels for schedule_task actions and stop_scheduled_task
- ThreadScheduledTasksButton replaces the header link: hidden without tasks,
  shows the count, links to the task (or the chat filter), and a run's chat
  links to its task
- recorded en-3 / zh-1 chat and run-thread fixtures; chat-card and run-thread
  e2e specs; unit and DOM tests for parsing, grouping, turn boundaries, card,
  run block, header button, step labels and reconnect hydration
- i18n: header.countLabelOne added, unused card.next removed; CHANGELOG and
  frontend AGENTS.md notes

* fix(scheduler): address verification findings

Move the scheduled-task lifecycle guidance from backend/AGENTS.md into the persistence guide and leave a one-line pointer, so the effective AGENTS.md chains for agents/middlewares, sandbox and subagents stay under the 98304-byte hard limit enforced by scripts/check_agent_guidance.py in lint-check.

* fix(scheduler): address review findings

* fix(gateway): no-transform on SSE responses

All four text/event-stream responses (thread run create stream, join,
existing-run GET/POST stream, stateless create stream) now take their
headers from app/gateway/sse_headers.py and send
Cache-Control: no-cache, no-transform, so compressing proxies such as
Next's rewrite proxy stop buffering streams. Content-Location and
X-Accel-Buffering are unchanged; scheduled and MCP-notification runs keep
stream_mode=None.

Tests pin the headers on every SSE route and fail on a literal no-cache
header left in routers. Docs: API.md SSE headers note, CHANGELOGs.

* feat(scheduler): per-owner run cap and fair queue selection

New scheduler.max_concurrent_runs_per_user (default 2, 0 = off, effective
min(cap, max_concurrent_runs)) caps launching/running scheduled runs per task
owner. claim_queued_run checks it right after the global count under the same
budget lock, so it is atomic across workers and Pods. list_queued_runs ranks
same-thread FIFO heads per owner with ROW_NUMBER() OVER (PARTITION BY owner)
and orders the batch by that rank, so the drain is owner-fair even with the
cap off; owners at their cap drop out before the batch limit. Orphan rows
(task deleted while queued) are never held back by the cap.

Gateway lifespan passes the startup value; the service constructor default
stays 0. Docs: config.example.yaml (config_version 54), CONFIGURATION.md,
the six READMEs, backend AGENTS.md, CHANGELOGs (behavior change flagged).
The live-task quota is untouched.

* feat(scheduler): durable lifecycle events for the originating chat

When a chat-created schedule is paused by the agent, auto-paused or finishes,
finalization now reports task_stopped / task_paused / task_finished, only on a
live -> paused/finished transition. The new finish_task_at_end_condition
replaces the three inline "mark completed" blocks (admission rejected as ended,
claim-time skip, complete_if_ended), so every site emits exactly once.

The scheduler's finalization observer is always installed. It writes
scheduled_task_events rows for the originating chat in the same transaction as
the state change, deduplicated on (task_id, anchor, event), with a title
snapshot, stop condition, run number, last run outcome and the chat's newest
run as placement anchor. Lookups fall back on bad stored data; only database
errors propagate. Manual trials drop only run_* events. The IM step keeps
PR1's selection until B4.

New GET /api/threads/{thread_id}/scheduled-task-events (owner check, caller's
rows, oldest first); rows survive task deletion and go with the chat.
Migration 0032_activity_and_task_events adds the events table plus the
run-origin column/index and read-state tables used later, each object guarded
on its own. Contract scheduled_goal_notes v3 pins the lifecycle vocabulary.
Docs: API.md, migrations chain, backend AGENTS.md, six READMEs, CHANGELOGs.

* feat(scheduler): honest, localized and self-contained IM notices

Every scheduled-task IM notice is now enqueued by the finalization observer
in the transaction that records the outcome, one message per occurrence
(task_stopped > task_paused > task_finished > run_failed > run_unmet >
run_completed; a once task sends only its run event). The merged auto-pause
replaces the separate unmet + pause messages, agent stops and finishes now
notify, and recovery-finalized runs notify exactly once. Idle finishes use a
deterministic idle: key derived from the lifecycle anchor. The old
non-transactional enqueue in handle_run_completion is removed.

New app/channels/capabilities.py holds CHANNEL_CAPABILITIES with a
proactive_notifications flag (adds the missing qq entry); only push-capable
providers (WeCom) get rows, /api/channels/providers returns the flag, and the
worker ends legacy rows for other providers as terminal failed.

New app/scheduler/notification_text.py renders en-US/zh-CN text: title, what
happened (plus the last run's outcome), the tasks page's one-line run summary
(redacted) when the agent replied, and a closing line. No IDs, no links, no
public_base_url. Locale is the owner's new `locale` preference, else the new
channel_connections.notification_locale (default en-US).

Docs: config.example.yaml (config_version 55), CONFIGURATION.md, API.md,
channels AGENTS.md, six READMEs, CHANGELOGs (behavior change flagged).

* feat(gateway): thread origin, activity feed and read markers for server-created threads

Runs started by the server for a user (scheduler, IM channels, GitHub
agents, extension handles, MCP notifications) now carry a server-owned
metadata.deerflow_origin {kind, provider?, namespace?} (new harness module
deerflow.runtime.run_origin, pinned by contracts/thread_origin_contract.json).
It is stamped only from request.state.run_origin or by the internal channel
caller; run admission drops client copies from both metadata and
config.metadata, and thread create/patch strip it. RunRepository derives
runs.origin_kind on put and on create_thread_operation_atomic (agent runs
only), and gains latest_change().

New ThreadReadRepository keeps a per-user, never-decreasing read position
(thread_read_markers) and a per-user read clock (thread_read_versions).
POST /api/threads/{id}/read marks a thread read, thread search items carry
unread, and thread delete removes the markers. GET /api/thread-activity pages
the caller's run changes over the run-change clock, returns only threads
changed by server-originated runs plus read_version, and 503s without SQL;
/api/features reports thread_activity.available.

Docs: API.md, gateway AGENTS.md (condensed to stay under its budget),
CHANGELOGs.

* fix(scheduler): adapt PR2 to PR1 follow-ups

PR1 moved the scheduled-task lifecycle rules out of backend/AGENTS.md into
the persistence guide and grew the sandbox AGENTS.md chain to within 267
bytes of its hard limit. The per-owner queue-fairness detail now lives in
the persistence guide next to the lifecycle rules (backend/AGENTS.md keeps
the cap and a link), so check_agent_guidance passes again.

PR1's goal-outcome unit test pinned scheduled_goal_notes_contract version 2;
PR2 bumps it to 3 (lifecycle vocabulary only, the v2 keys are unchanged).

* fix(scheduled-tasks): polish from live acceptance

- zh schedule wording: an interval of one unit reads 每分钟 / 每小时 / 每秒,
  and a saved interval of whole days reads 每天 / 每 2 天 (Every day / Every 2
  days) instead of 每 1 分钟 or 每 24 小时; several units keep the number
- zh spacing: 已于{time} 满足 and 在{time} 的运行中 now leave a space between
  the interpolated time and the Chinese after it; a copy test guards every
  scheduled-task zh string against a placeholder glued to Chinese
- run summary: when the first line of the final reply only leads in to a list
  (ends with ":" or ":"), the list items follow it as plain text (bullets,
  numbering, checkboxes, bold, code and links stripped), joined with ";" for
  CJK text and "; " otherwise, within the 160-character cap
- a schedule_task result (create, update, pause, resume, trial, delete) that
  arrives in a chat invalidates that chat's task list, the tasks list and the
  named task, so the header button and cards update without waiting for the
  idle poll; results already in the loaded history do not refetch
- e2e and unit fixtures are now the live acceptance recordings (DeepSeek
  Flash, demo data, fictional names): live-weekday.json (create, trial, edit,
  pause, resume in one zh chat) and live-minute.json (per-minute chat, run 1
  and run 2, paused by the agent), fetched with GET from the demo Gateway and
  trimmed; the hand-shaped en-3 / zh-1 fixtures are removed and the chat-card,
  run-thread and tasks-page specs run in both locales over the recorded data

* docs(changelog): reference #6378 for the scheduled-tasks entries

* docs(agents): shorten the scheduled-task pointer to stay under the guidance limit

* test(scheduled-tasks): pin the live takes to their recorded time zone

The live fixtures were recorded in Asia/Shanghai, and an interval task
reads in the viewer's zone, so the 16:48 assertions failed on UTC CI
runners. Pin the browser zone in the two live-take e2e specs and the
zh live-take unit test.

* feat(frontend): thread origin, activity polling, read markers and task events core

Frontend core for server-created threads and schedule lifecycle events
(PR2 F1); the sidebar markers (F2) and the chat event line (F3) build on it.

- core/threads/origin.ts: threadOriginOf reads metadata.deerflow_origin,
  then channel_source (github -> GitHub origin), then a legacy
  scheduled_task_id; key and kinds pinned to
  contracts/thread_origin_contract.json. labelOfThreadOrigin and the now
  exported labelOfChannelProvider(provider, t) use the new localized
  threads.origin.providers names (飞书, 企业微信, ...), with the English map
  (gaining GitHub) only as the fallback.
- core/threads/activity.ts: useThreadActivity polls /api/thread-activity
  every 15 s (focus refetch, never in the background, off unless
  features.thread_activity.available). The first response only seeds the
  cursor; server-origin threads, a truncated page or a higher read_version
  invalidate the thread lists, a scheduled thread also ["scheduled-tasks"];
  an invalid cursor re-seeds. useMarkThreadRead debounces POST /read by 1 s,
  skips a thread whose cached unread is false, patches the search and
  infinite caches optimistically and stores the returned read_version so a
  tab's own read does not refetch its lists. AgentThread gains unread.
- core/scheduled-tasks/events.ts: ScheduledTaskEvent, the events query
  (["scheduled-tasks","thread-events",id], one fetch per saved chat, not
  gated on the task list so a deleted task's lines stay, refetched when a
  task of the chat changes status or finishes a run), describeTaskEvent
  (en/zh copy, title and condition as segments, last-run suffix, See that
  run / Open task) and placeTaskEvents (end of the after_run_id turn, else
  the tail, ordered by created_at); lifecycle names pinned to contract v3.
- features: thread_activity (default unavailable); channels:
  proactive_notifications on providers.
- Locale sync: the I18nProvider reports the UI language and a signed-in
  preference session saves it as the new `locale` preference when the
  account's differs (and on every switch), so IM notices follow the UI
  language; nothing is written with auth disabled.
- i18n: threads.* (unread, origin labels, provider names),
  scheduledTasks.events.*, history.waitingForSlot (now shown for queued run
  rows), channels.scheduledUpdates.*.
- e2e mocks: thread activity feed and controller, POST /read, the events
  route, providers with proactive_notifications, search unread, features
  thread_activity (off by default). Preference e2e mocks return the UI
  locale so they keep asserting only their own edits.
- Docs: frontend AGENTS.md, CHANGELOGs.

* fix(scheduled-tasks): keep untouched end times and drop a chat filter the new task is not in

- The edit, duplicate and renew dialogs keep a stored end time's instant
  while its field still shows it. A minute-precision wall value cannot
  keep seconds or name the later 01:30 of a DST fall-back, so a
  title-only edit moved the cap an hour earlier, and raising the run
  limit re-sent the end time without its seconds.
- Creating or duplicating a task from a chat's task view drops the chat
  filter when the new task does not belong to that chat, instead of
  showing "This task isn't one of this chat's tasks".

* feat(frontend): origin markers and unread state in the thread lists

Server-created threads (PR2 F2) now say who created them and whether they
are new, in the sidebar and on the Chats page, and reach an open sidebar
without a reload.

- ThreadOriginIcon (thread-channel-source.tsx): clock for a scheduled run,
  the provider mark for IM threads, the GitHub mark (new in
  ChannelProviderIcon) for GitHub threads, a puzzle piece for extensions;
  the localized "Scheduled run" / "From Feishu" / "From GitHub" / "From an
  extension" label is its accessible name and tooltip. ThreadChannelIcon
  stays exported as a wrapper, and the provider chip tooltip is localized
  (no more "Feishu channel").
- Unread dot after the title (aria-hidden dot plus sr-only "Unread"); the
  row aria-label becomes "{title}, unread" / "{title},未读". Never shown for
  the open thread.
- WorkspaceSidebar mounts useThreadActivity once.
- useMarkOpenThreadRead (components/workspace/chats): the default and
  custom-agent chat pages mark the thread read once its metadata has
  loaded, when its cached unread flips to true while open, when the tab
  becomes visible, and from onFinish (a send or a joined run ended). Each
  trigger goes through useMarkThreadRead, so it is debounced and a no-op
  while the cached unread is false.
- Tests: thread-origin-icon and recent-chat-list DOM tests (en/zh, active
  row, no raw identifiers), use-mark-open-thread-read DOM tests,
  e2e thread-activity.spec.ts (new scheduled run appears unread within one
  fake-clock poll without a reload, opening it posts /read and clears the
  dot, a read on another device clears it, IM and GitHub markers, zh
  labels, the Chats page, no polling when unavailable);
  thread-history.spec.ts now finds the localized origin icon.
- Docs: frontend AGENTS.md, CHANGELOGs.

* feat(frontend): schedule event line in the originating chat and channel notice support

The chat that created a schedule now says when the schedule paused or
ended, and the channel cards say which apps receive scheduled task updates
(PR2 F3).

- ScheduledTaskEventLine (mockup m2, marker 4): a dashed, muted row with
  the describeTaskEvent text (title in <strong>, stop condition clamped to
  one line with the full text in its tooltip, last-run suffix), the time
  via formatTaskTime and a right-aligned "See that run" (run chat) or
  "Open task" link. PauseCircle for paused, CircleCheck for finished and
  once-done, CircleX for once-failed. role="note", aria-label "Scheduled
  task update" / "定时任务通知", data-task-id and data-event-id only.
- MessageList takes scheduledTaskEvents and renders placeTaskEvents output
  through a new VirtualMessageList.renderAfterGroup, so each line sits at
  the end of its after_run_id turn inside the group's measured row (also
  when the list virtualizes); lines with nowhere to go render at the tail.
- Both chat pages load useThreadScheduledTaskEvents (not gated on the task
  list, so the lines stay after a task is deleted). The custom-agent page
  passes its agent so "See that run" opens the run on that agent's route.
- ChannelScheduledUpdates: "Scheduled task updates: sent here" / "... not
  available for this app yet" (定时任务通知:会发送到这里 / 此应用暂不支持)
  on the Settings provider cards and under each sidebar channel row; nothing
  when an older backend does not report proactive_notifications.
- e2e mock controller: setScheduledTasks.
- Tests: scheduled-task-event-line and channel-scheduled-updates DOM tests
  (every kind in en and zh, hrefs, clamp, no raw identifiers, Chinese
  title), MessageList placement DOM tests; e2e
  scheduled-task-chat-events.spec.ts (the line appears with the card's
  "Paused by agent" without a reload, See that run opens the run chat,
  anchored vs tail placement, two events in time order, the line survives
  task deletion and Open task shows the task is gone; en-3 and zh-1) and
  channels.spec.ts (WeCom sent here, Feishu not yet; en and zh).
- Docs: frontend AGENTS.md, CHANGELOGs.

* fix(scheduled-tasks): tie pause notices to the pause they describe

- Paused by agent links and times only the run named in last_error. When
  that run is not loaded, the notice shows no link and the task's own time
  instead of an earlier run that also asked to stop.
- The outcome notice reads the latest history page whichever page is
  shown, so browsing older runs no longer swaps the auto-pause reason and
  "Open latest run" to an earlier miss. It shares that page's request.

* fix(scheduler): address PR2 review findings

- Event lines: hold an event whose anchor run is on an unloaded history
  page instead of placing it at the bottom (hasMoreHistory); tail fallback
  only for fully loaded (branched/pruned) history or no anchor.
- Idle-finish anchor follows its reason: end:<iso> only for an end_at
  finish, seq:<n> otherwise, so a later end-time finish is not deduplicated
  away by an earlier max_runs finish.
- Observer takes FOR SHARE on the originating threads_meta row, so a racing
  thread delete cannot leave an orphan event line.
- One taskPagePath (core), re-exported by the card.
- Notification locales derived from NotificationLocale; /preferences uses it.
- IM notices drop generic reasons (goal_not_met_yet, unknown) instead of
  repeating the sentence.
- Localized provider names on Settings cards, sidebar rows and setup dialog.
- zh event lines: no space after a Chinese title (Latin-aware gap).
- Unread row label keeps the origin marker; chats page keeps the time as a
  description.
- Sidebar shows the scheduled-updates line only for connected apps;
  Settings says "available after you connect" for unconnected push apps.
- Event line keeps a real space before the time.

* fix(scheduled-tasks): three lifecycle gaps found while re-checking review classes

- The edit form compares a cron in the form its schedule input emits, so an
  untouched "0 09 * * 0,6" is not re-sent as "0 9 * * 6,0" and a title-only
  edit no longer re-arms or refuses a finished task.
- Chat resume asks for a timezone, as update does, when a naive end_at would
  land in the UTC placeholder of a zone-free task.
- Re-arming a task clears a host pause marker, as resume does, so a later
  manual pause no longer reads as "Paused by agent".

* test(scheduler): consolidate PR2 backend test setup

- Fold the repeated task, origin-chat and observer setup of the
  lifecycle-event repository tests into one lifecycle_task builder.
- Build the per-owner-cap services through one _capped_service builder
  that keeps every cap explicit at the call site.
- Table the five SSE route header checks into one parametrized test with
  the same routes, bodies and Content-Location expectations.
- Reuse bind_wecom and hoist repeated local imports in the event tests.

Every case and assertion is kept; collected cases are unchanged.

* test(frontend): consolidate PR2 thread activity and origin test setup

Extract shared builders and helpers in the frontend tests PR2 adds, with
every case, input and assertion kept:

- activity.test.ts: seededClient/apply helpers and one LIST_KEYS constant
  replace the per-test seed, spy and apply boilerplate.
- mark-read.dom.test.tsx: newClient() and advance() replace the repeated
  QueryClient wrapper and fake-timer act blocks.
- origin.test.ts: the English and Chinese label checks become tables; the
  no-English-name check now runs on every Chinese row.
- thread-activity.spec.ts: a scheduledRun() builder replaces four inline
  copies of the scheduled run thread.
- channels.spec.ts: the PR2 block uses setLocaleCookie and one card helper.

Collected cases are unchanged (unit 211, e2e 60); no test was removed.

* fix(scheduled-tasks): keep the notice's latest-runs query from refreshing older pages

useLatestScheduledTaskRuns kept React Query's default refetch on mount,
focus and reconnect, so going offline and back while an older history page
was shown fetched the latest page again. Refreshing stays with the history,
which refreshes the latest page only while it is shown.

* fix(scheduler): two gaps found in live acceptance

- A fresh scheduled run thread is named when it launches. The worker
  copied the title to the thread list only when the run ended, so the
  sidebar, which now shows the new run thread at once, listed it as
  untitled while it ran.
- A task capped at one run finishes with "its one run is done" /
  "唯一一次运行已完成" in the chat event line and the IM notice, instead
  of "all 1 runs are done".

* test(scheduled-tasks): let the viewer's time carry its own day label

Between 16:00 and 24:00 UTC, UTC and Asia/Shanghai are on different days,
so the run prompt shows "Tomorrow 01:00 · Today 09:00 your time" and the
fixed "01:00 · 09:00 your time" assertion failed depending on the hour.

* test(scheduler): expect the one-run finish copy in service notices

* fix(gateway): create a scheduled run thread with its title

Pre-setting the name after start_run raced the thread row, which the run
worker creates asynchronously: live, one of two run threads still reached
the sidebar untitled. Run admission now creates a new thread with the title
its run input carries, which the worker would copy only at run end.

* docs(changelog): reference #6393 for the scheduled-task signal entries

* fix(scheduled-tasks): route event-line run links by the agent that ran them

The originating chat's agent was used for "See that run", but a task's
agent can change after the chat created it, so the link could open the run
chat on another agent's route. Lifecycle events now record run_agent_name,
the task's agent when the run finished (edits are refused while a run is
active), and the line routes by it.

* fix(scheduled-tasks): load up to the route's maximum of chat event lines

The chat loads its lifecycle events once, and the route's default of 50
would silently drop the oldest lines of a chat with many schedules. Ask
for the maximum, 200.

* fix(threads): keep activity reads and the first cursor in step with the lists

- Accepting the first activity cursor refreshes the thread lists once. The
  lists load on their own, possibly before a run the seed already counts,
  and later polls start after it, so such a thread never appeared.
- A local read whose returned read clock jumps past that one read also
  covers reads on other devices; the lists are refreshed instead of the
  clock silently skipping those threads' stale unread dots.
- An invalid cursor re-seeds through the same path, so it refreshes once.

* docs(agents): bring the middlewares guidance chain back under the hard limit

#6378 and #6363 each fit the budget, but together the middlewares AGENTS.md
chain on main is 98,425 bytes, 121 over the 98,304 hard limit, so Lint
Check fails on main. Move the scheduled-task recovery, launch-update and
queue-timeout rules from backend/AGENTS.md into the persistence guide's
lifecycle section, which backend/AGENTS.md already points to. The chain is
now 98,067 bytes.

* fix(helm): bump the chart's config_version to 55

config.example.yaml moved to 55 in this PR, so the Helm chart's
validate-chart drift check failed. The chart's embedded config needs no
field changes: the new scheduler and channel keys have defaults.

---------

Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-07 10:22:15 +08:00
NanPan
8598687d3d
fix(channels): keep config reload publication under lifecycle lock (#6390)
* fix(channels): keep config reload publication under lifecycle lock

* test(channels): prevent stale config publish after cancellation

* test(channels): exercise real stale reload write

* test(channels): harden cancelled config reload regression

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-07 08:26:53 +08:00
NanPan
8c0cc39ad1
fix(wechat): drain poll-state persistence across cancellation (#6389)
* fix(wechat): retain cursor persistence across cancellation

* fix(wechat): drain expired auth-state persistence

* fix(wechat): drain the complete token expiry transition

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-07 08:18:55 +08:00
Shxiao
a97772d016
fix(wechat): deny all users when allowed_users has no valid ID (#6376)
* fix(wechat): deny all users when allowed_users has no valid ID

A single iLink user ID was split into characters, and a list with no
usable ID collapsed to an empty set that the inbound gate read as
allow-all.

* fix(wechat): fail closed on numeric and mapping allowed_users

Integer-valued floats now match the sender's integer text. Fractions,
non-finite numbers, and mappings no longer admit anyone, and a denied
sender is dropped before inbound media is downloaded.

* docs(channels): keep WeChat guidance within size budget

* fix(wechat): warn on scalar allowlist separators

* refactor(channels): share fail-closed allowlist parsing

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-06 21:17:11 +08:00
haochen
8d5654363e
fix(wechat): enforce outbound limits while reading attachments (#6309)
* fix(wechat): enforce outbound limits while reading attachments

* fix(wechat): centralize outbound read overflow rejection

---------

Co-authored-by: Zhangchenhao-111 <285911435+Zhangchenhao-111@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-06 07:45:18 +08:00
NanPan
9c5e5253ea
fix(telegram): retain polling worker on stop timeout (#6333)
* fix(telegram): retain polling ownership on stop timeout

* test(telegram): cover stop timeout ownership retry

* docs(channels): pin Telegram stop ownership
2026-10-06 07:00:09 +08:00
quifox
cc2ccaa523
fix(channels): handle streamed run errors before dedupe cleanup (#6272)
* fix(channels): handle streamed run error frames

* fix(channels): log streamed run error details
2026-10-05 06:54:32 +08:00
Hyeonsang Cho
12fb53e5de
fix(channels): follow Buzz threads mapped in the connection repository (#6232)
* fix(channels): follow Buzz threads mapped in the connection repository

With channel_connections enabled, ChannelManager stores a bound author's
thread mapping only in the connection repository, but Buzz's thread-follow
gate read only the JSON ChannelStore. engaged_thread was therefore always
False for authors bound with /connect, and every unmentioned reply in a
thread the bot was already answering was silently dropped.

Buzz now resolves the connection identity before the mention gate and looks
the thread up through lookup_thread_id, a helper extracted from
ChannelManager._lookup_thread_id that both sides now share, so the gate and
the manager can no longer disagree about which store owns a mapping.

* fix(channels): drop the manager's own JSON-store read in the skill whitelist

_resolve_available_skill_names fell back to reading the JSON ChannelStore
when the caller's lookup_thread_id found nothing. For a bound message that
lookup is repo-only, so a legacy unbound mapping for the same chat decided
which custom agent's skill whitelist applied. The parameter is now required
and the caller passes "" when nothing is mapped.

Also pin the Buzz thread-follow fix against the real SQL connection
repository (bound through a real /connect) instead of a fake.

* docs(changelog): reference #6232 for the Buzz thread-follow fix

* fix(channels): log Buzz drops of unmentioned replies with no engaged thread

The thread-follow gate was the one drop in _handle_chat_event without a
log line. Log it at DEBUG with the thread root and whether the author was
bound, matching the allowlist drop, so a report that the bot stopped
following replies can be told apart from a lost bind or missing mapping.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-04 20:38:43 +08:00
NanPan
b768527187
fix(channels): retain live SDK workers after stop timeout (#6215)
* rebase(channels): replay retained worker fix

* rebase(channels): replay retained worker fix

* rebase(channels): replay retained worker fix

* rebase(channels): replay retained worker fix

* rebase(channels): replay stop-timeout classification

* rebase(channels): replay retained timeout handling

* rebase(channels): replay retained worker guidance

* docs(channels): keep lifecycle guidance within CI size limit

* docs(channels): fit lifecycle guidance within size budget

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-03 21:55:00 +08:00
Hyeonsang Cho
9bcf93ec40
fix(telegram): deny all users when allowed_users has no valid ID (#6230)
* fix(telegram): deny all users when allowed_users has no valid ID

TelegramChannel parsed channels.telegram.allowed_users with int() and
dropped every entry that failed without logging, and _check_user treated
an empty result as "no allowlist". A list of @usernames therefore opened
the bot to everyone. A single string ID was iterated character by
character ("123456" allowed users 1-6 and blocked 123456), which is what
an allowed_users: $ENV reference resolves to, and null or a bare integer
crashed the channel at startup.

The allowlist is now parsed into frozenset[int] | None: None (unset,
null, empty list, blank string) still allows everyone, a single ID is a
one-entry list, entries that are not positive numeric IDs (@usernames,
floats, booleans, comma-joined strings) are dropped with a warning, and
a configured list with no valid ID denies every user and logs an error.

* docs(changelog): reference #6230 for the telegram allowed_users fix

* fix(telegram): say "positive" in the allowed_users drop warning

0 and negative integers are numeric but still dropped, so "expected one
numeric Telegram user ID" misdescribed them. The warning now asks for a
positive numeric ID, matching _parse_telegram_user_id, and the drop test
covers 0 and -5 alongside @alice.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-03 21:37:39 +08:00
Hyeonsang Cho
aeeee2994f
fix(channels): run Discord connection-repository work on the Gateway loop (#6214)
* fix(channels): run Discord connection-repository work on the Gateway loop

discord.py delivers messages on a private loop in the client thread, and
the Discord adapter awaited the channel-connection repository there even
though its SQLAlchemy engine and pool belong to the Gateway loop. On
PostgreSQL the first Discord message after the Gateway used the pool
failed with "got Future attached to a different loop" and was dropped; on
SQLite a pool-exhausting burst failed with "Queue is bound to a different
event loop" and left the pool's wait queue bound to the Discord loop, so
the Gateway's own queries then failed the same way.

Resolve the identity together with the intake commit, and run /connect
binding, on the Gateway loop through _submit_threadsafe_coroutine, as
Telegram, Feishu and DingTalk already do. Bind replies return through
_run_on_discord_loop, and start()/stop() now open and drain the
cross-thread submissions.

* docs(changelog): reference #6214 for the Discord connection-loop fix

* fix(channels): correct the unscheduled Discord bind comment and pin it

When _submit_threadsafe_coroutine refuses the bind, base.py closes the
coroutine, so consume_oauth_state never runs and the one-time code stays
valid; only the Discord message is swallowed. Say that instead of
claiming the code is consumed, and pin both halves with a test: the
attempt is handled (never a chat turn) and the code still binds later.

* fix(channels): undo Discord typing when the Gateway hand-off fails

Since identity resolution moved to the Gateway loop, both inbound paths
started typing and the ack reaction as soon as the hand-off was
scheduled. A lookup that then raised (database outage, pool-checkout
timeout) dropped the message but left an endless typing loop and a
success reaction behind.

The hand-off now returns its completion future (new
Channel._submit_threadsafe_coroutine_future, which the bool helper wraps)
and the Discord side waits on it through asyncio.shield. Typing still
registers before the hand-off settles, so a fast reply's _stop_typing
cannot run first; the ack reaction waits for the commit. On failure, or
when stop() drains the hand-off, the indicator this message started is
stopped (never one another in-flight message owns) and no ack is added.

* fix(channels): keep shared Discord typing alive for dependent messages

Two messages to the same target share one typing task: the first starts
it, a later one reuses it. If the creator's hand-off failed after a
follower had committed, or while the follower was still pending, the
creator stopped the indicator the follower relied on, and nothing
restored it while the follower's request ran.

Count the hand-offs that depend on each typing task. A pending or
committed hand-off holds the task (a commit keeps its hold until a reply
stops the indicator), a failed one releases its hold, and the indicator
is stopped only when the failed message started it and no hold remains.
The count is dropped when the task ends or the channel cancels typing.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-03 17:14:19 +08:00
Xuehao Xu
9b17bef630
feat(channel): add QQ WebSocket channel (#6081)
* feat: add QQ WebSocket channel

* fix: address QQ channel review feedback

* docs: trim inherited agent guidance below CI limit

* fix: align QQ gateway and passive reply limits with protocol

* docs: keep merged channel guidance within CI budget
2026-10-03 14:32:44 +08:00
NanPan
9c2dc6e392
fix(channels): keep runtime config commit owned across cancellation (#6198)
* fix(channels): own runtime config commit across cancellation

* test(channels): pin cancelled runtime config commit

* docs(channels): record runtime config commit ownership

* style(tests): fix channel connection router lint

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-03 08:43:10 +08:00