* 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>
* 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>
* fix(community): resolve async tool config off the event loop
Agent runs execute on the Gateway event loop, and the async community
tools resolved their tool config through get_app_config() inline. That
call stats and hashes config.yaml and extensions_config.json on every
call and reparses them after an edit, so each tool call held the loop
for it, up to three times per call: Tavily, SearXNG and Firecrawl read
the config again to build their client, InfoQuest read it three times,
and the browser tools read the launch config for every action.
Resolve it through asyncio.to_thread once per call, as image_search and
groundroute already do, and build the client from that same read. This
covers jina_ai, crawl4ai, browserless, tavily, searxng, firecrawl,
infoquest, ragflow, lightrag and browser_automation.
* docs(changelog): reference #7580 for the off-loop community tool config reads
* docs(changelog): use the existing community-tools label in the zh entry for #7580
* fix(community): resolve the jsonify dataset_rows config off the event loop
dataset_rows_tool is async and called _load_settings() inline, so every
call ran get_app_config()'s stat + sha256 revalidation on the loop.
Read it through asyncio.to_thread like the other async community tools,
and add the tool to the off-loop anchors.
* docs(changelog): include Jsonify in #7580 and drop trailing whitespace
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(auth): read the live config off the event loop on public auth routes
/register, /setup-status, /providers, /oauth/{provider} and
/callback/{provider} read the live auth config through get_app_config()
inline. That call stats and hashes config.yaml and extensions_config.json
on every request and reparses them after an edit, and these routes are
unauthenticated, so any client could keep that file IO on the event
loop. /login/local already reads its throttle policy in a worker.
Read the registration gate through asyncio.to_thread, and the SSO
routes' auth.oidc section through a shared _oidc_config() helper that
does the same.
* docs(changelog): reference #7576 for the off-loop auth route config reads
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(gateway): read the route authorization config off the event loop
AuthMiddleware resolves route permissions for every authenticated
request, and resolve_route_permissions read the hot-reloaded
authorization config inline. get_app_config() stats and hashes
config.yaml and extensions_config.json on every call and reparses them
after an edit, so that file IO ran on the event loop per request.
Read it through asyncio.to_thread, like the plugin authorization path
already does. The context copy keeps runtime config overrides visible.
* docs(changelog): reference #7557 for the off-loop route authorization config read
* docs(config): note the get_app_config read cost where it is documented
Move the off-loop note from gateway/AGENTS.md, which is 12 bytes under
its hard limit, to the Config Caching section that documents what each
get_app_config() read costs.
* fix(utils): construct one-shot models off the event loop
* docs(utils): document all one-shot callers and offload scope
Project-file summaries use the shared one-shot helper as well as input
polishing and follow-up suggestions. Include that third caller in the
README and utility guidance, and distinguish the separate skill scanner
and goal evaluator constructors.
Runtime and tests are unchanged. The existing exact-head strict and caller
regressions pass together: 80 passed on Windows with Python 3.12.14.
* fix(memory): flush pre-compaction memory off the event loop
The async compaction path fired the before-summarization hooks inline.
memory_flush_hook hands the summarized messages to add_nowait, which is
sync HTTP for mem0 and Honcho (inherited default -> add) and OpenViking
(its own override), so every automatic compaction with an HTTP memory
backend blocked the Gateway event loop for the whole request.
acompact_state now runs the hooks through asyncio.to_thread, as
MemoryMiddleware.aafter_agent already does for its writes. Ordering,
failure isolation, summary-first timing and context propagation are
unchanged; the sync compact_state path is untouched.
* docs(changelog): reference #7530 for the pre-compaction memory flush fix
* test(memory): anchor the OpenViking pre-compaction flush off the event loop
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(sandbox): derive the AIO sandbox ID off the event loop in async acquire
_acquire_internal_async offloads every acquire layer except the sandbox ID
derivation, which ran inline. _sandbox_id_for_thread checks the thread's
skills_view directory with exists(), and resolving it goes through
Paths.base_dir, so each async thread acquire made filesystem syscalls on the
event loop. The strict blocking-I/O gate raises on it.
Run the derivation through the same owned-worker helper as the reuse and
warm-pool layers around it, and anchor it in the blocking-I/O suite for both
the default and the policy-scoped ID.
* docs(changelog): reference #7507 for the AIO async acquire sandbox ID fix
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(projects): make read_file_chunks cancellation-safe for worker handoffs
Cancelling a project document read had three unsafe windows around the
dedicated file-I/O worker: (1) a cancellation landing between the open
worker completing and the result reaching the coroutine leaked the
handle (the cleanup path never saw it); (2) the cleanup close could
start while an admitted read was still running; (3) a repeated
cancellation let the caller escape before the close worker settled
(#7491).
Gate every admitted operation with the existing await_drained
primitive: cancellation is delivered only after the operation settles,
the open result is retained in a list cell the moment the worker
returns it (so the cleanup close always has the handle), and waiting
for the next chunk via the yield stays immediately cancellable.
Regression tests gate the real executor with asyncio events at the
worker boundary (real files, no mocks): all three windows are red on
unmodified main and green with the fix; the happy path is unchanged.
The dispatch-spy promotion tests that import app.gateway.routers need
the full backend dependency set and could not run in the minimal local
env; they fail identically on unmodified main (verified).
Fixes#7491
Co-authored-by: Jay Feng <jayfeng77@outlook.com>
* test: fix _gated_offload return annotation to the unpacked tuple
Review nit: the helper returns (calls, gated) and every call site
unpacks it as a pair, but the annotation claimed list[str]. Type it as
the tuple it actually returns.
Co-authored-by: Jay Feng <jayfeng77@outlook.com>
* test: pin missing-path FileNotFoundError in read_file_chunks
Convergence item from the duplicate-pair verdict (#7493): the
open-failure path must surface FileNotFoundError as-is — the cleanup
finally skips the close because retained is empty (no handle was ever
opened), rather than raising AttributeError from closing None.
Co-authored-by: Jay Feng <jayfeng77@outlook.com>
---------
Co-authored-by: Jay Feng <jayfeng77@outlook.com>
* feat(projects): search shelf documents by keyword
Add search_project_documents: two-tier name/summary then bounded content scan, case-insensitive substring matching with original-text offsets. Never converts on the fly; reports stop_reason, content_scanned, skipped reasons, and capped unscanned/truncated ids.
* fix(projects): derive unscanned count without paging after budget trip
Review feedback: once the content budget trips, stop paging list_active to enumerate unscanned ids — cap ids at the trip page and derive the honest total from one count_active query (tier 1 is exhaustive, so active - tier1 hits - processed = unscanned). Also add a tool-level isolation test pinning that search never crosses projects or users.
* fix(projects): scan the shelf over one stable snapshot, not offset pages
Review feedback: offset paging re-read the live shelf per page, so a concurrent upload/trash between pages could duplicate a match or silently skip one while still claiming complete coverage. Tier 1 now materializes the whole shelf in a single SELECT (one read snapshot) and tier 2 iterates those same rows; the budget-trip unscanned count is derived exactly from the snapshot with no extra query. Mid-scan upload/trash regression tests pin both failure modes.
Unsafe filenames and unnamed parts were dropped silently: the response kept
`success: true` with an empty `files` list, so the #7122 frontend gate never
blocked a chat send whose attachment had been rejected (#7160).
Both drop arms now record the rejected name in `skipped_files`, and `success`
follows `bool(uploaded_files)`: an all-rejected batch fails, a mixed batch
succeeds and lists what was dropped.
* fix(acp): fail fast when an ACP agent exits and drain its stderr
An ACP agent that exited before answering (missing credentials, a wrong
argument) left invoke_acp_agent waiting for the whole timeout_seconds
budget (30 minutes by default) and then suggested raising the timeout.
The pinned SDK never fails pending requests when the agent's stdout
reaches EOF, so nothing noticed the process was gone.
The SDK also opens the agent's stderr as a pipe and never reads it. An
agent that logged more than asyncio's pipe buffering absorbs blocked on
its next stderr write and stalled the invocation the same way.
Race the ACP exchange against the agent process: when it exits (or a
newer SDK reports the connection closed), allow a short grace for
responses still buffered in the pipe, then report the exit code. Drain
stderr continuously into a bounded tail that is logged on failure and
kept out of model-visible text.
* fix(acp): log the full stderr tail on timeout and pin tail retention
Log the timeout tail after the SDK context has terminated and reaped the
agent, so lines still in flight when it stalled are included. Make the
exit test write more stderr than the retained tail, so keeping the start
of the stream instead of its end fails the assertion on the fatal line.
* feat(community): migrate You.com tools to async HTTP
The You.com provider sent its search request through a sync httpx.Client. A
sync LangChain tool invoked through ainvoke runs on the default executor, so
the request held one of that pool's threads for the whole network round-trip,
and several providers queried in parallel queued behind each other.
Make the tool a coroutine over httpx.AsyncClient, resolve the API key and
the configured max_results in a single config read, and move both disk reads
off the event loop: the tool config, and the User-Agent, which reads the
installed package metadata the first time it is needed.
* refactor(community): drop the dead You.com API key wrapper
The async migration resolved the key and max_results through
_resolve_tool_settings, which left _get_api_key with no production
caller: only TestGetApiKey still exercised it. Keeping a second seam
around the same config read lets it drift from the real call path, so
drop the wrapper and point the tests at _resolve_tool_settings.
Also keep the hot path off the executor: _user_agent is cached, so only
the first call reads the installed package metadata. Later calls are
in-memory hits and now skip the to_thread round-trip.
Finally, record which httpx client factory the provider reaches for in
the runtime anchor. MockTransport runs its handler on the calling thread
without real blocking I/O, so a provider that stayed async but sent the
request through a sync client would satisfy the thread assertion; the
recorded factory is what catches that partial regression.
* test(community): align You.com URL safety stub with async tool
The URL safety matrix still patched the removed _get_api_key seam and invoked the You.com tool synchronously. Mock the awaited search with AsyncMock and call the tool through ainvoke so URL screening remains covered after the async migration.
---------
Co-authored-by: treasuresyq <309743290+treasuresyq@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* feat(knowledge): add independent Wiki and entity relation retrieval tools
* fix(knowledge): address review diagnostics and CI catalog coverage
* fix(knowledge): deduplicate graph evidence and update catalog e2e
* fix(knowledge): clarify bounded evidence and pin browser CI runners
* Update e2e-tests to run on ubuntu-latest
* Update workflow to use ubuntu-latest
* test(ci): stop pinning maintainer-controlled browser runner images
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* feat(community): migrate GroundRoute tools to async HTTP
The GroundRoute provider sent its search and fetch requests through a sync
httpx.Client. A sync LangChain tool invoked through ainvoke runs on the
default executor, so the request held one of that pool's threads for the
whole network round-trip, and several providers queried in parallel queued
behind each other.
Make both tools coroutines over httpx.AsyncClient, resolve the API key and
the configured max_results in a single config read, and keep that read off
the event loop with asyncio.to_thread.
* refactor(community): drop the dead GroundRoute API key wrapper
The async migration resolved the key and max_results through
_resolve_tool_settings, which left _get_api_key as a second seam around
the same config read: only TestGetApiKey still exercised it. web_fetch
now reads its key through the same resolver, and the tests point at
_resolve_tool_settings.
Also record which httpx client factory the provider reaches for in the
runtime anchor. MockTransport runs its handler on the calling thread
without real blocking I/O, so a provider that stayed async but sent the
request through a sync client would satisfy the thread assertion; the
recorded factory is what catches that partial regression.
---------
Co-authored-by: treasuresyq <309743290+treasuresyq@users.noreply.github.com>
* fix(mcp): keep rotating OAuth refresh tokens across restarts and Gateway instances
MCP servers on the OAuth refresh_token grant lost their rotated refresh
token whenever a new process or manager started (restart, tool-cache
reset for non-task servers, every personal-connection run, a second
Gateway instance) and re-seeded with the consumed configured token,
getting invalid_grant for good.
- New table mcp_oauth_tokens (migration 0047_mcp_oauth_tokens), one row
per refresh_token connection keyed (scope, owner_user_id, server_key):
('deployment', '', name) or ('personal', owner, personal runtime
name). Access and refresh tokens are DEER_FLOW_CREDENTIALS_KEY
ciphertext; a config fingerprint (token_url, client_id, grant, scope,
audience, seed) makes a changed seed win.
- OAuthTokenManager re-reads the row under its process lock, adopts a
peer's valid token, and otherwise claims the refresh with a lease plus
a version compare-and-set (database clock), fetches outside any
transaction, writes under the lease and releases it on failure.
- The Gateway installs the store (sync engine via to_thread) around the
LangGraph runtime for sqlite/postgres; personal managers pass their
owner. client_credentials stays in process; an unloadable key falls
back to process memory; database errors fail the refresh.
- credentials_key_consumers lists refresh_token MCP OAuth servers, so a
declared multi-instance deployment needs the shared key.
Docs: MCP_SERVER.md, CONFIGURATION.md, Helm README, README, mcp and
migrations AGENTS.md, CHANGELOG (en/zh).
* docs(changelog): link the MCP OAuth token store entry to #7207
* fix(mcp): prune removed connections' OAuth token rows and ask owners to re-authorize on invalid_grant
Review follow-up (willem-bd):
- Both MCP config writers drop mcp_oauth_tokens rows of connections a
save removes (deployment: _commit_mcp_config_write; personal:
_mutate, keeping each connection's enabled/disabled runtime-name
variants so a toggle keeps its chain). Best effort; no startup sweep,
since a missing config file would look like every server removed.
- invalid_grant on a refresh_token request raises
McpOAuthReauthorizationRequired: the tool error names the server and
asks for a new refresh token, plus the shared-key hint when this
instance could not decrypt the stored chain.
- Annotate the gateway helpers' config as AppConfig.
* fix(mcp): renew the OAuth refresh lease, keep unstored rotations, and key access tokens by request parameters
Review follow-up (willem-bd, second round):
- HTTPX timeouts bound each I/O wait, not a whole response: the refresh
holder now renews its lease every third of REFRESH_LEASE_SECONDS while
the token request runs and abandons it after REFRESH_MAX_SECONDS (120 s);
waiters outlast a live holder. Regression uses a real loopback endpoint
that streams its body for three lease lengths.
- A refresh whose tokens could not be stored keeps them pending
(unsaved_base_version): they are written back before an older stored
token is adopted or refreshed with, retried every few seconds while the
access token is still valid, and dropped once a peer stored a newer
chain. A failed save releases the lease.
- New token_request_fingerprint (extra_token_params and response fields):
a stored access token is reused only under the same request parameters;
otherwise the stored refresh-token chain is refreshed.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(memory): persist the DeerMem extraction watermark across restarts and replicas
The updater's per-thread extraction watermark lived only in an in-process
LRU, so a restart or a second Gateway replica on the same storage root fed
already-extracted turns to the memory LLM again.
FileMemoryStorage now keeps it in users/{user}/agents/{agent}/.metadata/
watermarks.json, keyed by thread id, written atomically under the per-user
lock without touching memory.json and capped by watermark_max_keys. Each
update feeds after the furthest of the LRU entry and the persisted one, and
writes through after an extraction or a pre-screen skip; bypass_watermark is
unchanged and sidecar IO failures fall back to the LRU. Memory clears remove
the file with the other .metadata sidecars. Content-based message identities
are stored as SHA-256 digests so the sidecar holds no conversation text.
* docs(changelog): link the memory watermark entry to #7204 and #7205
* fix(memory): never replace an unreadable watermark sidecar
set_extraction_watermark rewrites the whole watermarks.json, and the shared
sidecar reader turns an OSError into an empty mapping, so a failed read could
replace every other thread's watermark with the current one (or delete the
file). The watermark sidecar now has its own reader: a missing file reads as
empty, malformed content reads as empty (nothing to keep; the next write
repairs it), and any other OSError propagates before anything is written. The
updater already logs that and keeps the extraction successful.
* fix(memory): keep the persisted watermark when the last message has no identity
_message_identity returns None for a last message with neither an id nor
text. The updater wrote that None through, which deleted the thread's entry
from watermarks.json, losing a watermark a peer may have advanced and forcing
a full feed on every replica. The write-through now skips None: the persisted
entry still marks an extracted prefix and the furthest-of-both read keeps
using it.
* fix(channels): keep staging cleanup from replacing the save error
The channel stores publish state through a staged temporary file and, if the
rename fails, close and unlink that file inside the handler before re-raising.
Neither step was guarded, so a failing close skipped the unlink and a failing
unlink replaced the error that actually caused the save to fail -- leaving the
staged copy, which for the runtime-config and WeChat stores holds an 0600 IM
token, behind with nothing reporting it.
Make both steps independently best-effort, name the leaked file in a WARNING,
and suppress the diagnostic itself so a broken log handler cannot become the
reported failure. This is the same contract PR #6871 establishes for browser
captures, applied to the three JSON publishers in app/channels.
* fix(channels): address staging cleanup review follow-ups
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* 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>
* fix(multi-instance): publish skill_manage cache resets and tolerate a concurrent namespace create
Two leftovers from the multi-instance work (#7097).
The agent's skill_manage tool refreshed only its own process's skills
prompt cache after a create, edit, patch, delete, write_file or
remove_file, so a Gateway replica sharing the home volume kept rendering
that user's previous skill list. Every mutation now runs one drained tail,
as the skill routes do: the write and its history record, the local
refresh, then publish_skills_cache_reset(user_id=...) off the event loop.
A run cancelled while the write is in flight still refreshes and
publishes before the cancellation propagates. A marker that cannot be
written is logged inside the tail and raised as SkillCacheResetPublishError
with a message saying the change was applied, so the model does not retry
a write that succeeded.
Two provisioner replicas starting together against a missing
K8S_NAMESPACE both read 404 and both create; the loser's 409 escaped the
lifespan and crashed it. A 409 on create now counts as the namespace
existing, as the Pod and Service creates already do.
Also drops a stale duplicate of the multi-instance "declaration also
drives" note in CONFIGURATION.md.
* docs(changelog): reference #7099 for the multi-instance leftovers
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(memory): filter honcho search by category instead of stamping it on unfiltered results
HonchoMemoryManager.search sent an identical unfiltered query whether or
not a category was requested, then stamped the requested category on
every hit, so memory_search(category=...) returned unfiltered results
attested to be that category (e.g. a work fact labeled "preference")
— the opposite of the contract, where category filters before the
top_k slice and only facts with that exact category are returned.
Send the category as a real server-side metadata filter (Honcho applies
"metadata" filters in its ranked query, before the limit) and label
each result from the record's own metadata, falling back to "memory"
as before. HonchoClient.search gains a keyword-only filters argument
that is only put on the wire when set, so unscoped requests are
byte-identical to before.
* docs(memory): note honcho category filtering requires stored metadata
* 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.
* 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>
* 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>
* 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.
* fix(security): add DEER_FLOW_CREDENTIALS_KEY for at-rest credential encryption
There was no at-rest encryption key. ChannelCredentialCipher had no
production caller: the channel service, the scheduled-task notification
outbox and the channel-connections router all built
ChannelConnectionRepository without a cipher, so store_credentials refused
(RuntimeError) and get_credentials always returned None.
- New harness module deerflow.config.credentials_key: env-only
DEER_FLOW_CREDENTIALS_KEY (comma-separated Fernet keys; first encrypts,
all decrypt via MultiFernet), CredentialsCipher with encrypt_text /
decrypt_text / rotate_text, a fernet:v2: prefix with legacy fernet:v1:
values still readable, and get_credentials_cipher() as the process-wide
entry point. Malformed keys are refused without echoing key material.
Without the variable a single instance generates
{base_dir}/.credentials_key. Never derived from AUTH_JWT_SECRET.
- New deerflow.config.secret_file.read_or_create_secret_file: O_EXCL
create (0600) plus read-back with a bounded settle window, shared by
.credentials_key, .jwt_secret (fixes two replicas cold-starting on a
shared volume keeping different JWT secrets) and the managed-model key.
- ChannelCredentialCipher now builds on CredentialsCipher (same
constructors); the Gateway loads the cipher off the event loop at
startup when channel_connections is enabled and passes it to every
ChannelConnectionRepository. Undecryptable values stay "missing + warn".
- Startup gate: a malformed key stops startup in any topology; a declared
multi-instance deployment with channel_connections enabled and no key
is refused. Plain multi-worker shares the generated file and is allowed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(deploy): generate and inject DEER_FLOW_CREDENTIALS_KEY in Helm, compose and deploy.sh
- Helm: the app Secret generates a Fernet key (randBytes 32 translated to
the urlsafe alphabet) and preserves it across upgrades via lookup like
AUTH_JWT_SECRET; the gateway reads it through an optional secretKeyRef
so a user-managed existingAppSecret without it still boots (the Gateway
gate refuses only multi-instance deployments that store credentials).
README, NOTES and values document backup, rotation and existingAppSecret.
- scripts/deploy.sh resolves the key shell -> .env -> persisted file ->
generated, persisting it as $DEER_FLOW_HOME/.credentials_key (0600,
noclobber/O_EXCL with read-back): the same file the Gateway generates in
its runtime home, so either side's key is the one both keep.
- Both compose files pass DEER_FLOW_CREDENTIALS_KEY=${...:-} to the gateway.
- .env.example entry with a generation hint; README and CHANGELOG notes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(security): make abandoned secret-file replacement single-winner
A rename is last-writer-wins, so replicas that restart together and all
find the same abandoned empty secret file could each replace it and keep
a different value. The process that exclusively creates
<name>.replacing now owns the replacement, re-checks the file is still
empty, and publishes by renaming the claim over it; peers wait for that
value, bounded so a crashed replacer cannot block startup forever.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(changelog): link DEER_FLOW_CREDENTIALS_KEY entries to #6611
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(security): name the setting that turns off a credentials-key consumer
The multi-instance refusal ended with "or disable
channel_connections.enabled=true"; it now says "or set
channel_connections.enabled=false".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(security): never expose a secret file before its value is written
O_EXCL published the name before its content, so a creator that stalled
mid-write left an empty file a peer replaced after the settle window; the
resumed creator then returned a value nobody persisted, or deleted the
peer's valid file when its own write failed. Creators now write and sync
a private temporary file and hard-link it to the name, which fails if
the name exists; a failed creator only removes its own temporary file.
Filesystems without hard links publish through the same single-winner
<name>.replacing claim that replaces abandoned empty files, so creation
and replacement are serialized there.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(models): recover an empty managed-model key when no catalog exists
An interrupted first save could leave an empty key file; every later save
then passed empty bytes to Fernet and the endpoint returned 503 until the
file was removed by hand. An empty key with no catalog encrypted nothing,
so it now goes through the secret-file recovery path; next to an existing
catalog it is still refused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(docker): pass the dotenv DEER_FLOW_CREDENTIALS_KEY through make docker-start
scripts/docker.sh runs Compose from docker/ without --env-file, so the dev
gateway's DEER_FLOW_CREDENTIALS_KEY=${DEER_FLOW_CREDENTIALS_KEY:-} entry
interpolated to empty and overrode the key the checkout .env supplied via
env_file. Export it from the dotenv like AUTH_TRUSTED_PROXIES.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(acp): resolve a bare agent command through PATH before spawning
`invoke_acp_agent` handed `acp_agents.<name>.command` straight to the ACP
SDK, which spawns it with `asyncio.create_subprocess_exec`. That API applies
no `PATHEXT` lookup on Windows, so a bare npm launcher (`npx`, `mcode`)
raised `FileNotFoundError` although its `.cmd` shim was on `PATH`. The
documented `command: npx` and `command: mcode` shapes could not start, and
the tool reported "was not found on PATH" for an installed launcher.
Resolve the configured command with `shutil.which` before spawning — the way
the MCP Python SDK normalizes stdio server commands — and keep the configured
value when nothing matches, so the existing install guidance still fires.
Fixes#6597
* fix(acp): resolve the ACP agent command off the event loop
`_resolve_agent_command` calls `shutil.which`, which stats the filesystem.
Calling it inline in the async `_invoke` tripped the strict BlockBuster gate
(`BlockingError: Blocking call to os.access`) for every command that resolves,
and would block the Gateway event loop in production.
Offload the lookup with `asyncio.to_thread`, matching the neighbouring
`_get_work_dir`, `_build_acp_mcp_servers`, and `_format_invocation_error`
calls.
The blocking-io anchor configured `command="test-agent"`, a name that resolves
nowhere, so `shutil.which` short-circuited before `os.access` and the gate
never covered the new call. Point it at `sys.executable` so the lookup is
really exercised, and cover the unresolvable-guidance branch end-to-end with a
new test.
Refs #6597
* fix(acp): keep the configured command in ACP remediation text
Review follow-ups on the PATH resolution:
- Only bare names are resolved. A configured path is interpreted by the spawn
against the per-thread ACP workspace, so resolving it against the Gateway's
working directory could pick a different file or hand the spawn a path that
then fails.
- `_format_invocation_error` receives `agent_config.command` again. A command
can resolve on PATH and still fail to launch (a broken shebang, for example),
and reporting the resolved path made "was not found on PATH" false and
dropped the `codex-acp` hint.
- The blocking-io anchor uses a bare name backed by a shim on a controlled PATH
instead of `sys.executable`, which the bare-name scoping no longer resolves.
Tests: the pass-through assertion in
`test_invoke_acp_agent_uses_fixed_acp_workspace` pins PATH, and new coverage
covers the bare-name scoping and the spawn-failure message.
Refs #6597
* docs(acp): correct the path-resolution rationale
A configured path is resolved by the spawn in its own process, so the
pass-through reason must not claim it is resolved against the ACP workspace:
that only holds on POSIX, while Windows resolves a relative executable against
the calling process's directory. State the platform-neutral reason instead.
Refs #6597
* fix(acp): resolve the launcher against the agent's own PATH
The ACP SDK merges acp_agents.<name>.env over its inherited environment at spawn time, so a configured PATH, not the Gateway's, decides which launcher the agent would find. Resolve a bare command against that effective PATH, and use the Gateway's only when the agent does not override it.
Return a successful lookup as an absolute path: a relative PATH entry makes shutil.which yield a result relative to the Gateway's cwd, while the spawn runs with cwd set to the ACP workspace, where that path resolves elsewhere or not at all.
Regressions: a real-subprocess test with same-named launchers in the Gateway and agent PATHs (asserting which one ran), and a real-subprocess test whose PATH holds a relative directory while the ACP workspace is elsewhere. Both are red on the pre-fix code.
* 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>
* fix(mcp): reconcile committed Gateway config mutations
Install selective MCP binding fences inside the Gateway config commit boundary and finish teardown after releasing config locks. Reject frozen durable-task server changes before persistence.
Preserve drained cancellation semantics and cover delete/re-add, durable-only bindings, multi-server diffs, shared writers, and post-write reconciliation failure.
* test(mcp): pin committed handoff boundaries
Prove the fallback fence is installed before releasing the config lock, a committed writer cannot swallow an earlier shared reset, delete/re-add advances the applied baseline and cache generation, and retries after a post-write 500 have explicit conflict or missing semantics.
* fix(mcp): fence committed config after reload failure
Treat a post-write reload failure as a committed handoff failure so local MCP state is detached before releasing the config lock and teardown remains outside it.
* fix(mcp): always finish committed pool teardown
Run finish_mcp_reconciliation from a post-lock try/finally so response or logging failures cannot strand a retired full-reset pool.
* test(mcp): fence cold-start binding race
Pin the in-flight discovery path that keeps the committed fast path from admitting a late old-fingerprint binding.
* docs(mcp): document committed teardown guarantee
* fix(mcp): sanitize committed response errors
Keep post-write response conversion failures behind a fixed HTTP detail while preserving the retired-pool teardown guarantee.
* fix(mcp): avoid duplicate committed reconciliation error logs
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* 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>
* fix(sandbox): attest lark broker mode per Pod and quarantine uncertain shell outcomes
Why this change:
Two fail-open paths in the AIO sandbox lifecycle. First, the lark broker
capability probe collapsed "provisioner unreachable" into "broker not
configured": one 1.5s timeout cached a negative for 300s, so new sandboxes
were created without the broker sidecar while the per-user credential
directories were still bind-mounted into the container, and existing Pods
kept being reused without their actual mode ever being read. Second, an
implicit-shell command that timed out or returned an unknown status only
marked the current client object; warm-pool reclaim and rediscovery built a
fresh client that re-entered a container whose shell state could not be
proven clean, and nothing about that ambiguity survived a Gateway restart.
What changed:
- The capability cache stores confirmed observations only, keyed per
provisioner endpoint/auth identity; an unknown answer (timeout, malformed
payload) raises instead of selecting credential placement, while a
confirmed broker requirement is retained through outages. One in-flight
probe per identity shares success and failure, and a Pod-attested mode
contradicting the cache forces one shared refresh.
- Execution uses the mode attested for the actual sandbox:
SandboxInfo.lark_cli_broker flows from the provisioner's atomic Pod
observation into the client, so a probe outage delays drift detection but
never denies reuse of an attested runtime, and never trusts an unattested
one.
- The provisioner reports each Pod's actual broker mode, generation (Pod
UID) and shell capacity from one read, rejects lark requests conflicting
with its configuration (409/503 carrying a capability-refresh marker the
Gateway uses to drop the stale cache entry), rejects conflicting or
Terminating Pods on coordination creates while reporting Terminating as
data to status/get/list, and answers Pod presence independently of
Service readiness.
- A persistent quarantine (DEER_FLOW_HOME/sandbox-quarantine, keyed by
scope/sandbox/generation) fences containers after uncertain implicit
shell outcomes across client rebuilds and restarts; in-process reuse,
warm reclaim, discovery, create responses and orphan reconciliation all
pass one policy gate, and replacement runs under the existing ownership
and teardown fences. A fenced sandbox still tracked after a failed
release-recycle is destroyed inline on the next acquire.
- Quarantine records retire only after confirmed absence, retrying partial
cleanup for the quarantined generation and releasing remembered ports
under the same fences; local stop failures now propagate instead of being
logged away, and remote destroy errors surface to callers.
Problem solved:
A provisioner blip could silently strip broker isolation from new lark
sandboxes for minutes at a time, and ambiguous shell outcomes let corrupted
default shells persist across warm reuse, rediscovery and restarts. Docs
updated: README, aio_sandbox and skills AGENTS.md.
* docs(changelog): record sandbox attestation and quarantine fix (#6436)
* fix(sandbox): preserve active leases and fail closed on unattested broker modes
Concurrent uploads could recycle a healthy AIO runtime while another holder
was creating or cleaning up a command session. Older provisioner responses
could also trigger destructive replacement before failing admission.
- Distinguish pending session creation from ambiguous outcomes and reserve
idle teardown until execution and upload holders finish scope cleanup.
- Reject stale fork lookups and reconcile cancelled acquires without
releasing a sandbox still borrowed by another holder.
- Default Lark broker mode to unverified and preserve unattested Pods until
the provisioner is upgraded.
- Recognize Docker 29 absence diagnostics while matching resource names exactly.
- Preserve Gateway upload errors and cover admission and lifecycle regressions.
* fix(sandbox): scope the unverified-broker refuse path to attested providers
The fail-closed raise in _lark_cli_env_from_runtime treated every Sandbox
without a lark_cli_broker attribute as unverified, but only AioSandbox
carries the attestation — E2B, OpenSandbox, Tenki, and Boxlite have no
broker sidecar, no credential mounts, and no provisioner to upgrade, so
every lark-cli command on those providers failed with an unactionable
error.
Declare the tri-state on the Sandbox base class (defaulting to False,
the only mode brokerless providers have) so the None fail-closed path
stays scoped to AioSandbox's deliberate unverified default.
- Add lark_cli_broker: bool | None = False to the Sandbox ABC, mirroring
the persistent_shell_sessions tri-state pattern
- Add a regression test pinning the non-broker overlay for a provider
without the attribute
* fix(sandbox): close review gaps in broker attestation and teardown lifecycle
Address the latest #6436 review round:
- The provisioner's post-build broker-mode 409 now carries the
capability-refresh marker, so an incompatible-mode conflict surfaced by
idempotent re-create, attestation drift or post-create verification drops
the Gateway's cached capability observation instead of repeating the same
409 for the full negative-cache TTL.
- AioSandboxProvider.destroy() returns the _destroy_tracked outcome: False
when teardown is deferred because an execution/upload holder or a peer
teardown is still active, so callers can no longer mistake a deferred
destroy for a completed one.
- A live replacement under the same logical ID proves the fenced old
generations are absent; retire_replaced_generations prunes only those
stale records so they cannot strand future creates, while the live
generation's fence and unversioned crash fences are never touched.
- SandboxInfo.from_dict keeps a missing lark_cli_broker attestation
unverified (None) instead of silently rehydrating as attested non-broker.
- The restricted-network inspect path shares the strict
_is_no_such_network_error parser, closing the substring loophole for
context-name collisions and wrong-target errors.
* docs(sandbox): condense lease notes to keep the AGENTS.md chain under the size gate
The three lease notes added in 26c307f3 pushed the effective guidance chain
to 98,312 bytes — 8 over the 98,304 hard limit enforced by agent-guidance
(AG002). Fold them into two lines carrying the same rules.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>