* fix(gateway): resolve the agent factory off the event loop
start_run() called resolve_agent_factory() inline, which lazily imports
the full lead-agent assembly stack (middlewares, authz, MCP, jsonschema;
measured at ~8s cold on Windows, multi-second on slower filesystems).
start_run is the single choke point every run-creation path flows
through, so the first run after Gateway startup blocked the event loop
for the whole import, stalling every other request, stream and channel
on the same loop - the loop-stall family issue #5172 fixed for agent
construction itself (the checkpoint-state path already resolves through
the assembly pool).
Resolve the factory via asyncio.to_thread: the first call pays one
worker-thread hop for the import, later runs hit the cached module.
Regression test pins the contract with a probe module whose module-level
__getattr__ records the thread that performed the from-import.
* fix(gateway): isolate factory resolution in the assembly pool
Move import regressions into the strict blocking-I/O suite and cover default-executor isolation, request context, and failure-before-admission ordering.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(skills): keep custom-skill edit checks off the event loop
update_custom_skill built the user-scoped storage, ran the editability
probes, validated the frontmatter through a temporary directory and read
the content being replaced on the Gateway loop; only the write and history
append had been offloaded. Run the pre-scan checks in one worker and the
replaced-content read in another, matching rollback_custom_skill, and build
delete_custom_skill's storage off the loop as well.
* fix(skills): build skill storage off the loop in install and skill_manage
The same sweep that moved the custom-skill edit checks off the loop finds
two more storage lookups on it: the archive-install helper behind both
install routes, and the agent's skill_manage tool, whose lookup resolves
and stats config.yaml on every call. Offload both, and pin that the
caller's user context still reaches the storage lookup now that the edit
route resolves it inside a worker.
* docs(changelog): reference #6332 for the skill storage off-loop fix
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(gateway): gate declared multi-instance deployments and require the redis stream bridge
GATEWAY_WORKERS / WEB_CONCURRENCY only count the uvicorn workers of one
process tree, so a Kubernetes Deployment with replicas > 1 (one worker
per Pod) passed every multi-worker startup check while each Pod's
startup orphan reconciliation wrote the other Pods' lease-less runs off
as crashed on every rolling update.
Add `deployment.multi_instance` as an explicit declaration that runs the
same gate, plus a DEER_FLOW_MULTI_INSTANCE env override so deploy tooling
can set the declaration from its replica count without editing
config.yaml (wiring the Helm chart to export it is a separate change).
Both the worker count and the declaration now also require a Redis
stream bridge and refuse an explicit memory sandbox ownership store,
since the memory bridge and ownership store are process-local. The
agent-storage divergence warning, the inbound webhook dedupe warning and
the WeChat QR-login guard honor the same declaration.
* docs(changelog): link the multi-instance gate entry to #6328
* fix(ci): bump chart config_version to 53 and keep AGENTS.md under the size limits
config.example.yaml moved to config_version 53 for the new deployment
section, so the Helm chart's embedded config and its README example
follow. The multi-instance notes in backend/AGENTS.md and
backend/app/gateway/AGENTS.md shrink to one line each: main already sits
within 55 bytes of the per-file hard limit for the gateway guide and
within 163 bytes of the chain hard limit under backend/, and the full
contract lives in backend/docs/CONFIGURATION.md.
* fix(gateway): name every active knob in the multi-process rollback
When GATEWAY_WORKERS > 1 and a multi-instance declaration are both
active, the refusal offered only "Set GATEWAY_WORKERS=1", so an operator
who followed it bounced through a second refusal on
DEER_FLOW_MULTI_INSTANCE / deployment.multi_instance at the next start.
The rollback now withdraws both knobs, and the browser-tools refusal
takes the same path instead of the worker-count-only message.
multi_instance_declaration() returns a frozen MultiInstanceDeclaration
(source, value) with knob/rollback properties instead of a display
string, so deps.py no longer branches on message prose. Tests pin the
combined rollback text and that following it actually clears the gate.
* docs(gateway): keep the gateway AGENTS.md under the size hard limit after merging main
main grew backend/app/gateway/AGENTS.md by 30 bytes, which left it 25 bytes
under the 49152-byte hard limit; the one-sentence multi-instance note from
this branch pushed the merged file to 49164. Shorten that sentence by 18
bytes so scripts/check_agent_guidance.py passes on the merge result.
* ci: re-run checks after test_stop_cancels_a_hung_driver_poll flaked on shard 4
tests/test_mcp_task_service.py::test_stop_cancels_a_hung_driver_poll timed
out once on backend-unit-tests (4) for 2c16206ac. The MCP task service and
its test are identical to main, main's run for cfe2023df passed, and 15
consecutive local runs pass; the test gives service.stop() a 1s budget
while cancellation drain allows 5s, so a loaded runner can exceed it.
Fork contributors cannot re-run a job, hence this empty commit.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(knowledge): load the catalog's agent config off the event loop
Both retrieval-catalog handlers called load_agent_config() directly from
async code. The agent store is synchronous: file IO on the file backend,
a sync SQLAlchemy round trip on the db backend, so a slow disk or
database stalled every other Gateway request. Offload the load with
asyncio.to_thread, matching agents.py, scheduled_tasks.py and the
run-admission path in services.py.
The strict Blockbuster anchors drive both handlers end to end against
real file and SQLite agent stores, plus the unknown-agent 404 path.
* docs(changelog): reference #6313 for the knowledge catalog fix
* Added the Chinese changelog entry back
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(threads): remove a deleted thread's files off the event loop
DELETE /api/threads/{id} called shutil.rmtree over the thread directory
directly on the event loop, so deleting a thread with a large workspace
stalled every other Gateway request and live SSE stream until the whole
tree was gone. Branch copying in the same router already offloads its
filesystem work through run_file_io; deletion did not.
The removal now runs on the file-IO pool. It is wrapped in await_drained
rather than bare-awaited: cancelling the await abandons the worker, not
the removal, so the durable delete reservation would otherwise release
while files are still being deleted, and a newly admitted run could
write into a directory that is about to disappear.
* docs(changelog): reference #6319 in the thread delete off-loop entry
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(browser): drain live cleanup before releasing session
* test(browser): cover cancellation precedence during cleanup
* style(browser): separate cancellation regressions
* fix(gateway): scope per-run message and event reads to the run's data identity
start_run stamps run-event rows with the raw trusted owner id, but
GET /runs/{rid}/messages and /runs/{rid}/events called the event store with
user_id=AUTO, which resolves to the internal caller's make_safe_user_id form.
On the database event store, owners whose id contains characters outside
[A-Za-z0-9_-] (IM channel ids such as "feishu:owner-777") therefore got an
empty list. The regenerate source-run scan had the same mismatch and fell
back or returned 409.
Pass _run_scope_user_id() explicitly, as the thread message routes already
do (#5448), and add user_id to RunEventStore.list_messages_by_run() so every
backend accepts it like list_messages()/list_events(). Browser and API
sessions keep their per-user filter.
* fix(gateway): scope workspace-changes and archive reads to the run's data identity
The same raw-owner mismatch reached two more run-scoped routes. On the SQL
stores, GET /runs/{rid}/workspace-changes reported no changes because its
event lookup used user_id=AUTO, and GET /runs/{rid}/artifacts/archive
answered 404 because both its run-row lookup and its run.delivery event
lookup did.
Pass _run_scope_user_id() to both, and let get_workspace_changes_response()
forward an explicit user_id to the event store.
* docs(changelog): reference #6282 in the run-scope read fix entries
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(gateway): read uvicorn's WEB_CONCURRENCY in the multi-worker safety gates
The SQLite/scheduler gates in deps.py derived the worker count from
GATEWAY_WORKERS alone, but backend/Dockerfile and scripts/serve.sh launch
uvicorn without --workers, and uvicorn then takes the count from
WEB_CONCURRENCY. A Gateway scaled that way starts N processes with every
gate inert, which is exactly what the gates exist to prevent.
Read both variables, preferring GATEWAY_WORKERS, treat a blank value as
unset the way the compose command's ${GATEWAY_WORKERS:-1} does, and name
the variable that actually set the count in the refusal text. The channel
connection gate already reads both (channel_connections.py).
* fix(gateway): fall through to the fallback worker count
A non-numeric GATEWAY_WORKERS made the shared reader return one worker
immediately, so on the launches that pass no --workers (backend/Dockerfile,
scripts/serve.sh, backend/Makefile gateway) an operator's
GATEWAY_WORKERS=abc plus WEB_CONCURRENCY=4 started four processes while every
gate stayed inert. Keep looking at the remaining spellings and report a single
worker only after all of them have been tried.
The gate file also needs to be independent of the invoking shell: with
WEB_CONCURRENCY exported, the pre-existing
test_gate_treats_invalid_env_as_single_worker already failed on this branch,
because the reader now consults that variable too.
* feat(extensions): add host-bound Agent run control
* fix(extensions): preserve internal launches and scope run control
* docs(extensions): use a plugin-local run operation key
* fix(mcp): drain the config read-modify-write across cancellation
Route the five MCP config endpoints through the canonical
run_drained_write helper and fold the tools-cache reset into the drained
unit, so a cancelled caller cannot detach from the worker holding the
extensions-config locks or skip the cache invalidation behind a settled
write. Caller-facing HTTPException contracts re-raise unlogged.
Fixes#6209
* fix(mcp): type the drain helper against the worker signature and unlist drain adopters
- _run_drained_mcp_apply now mirrors the canonical helper's [**P, T]
Callable[P, T] typing so each call site's argument list is checked;
- AGENTS.md stops enumerating the adopted routers, so the line stops
needing an edit on every new adopter.
---------
Co-authored-by: xiaodu55 <xiaodu55@users.noreply.github.com>
* fix(browser): pin browser connections to the SSRF-vetted addresses
The navigate screen and the per-request guard resolve a hostname to vet
it, but Chromium resolved the name again to open the connection. A
rebinding DNS server could answer both checks with a public address and
the connection with a private or cloud-metadata one, and the browser
followed it.
Launch each private browser through a per-session loopback SOCKS5 proxy.
Over SOCKS5 Chromium sends the hostname itself, so the proxy resolves it
exactly once through the session's egress resolver and connects only to
the addresses that resolver vetted. resolve_browser_egress applies the
same allow_private_addresses policy as validate_browser_url, on top of a
new url_safety.resolve_public_addresses helper that
validate_public_http_url now shares. Loopback is proxied explicitly with
bypass "<-loopback>". The tools, navigate_and_capture and the Live
WebSocket all pass the resolver, because a thread's first caller fixes
its launch options.
CDP-attached Chrome is unchanged. Delegated fetch services (crawl4ai,
Browserless, fastcrw) still resolve on their own side, which the Gateway
cannot pin.
* docs(changelog): reference #6201 in the browser egress pinning entry
* fix(browser): bound egress proxy faults and close, and record the WebRTC gap
Review follow-ups for the pinned browser egress proxy:
- A resolver exception other than ValueError escaped the connection
handler as an unretrieved task error. Log it with its traceback and
reply SOCKS5 general failure (0x01), so a resolver fault stays
distinguishable from a policy refusal (0x02).
- Bound the resolve with its own timeout. The handshake timeout did not
cover it, so a wedged getaddrinfo held the half-open tunnel until
Chromium gave up. A timeout replies host unreachable.
- Bound close(): Server.wait_closed() also waits for a connection whose
handler registered after the writer sweep, which could stall session
close and eviction for the 15 s handshake. A handler that starts after
close now drops its connection without opening a tunnel.
- Docs said a launched browser connects "solely" through the proxy.
WebRTC UDP does not traverse a SOCKS proxy, so AGENTS.md, README (en/zh)
and both changelogs now scope the guarantee to TCP and name the gap.
* test(blocking-io): catch socket.getaddrinfo on the event loop
Blockbuster wraps socket.socket methods but not the module-level
resolver, so a synchronous hostname lookup on the loop passes the strict
gate. The SSRF guard in deerflow.community.url_safety resolves with
socket.getaddrinfo from async web tools, and the anchors added next stay
green against that unfixed code without this rule.
Add socket.getaddrinfo to _PROJECT_BLOCKING_RULES, scoped to app.* and
deerflow.* callers. loop.getaddrinfo and asyncio.to_thread resolve in a
worker thread and are not flagged. The full make test-blocking-io suite
stays green with the rule active (187 passed).
* fix(community): resolve SSRF-guarded hostnames off the event loop
validate_public_http_url resolves hostnames with blocking
socket.getaddrinfo. The crawl4ai and Browserless web_fetch, web_capture,
browser_navigate, the Gateway browser navigate route, and the Live
stream's navigate input and seed all called it directly from async code,
so a slow DNS answer for a model- or user-chosen URL stalled the Gateway
event loop for the whole lookup.
Run the guard through asyncio.to_thread at each of those call sites, the
same way personal_network already offloads it. The screening result is
unchanged. fastcrw's web_fetch is a sync tool and personal MCP validation
already runs in a worker, so neither needed a change.
* docs(changelog): reference #6140 in the SSRF guard event-loop entry
* fix(browser): run the Playwright request guard off the shared browser loop
BrowserSession._install_request_guard screens every redirect hop, iframe
and subresource by calling the SSRF guard inside the async route
callback. The guard resolves hostnames with blocking socket.getaddrinfo,
and that callback runs on the process-wide Playwright loop, which also
pumps Live frames and input for every browser session, so one slow
lookup stalled all of them.
Run the guard through asyncio.to_thread there as well, and pin it with a
strict-gate anchor that drives the real route handler. Also note in the
url_safety docstring that the screen is check-time only: a caller that
re-resolves at connect time is not pinned to the vetted IPs.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Memory router kept its own bare drain wrapper (_run_memory_mutation,
five call sites) after #6151 hoisted the superset into
app.gateway.persistent_writes.run_drained_write - this is the remaining
per-router fork called out in that review.
Each call site passes the domain errors its handler already maps
(NotImplementedError 501s, ValueError/KeyError 400/404/409 mappings,
MemoryConflictError/MemoryCorruptionError, OSError 500s), so the helper
stays silent on them exactly as the removed wrapper did; unexpected
failures gain the type-only lost-failure log line per the AGENTS.md
rule. Behavior is otherwise unchanged: same off-thread execution and
cancel-drain semantics.
The router test's drain stub moves to the shared name and pins the
clear-memory error tuple.
Signed-off-by: yetuge <2219677952@qq.com>
Checkpoint durable refs for host-externalized tool results and materialize verified bytes before model calls on another Gateway.
Refs: #4189
Signed-off-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com>
Co-authored-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com>
* refactor(gateway): hoist the persistent-write drain into one shared helper
Each persistent-write surface forked its own per-router drain wrapper:
_drained_write in routers/agents.py (action label, expected_errors
pass-through, lost-failure type-only logging), _run_store_mutation in
routers/subagents.py (bare drain), and the inline await_drained call in
routers/managed_models.py. Hoist the superset into
app.gateway.persistent_writes.run_drained_write and point all three
routers at it, per the review suggestion on #6087 (its family
predecessors #6078/#6092/#6093 have since landed).
The subagents and managed-models call sites now also emit the type-only
lost-failure log line on a drained-then-failed write (previously
silent), matching the tradeoff acknowledged in the #6087 review.
artifacts.py and personal_mcp.py keep their direct await_drained forms:
the former drains a coroutine with its own rollback flow, the latter
needs no label or expected-error handling.
Router tests move their monkeypatch targets to the shared name; the
lost-failure assertion pins the helper's logger. AGENTS.md points the
next persistent-write surface at the canonical implementation.
Signed-off-by: yetuge <2219677952@qq.com>
* fix(gateway): quiet expected domain errors and scope the AGENTS.md drain claim
Review follow-ups on #6151:
- The subagents create/update call sites pass (ManagedSubagentExistsError,)
and (FileNotFoundError,) so the shared helper stays silent on routine
4xx outcomes (duplicate-name 409, stale-store 404 race), matching
create_agent's (AgentExistsError,).
- The managed-models save passes (HTTPException,), so the helper stops
double-logging failures that _save_with_failure_logging already
reports and stays silent on its routine 4xx outcomes.
- The AGENTS.md sentence now names the three converted routers instead
of implying every persistent-write surface uses the helper; the
shortened form also keeps the file under the guidance byte budget
that agent-guidance enforces.
Signed-off-by: yetuge <2219677952@qq.com>
---------
Signed-off-by: yetuge <2219677952@qq.com>
* feat(persistence): add notification delivery outbox for scheduled task IM push
New notification_deliveries table and repository (migration 0012) backing
the outbox pattern for issue #4254: idempotent enqueue keyed on task_run_id
plus event plus provider plus target, atomic claim of due rows, and
exponential-backoff retry state moving pending to sending to sent or failed.
Execution status stays in scheduled_task_runs; delivery status lives here.
* feat(scheduler): enqueue and deliver scheduled-task notifications over IM channels
Closes the loop for issue #4254. On run completion the scheduler writes
outbox rows for the task owner's connected channel identities (success and
failure events; interrupts stay silent), and a new NotificationDeliveryWorker
polls due rows and pushes them through the channel's proactive send path.
Channel gains send_notification; WeCom implements it via the WS client's
send_message with errcode checking, the base class raises for providers
without proactive push.
Delivery is best-effort and never shadows execution status: enqueue and send
failures are logged and recorded in the outbox, which retries with backoff.
Channel liveness is re-checked at delivery time, and the worker stops before
the channel service on shutdown. Feature activates only when channel
connections are enabled; no new dependencies.
Verified end to end against a live WeCom bot: scheduled run completion
produced an outbox row that was delivered in under one second.
* feat(scheduler): include scheduled run result summary in IM notifications
The v1 notification only carried the run outcome skeleton (status, task id,
run id). Completed runs now include the agent's final answer so the IM push
is actually useful headlessly.
- The delivery worker accepts an optional resolve_run_summary(run_id,
user_id) resolver; for run_completed events it enriches the rendered text
with a "Result:" line (bounded to 1000 chars). The lookup is best-effort:
resolver failures or missing summaries degrade to the skeleton text and
never block or fail the delivery.
- The summary is read at delivery time from runs.last_ai_message (the
convenience column written on run completion), not snapshotted into the
outbox payload; retried deliveries re-read the freshest value and the
outbox row payload stays skeleton-only. The claimed outbox row is never
mutated in place.
- Gateway wiring scopes the read to the outbox row's owner_user_id so a
stale row cannot pull another user's run content.
- Failed runs keep the error-only skeleton: a partial answer on a failed
run would be misleading.
Tests: 9 new worker cases (rendering incl. truncation, sync/async
resolvers, failure/missing fallbacks, no resolver call for failed runs).
Full scheduled + outbox + channels regression green (441 passed), ruff
check + format clean.
* fix(scheduler): address review feedback on notification delivery reliability
Review follow-ups for the scheduled-task IM notification outbox:
1. Crash recovery for "sending" rows: claim now stamps updated_at and the
worker reconciles rows stuck in "sending" past a 10-minute lease back to
"pending" before every claim. Per-row try/except isolates a crashing
delivery from the rest of the claimed batch.
2. Double-send race under multiple workers: claim returns exactly the rows
its own UPDATE...RETURNING flipped (guarded by status='pending'), so a
rival worker's concurrent claim can no longer be handed out twice.
3. Restore non-ASCII comments and drop the BOM accidentally introduced in
test_scheduled_task_service.py.
4. Manual "run now" completions no longer enqueue IM notifications; the
user is already watching the UI. Missing trigger metadata fails safe
towards notifying.
5. Channel-outage failures no longer consume the retry budget: they are
parked with count_attempt=False on a flat 15-minute backoff so an
hours-long outage cannot exhaust retries and drop the notification.
Tests: repository 16, worker 21, scheduled-task service 31; targeted
regression across notification/scheduler/channel suites: 511 passed.
* fix(scheduler): address round-2 review on IM notification delivery
Park WeCom transport/outage failures without burning retries, bound worker stop, include task titles, align enqueue with delivery, and document the IM push behavior.
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs(scheduler): complete IM notification docs and drop unused list API
Add backend/channels/migrations AGENTS notes, CHANGELOG and config.example hints for the outbox push path, and remove the unused list_by_task_run repository method flagged in review.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(scheduler): address round-3 review on IM notification delivery
Cap channel-down parking by row age and parked attempts, redact IM egress
content, narrow WeCom transport exceptions, add lifespan wiring tests, and
run ruff format so lint-backend passes.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(scheduler): redact entire PEM blocks in IM egress text
The previous BEGIN-only pattern left key material and the END marker in notifications.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(persistence): align 0014 parked_attempts with bootstrap schema tests
Backfill existing rows with a temporary server default, then drop it so create_all matches Alembic, and move HEAD anchors to 0014.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(persistence): format notification tests and shorten alembic revision id
ruff format --check failed on test_notification_delivery_worker.py.
0015_notification_delivery_parked_attempts exceeds alembic_version.version_num
(VARCHAR 32) and truncates on Postgres CI; use 0015_parked_attempts instead.
* test: fix post-merge CI failures after migration renumber
Update migration 0015 test HEAD expectation to 0017_parked_attempts and add queue_timeout_seconds to the lifespan notification worker mock scheduler config.
* fix(persistence): chain the notification outbox migrations after 0026
Main's head moved from 0016 to 0026_mcp_task_lease_tokens, so the two
outbox revisions are renumbered and re-parented:
- 0017_notification_deliveries -> 0027_notification_deliveries
- 0018_parked_attempts -> 0028_parked_attempts
Only the revision ids, parents and the migration guide change.
* fix(scheduler): adapt the notification hook and its docs to main
- tests: the completion tests assert on the atomic complete_run record;
new cases cover a completion that was not recorded, a task deleted
meanwhile, a failed task read, and the outbox being off.
- AGENTS.md: drop the root note and shorten the backend note to a pointer;
the two notes pushed two effective guidance chains past the 96 KiB limit.
- CHANGELOG: move the entry from the released 2.1.0 section to Unreleased
and add its link references.
* test(persistence): move the chain-head pin to the outbox migrations
test_migration_0026 pinned 0026 as the head, so it failed once the outbox
revisions chained after it. It now checks chain membership like the older
migration tests, and a new test file owns the head pin, checks the parents
and the 32-character revision id limit, and runs the upgrade and downgrade.
* fix(gateway): wire scheduled-task notifications after the channel service starts
The outbox wiring sat in the scheduler block and asked for the running
channel service there. #5035 moved the channel service start to after the
scheduler, and the two changes merged without a conflict, so on every
startup the lookup returned None and the feature disabled itself with the
"write-only outbox" warning. The lifespan tests patched the lookup to a
constant and kept passing.
The wiring now runs in its own step after the channel service start. The
scheduler is built as on main, and ScheduledTaskService gains
attach_notification_outbox() so the enqueue side is enabled once the
delivery worker is running. The guard, the worker and the shutdown order
are unchanged.
Tests: the lifespan helper can model the real accessor (None until the
channel service has started); a new case fails without this fix.
* fix(scheduler): enqueue from scheduler start and keep the push when the title read fails
Follow-up to the startup-order fix, from review.
- The enqueue side is wired with the scheduler again (it only needs the
durable table), and only the delivery worker waits for the channel
service. Attaching it later left a window after the scheduler started in
which a finished run wrote no outbox row. If the channel service is
missing or the worker cannot start, the enqueue side is detached, so the
outbox still never becomes write-only.
- A failed task read after complete_run no longer drops the notification:
the title is optional and the message falls back to the task id. A task
deleted meanwhile still stays silent.
* fix(channels): park WeCom notifications while the SDK socket is closed
The WeCom SDK raises a plain RuntimeError when its socket is not open, and
send_notification only mapped ConnectionError, TimeoutError and OSError to
ChannelUnavailable. A disconnect therefore spent the counted retry budget
and settled the row as failed after about 15 minutes, although the docs
say transport outages park without consuming retries.
send_notification now asks the client's is_connected first. The new test
uses a real, never-connected SDK client: it raised RuntimeError after three
retries before, and parks now.
* docs(scheduler): describe which outcomes notify; mirror the changelog entry
- README (en/zh) and CONFIGURATION.md: only runs that finish as success or
failed notify; occurrences that end without a finished run (launch error,
queue timeout, restart recovery) do not. Parking lasts up to about a day.
- CHANGELOG: cite the issue as text like the rest of the file, and add the
entry and link reference to CHANGELOG_zh.md.
- AGENTS.md: drop the backend pointer so this branch adds nothing to the
guidance chains closest to their size limit; the scheduler-side rules
and the lifespan order now live in app/channels/AGENTS.md.
* docs(changelog): cite #6135 next to #4843 for the notification outbox entry
* fix(scheduler,channels): re-check the IM binding at delivery time; park WeCom transport errors, count its rejections
Review round 1 on #6135.
The delivery worker now takes resolve_connections (the Gateway wires
ChannelConnectionRepository.list_connections) and, before the channel
liveness check and the summary read, requires the row's owner to still
hold a connected (provider, external_account_id). A disconnected target
is finalized with mark_failed(terminal=True); a lookup failure or an
unexpected result shape parks the row. Terminal outbox rows ignore
later status writes.
WeCom: the SDK raises RuntimeError from send_message for a socket that
is not open and for a dropped connection, but also for a platform
rejection ("Reply ack error: errcode=..."). Only that prefix keeps
counting; other RuntimeErrors, ConnectionError/TimeoutError/OSError and
websockets' ConnectionClosed park the row as ChannelUnavailable.
* fix(channels): treat a WeCom ACK rejection as proof the transport is up; pin the rejection prefix through the retry branch and the real connection-row shape
---------
Co-authored-by: GaoTianyuanhr <75788039+GaoTianyuanhr@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: GaoTianyuanhr <GaoTianyuanhr@users.noreply.github.com>
Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
* fix(skills): drain custom-skill mutation tails across cancellation
* fix(skills): log drained skill mutation failures and drain state/install tails
Address review feedback on #6078: record cancelled-then-failed mutation
tails inside the drained unit (exception type only, per the managed-models
precedent), and extend the drained tail to the remaining write-to-cache
shapes in update_skill and the skill install path.
* test(skills): pin the lock contract under the drained cancellation timing
The cancelled update_skill caller now drains its mutation tail, so
CancelledError surfaces only after the tail settles. Start the MCP
writer without awaiting the cancelled task first, assert the section
stays owned while the drained tail is in flight, then await both and
assert the final order.
---------
Co-authored-by: xiaodu55 <xiaodu55@users.noreply.github.com>
* fix(uploads): reject filenames reserved for temporary staging
Guard thread and shelf upload names, validate SDK batches before copying, and preserve legacy shelf downloads. Add regression coverage and document the reserved staging namespace.
* fix(uploads): report reserved names before HTTP batch ingestion
Preflight reserved staging basenames before thread storage or sandbox setup. Return HTTP 400 with a rename hint; add HTTP and strict blocking-I/O regressions and update upload documentation.
* fix(uploads): reject Windows-style staging basenames on POSIX
Normalize backslashes before the HTTP reserved-name preflight. Add POSIX basename regressions for single and mixed batches and strict blocking-I/O coverage. Clarify the preflight scope and upload documentation.
Follow-up to the review of PR #6122; AI-assisted with OpenAI Codex.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(auth): reject a non-ASCII ID token nonce instead of raising
The nonce comparison in validate_id_token still used the local
secrets.compare_digest on str operands, which raises TypeError when either
side holds non-ASCII characters. The nonce claim is provider-controlled
text, so such a provider response turned the callback's sso_failed redirect
(OIDCValidationError) into an unhandled 500.
Route it through the shared constant_time_equals helper #6076 introduced
for the same five compare sites, and drop the now-unused local helper.
Signed-off-by: yetuge <2219677952@qq.com>
* fix(auth): reject a non-string ID token nonce claim like a mismatch
A provider returning a truthy non-string nonce claim (e.g. an int)
slipped past the missing-claim guard and crashed the comparison helper
with AttributeError, which the callback turned into a 500 instead of
the sso_failed redirect. Guard the claim type before comparing so every
malformed nonce takes the same OIDCValidationError path.
Signed-off-by: yetuge <2219677952@qq.com>
---------
Signed-off-by: yetuge <2219677952@qq.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(gateway): drain agent and user-profile writes across cancellation
A client that disconnects mid-request cancels the handler task. On the four
persistent-write endpoints of /api/agents - create, update, delete, and the
USER.md write - a bare asyncio.to_thread then either cancels a still-queued
worker (the write silently never happens) or detaches from a running one and
drops its failure, because the handler's except never runs.
Route the four writes through await_drained like the managed-subagent (#6023),
managed-model (#6024), and custom-skill (#6078) mutations, logging a lost
worker failure with the exception type only before the drain consumes it.
Expected domain errors (AgentExistsError on create) re-raise unlogged; the
caller still maps them to a 409 while connected. Reads stay bare: abandoning
them loses nothing.
Signed-off-by: yetuge <2219677952@qq.com>
* fix(gateway): address review nits on the agents write drain
Make expected_errors a positional-only parameter before *args so the
ParamSpec signature is checker-clean under PEP 612 (the one call site with
extra worker arguments passes () explicitly), acknowledge that
non-cancelled failures log twice like artifacts.py's drained commit does,
and drop the unused request stub helper from the tests.
Signed-off-by: yetuge <2219677952@qq.com>
---------
Signed-off-by: yetuge <2219677952@qq.com>
* fix(agents): anchor agent-name validation against a trailing newline
Two copies of the shared ^[A-Za-z0-9-]+$ grammar still used re.match,
whose $ also matches before a final newline, while every other copy uses
fullmatch. POST /api/agents {"name": "reviewer\n"} therefore passed the
router guard and died inside the strict file store as an HTTP 500, and
DeerMem accepted the same name as an agents/{name}/facts directory.
* test(agents): state which trailing-newline params the regression actually covered
.match only accepted "reviewer\n" on main; "reviewer\n\n" and "reviewer \n"
were already rejected, so the docstrings should not read as if every param in
these parametrizations was broken before the fix.
* fix(gateway): reject non-ASCII tokens instead of raising in constant-time compares
hmac.compare_digest raises TypeError when a str operand holds non-ASCII
characters. Starlette decodes header bytes as latin-1 and query values as
UTF-8, so a single 0xE9 byte in a CSRF token, GitHub webhook signature,
internal auth token, or OIDC state turned the expected 403/401 into a 500.
Route the five client-facing comparisons through a new
app.gateway.utils.constant_time_equals helper that compares the UTF-8
encodings ("surrogatepass" so even a lone surrogate cannot raise). ASCII
results are unchanged, and no bypass was possible before the fix.
* fix(provisioner): reject non-ASCII API keys instead of raising; drive OIDC test over HTTP
The standalone provisioner compared X-API-Key with secrets.compare_digest on
str operands, so a non-ASCII header raised TypeError (500) the same way the
Gateway sites did. It cannot import app.gateway, so encode inline.
The OIDC state regression test now mounts the auth router and sends the
percent-encoded state through a real signed state cookie instead of stubbing
get_state_cookie and calling the handler directly.
* docs(changelog): reference #6076
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(goal): show the goal evaluator the tool calls and results
The evaluator read only the text of user and assistant messages and was told
to fail closed without explicit evidence, so it stood down with
missing_evidence on most completed file tasks: the tool calls and tool
results that the web UI shows never reached it.
Include the assistant's tool calls and the tools' results inside the
existing message window, shortened (200 characters per argument value, 600
per step, line breaks kept), and tell the evaluator to treat tool results as
data, that a successful tool call alone does not meet a goal, and that a
guessed input means needs_user_input. The no-progress and final-clear
signatures are unchanged.
* fix(goal): tighten the evaluator's tool evidence after review
- Pair each tool result with the latest call of its id before it. Some
providers reuse ids such as call_0 across turns, and the window-wide
map labelled an earlier read with a later write's name.
- Keep model-supplied tool names on one line in calls and result labels,
and escape U+0085, U+2028 and U+2029 in arguments and results, which
json.dumps(ensure_ascii=False) leaves raw, so no tool step can start a
new evidence line.
- Leave out the results of a hidden assistant message's calls. The web UI
renders a result only under its call, so neither shows there either.
A clarification prompt stays: the UI shows it as its own card.
- Over the 12,000-char cap, keep whole lines from the end instead of
slicing mid-line. Keep the latest user message that the cut drops at
the top, whole when it and the lines after it fit, else shortened to
2,000 chars. If the next assistant message back does not fit whole,
keep its end in the room left. A marker counts the lines left out.
- Docs: the Gateway AGENTS.md and a worker docstring no longer say the
evaluator never sees tool messages.
---------
Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
* fix(gateway): drain managed model saves on cancellation
* test(gateway): pin managed model save cancellation drain
* fix(gateway): log failed drained managed model saves
Signed-off-by: Willem Jiang <willem.jiang@gmail.com>
* fix(gateway): keep guidance within hard size limit
---------
Signed-off-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(persistence): reject SQLite file: URIs instead of opening them as filenames
resolve_sqlite_conn_str passed a file: connection string through
unchanged, but LangGraph's SQLite checkpointer and Store factories
(SqliteSaver, SqliteStore, AsyncSqliteSaver, AsyncSqliteStore) connect
without uri=True, so SQLite treated the whole URI as a literal filename:
file:checkpoints.db?mode=rwc created a file with that exact name in the
working directory, file::memory:?cache=shared persisted to disk, and a
file:/// URI failed to open. The readiness probe did open URIs with
uri=True, so it checked a different file than the runtime used and
reported in-memory URIs as not_configured.
Reject file: URIs in resolve_sqlite_conn_str, the one helper all four
factories go through, with an error that names
checkpointer.connection_string. The probe drops its URI parsing and
reports a rejected connection string as unreachable.
* docs(changelog): reference #6069
* test(persistence): pin the case-sensitive file: URI check
* fix(gateway): render the bundled interpreter repair path without backslash escapes
The repair hint used {sys.executable!r}, whose repr() escapes Windows
backslashes - users saw 'D:\college\...\python.exe' in the
copy-paste guidance, and
test_stale_bundled_interpreter_can_be_repaired_without_reinstall fails
3/3 on Windows because the raw path never appears in the detail. The
!r was written for the surrounding quotes; on POSIX paths that is its
only effect, which is why CI never saw it.
Quote the raw path explicitly instead. On Windows the three test params
now pass (22/22 in the file); POSIX output is unchanged apart from the
quoting coming from the literal instead of repr().
* fix(gateway): json-escape the interpreter command in the repair hint
Review follow-up (P2): the raw Windows path in the hint cannot be
pasted into the server's JSON ('\U' is not a JSON escape), so the
prescribed repair could not be saved. Show the command value via
json.dumps(sys.executable) - pasteable JSON on every platform - and
keep the raw path in a parenthetical for terminal use, per review.
* fix(integrations): keep Lark runtime status consistent
Probe sandbox runtime readiness for every Lark mutation response outside the credential lock. Expose explicit sandbox_runtime_probed state so the frontend can distinguish conservative fallbacks without inferring semantics from runtime detail text, while cancelling stale reads before updating the cache.
* style(frontend): format Lark cache test
* fix(integrations): snapshot Lark credential status under the lock
Why this change is needed:
PR #5994 moved the post-mutation status read outside the per-user
credential lock so the new sandbox runtime probe would not extend the
critical section. As flagged in review, that lets a concurrent app
switch or auth flow land between the committed mutation and the status
read; in remote-provisioner mode the runtime probe can spend seconds
before probe_lark_auth runs, so the raced switch can clear the new
tokens and make a completed login report success=false, or mix an old
app_id with a newer auth state.
What changed:
- add _LarkCredentialSnapshot (app config + auth probe) and capture it
in the same _lark_credential_lock section that commits the mutation
- thread the snapshot through _get_lark_mutation_status into
get_lark_integration_status(credential_snapshot=...); only the
credential-independent runtime probe still runs after the lock
- apply to complete_lark_auth, complete_lark_config, and
set_lark_app_credentials; install keeps the one-shot status build
Problem solved:
Mutation responses stay tied to the just-committed credential
generation: a raced app switch can no longer flip a completed
device-code login to success=false or blend a stale app_id with newer
auth state, while the runtime probe stays off the lock.
* fix(integrations): address Lark status review follow-ups
- install_lark_integration: snapshot credential-derived status fields
under _lark_credential_lock so the install response cannot mix newer
credential state read across the unlocked runtime probe
- useInstallLarkIntegration: route the cache write through
cacheLarkMutationStatus so install cancels in-flight reads and keeps
probed runtime fields under frontend/backend version skew
- README: soften the UI claim - the status API exposes
sandbox_runtime_probed and the mutation cache honors it
* test(integrations): tighten Lark status fixtures and guidance docs
- cache.test: use a distinct mutation runtime mode in the unprobed-merge
case so the assertions prove the cached runtime fields win
- e2e mock: include broker in the sandbox_runtime_mode union cast so the
fixture cannot drift from LarkSandboxRuntimeMode
- AGENTS.md (gateway + skills): mention sandbox_runtime_probed alongside
the existing runtime readiness fields, keeping the gateway guidance
file under its byte budget
* fix(integrations): harden Lark mutation cache against skew and races
- cacheLarkMutationStatus: await cancelQueries before writing so an
in-flight GET whose fetch already resolved cannot clobber the fresher
mutation status, matching the existing threads/archive pattern
- self-heal version skew: an unprobed (pre-field backend) mutation
response now triggers a status refetch instead of risking the
conservative runtime fallback lingering until the next focus/remount
- api: normalize a missing sandbox_runtime_probed to false at the
boundary so the declared-non-optional type matches skewed runtimes
- tests: pin the skew contract (missing-field cases for cache and api)
and add a useInstallLarkIntegration regression test proving the
install cache write goes through cacheLarkMutationStatus
- README: describe the probed flag as cross-version semantics instead of
claiming the same-version API surfaces both states
- lark_cli: note that install's credential snapshot runs the auth probe
subprocess while holding the credential lock
* refactor(integrations): derive sandbox_runtime_probed inside the resolver
_resolve_sandbox_runtime_readiness now returns a fourth probed element
next to the branch that produces the unevaluated remote fallback, so the
'was readiness actually evaluated' invariant can no longer drift from the
fallback logic it mirrors. Callers and resolver-level tests updated to
pin probed per branch.
* fix(mcp): close thread-scoped MCP sessions on thread deletion
Deleting a thread removed its filesystem data, checkpoints, runs, events,
feedback, metadata and browser session, but never closed its persistent MCP
sessions. MCPSessionPool.close_scope() had no production caller at all, so a
recreated thread id under the same user could reuse stale stateful MCP server
state, and the leaked owner task plus subprocess kept holding memory and file
descriptors.
Add MCPSessionPool.close_thread_scope(user_id, thread_id) and call it from the
reserved deletion path under the same best-effort policy as the neighbouring
cleanups. It matches every incarnation of the thread -- the legacy user:thread
scope and any v2:[user, thread, incarnation] scope -- because a deletion cannot
reliably read the current incarnation: the record may predate incarnation
tracking, and a new incarnation can be minted concurrently. close_scope() is
unchanged for callers that already hold one exact key.
Add mcp_scope_belongs_to_thread() to own the matching rule, and hoist the v2:
prefix into a shared constant so the encoder and matcher cannot drift.
Refs #5188
* test(mcp): bind owner identity in thread cleanup checks
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(mcp): isolate personal connections from deployment configuration
* fix(mcp): preserve personal task ownership and repair plugin E2E fixtures
* fix(mcp): handle corrupt personal config and cache owned calls
* fix(mcp): pin personal endpoint addresses and handle invalid listings
* fix(mcp): recheck current authority for privileged personal connections
* fix(gateway): load skills off the event loop in the listing and detail routes
GET /api/skills, GET /api/skills/custom and GET /api/skills/{name} called
load_skills() inline. Resolving the caller's storage builds absolute
paths, and load_skills walks every public and custom skill directory and
parses each SKILL.md, so the work grows with the number of installed
skills. #5747 moved the same call off the loop for get_custom_skill and
documented why; these three routes were missed, and the strict
Blockbuster gate raises BlockingError on each of them.
Offload the load through one shared _load_user_skills helper (also used
by update_skill's reload), and add a gate module covering the three
routes including get_skill's 404 branch.
* docs(changelog): reference #5945 in the skills listing blocking I/O entries