A `go test -v` run whose every case is skipped exits 0 and prints the same
`ok` package banner a passing run does, so the banner alone supplied positive
evidence for `tests_passed:` while no test executed. unittest (#6916) and Cargo
(#6891) already enforce the non-skipped bar; Go's per-case `--- SKIP` lines are
the only record content that separates the two here.
Non-verbose `go test` output is byte-identical for an all-skipped and a passing
package, so those records stay judged by the banner alone.
* fix(compaction): reject manual compaction of threads with pending execution
A thread paused at a static breakpoint (interrupt_before=["tools"]) keeps its
pending node send in the checkpoint's __pregel_tasks channel. The compaction
write goes through the state-mutation graph, which schedules no tasks, so
LangGraph drops the pending send ('Ignoring unknown node name tools in pending
sends') and a resumed run finishes success without ever executing the tool.
Fail closed: detect a non-empty __pregel_tasks in the raw checkpoint tuple
inside compact_thread_context and raise ContextCompactionPendingWork, mapped
to HTTP 409 by the compact route. The reservation (reserve_checkpoint_write)
already serializes this check against runs and other checkpoint writers, and
the write still forks from the exact head that was read.
Adds dual-mode (full/delta) regression tests: the rejected compaction leaves
checkpoint id, next and pending task ids untouched, and resuming afterwards
executes the pending tool exactly once; idle threads still compact.
* fix(compaction): reject unavailable checkpoints before summarizing
* docs: keep compaction guidance within merged size budget
Serper keeps a private _coerce_max_results in the pre-#5852 shape: a bare
int() silently turns max_results: true into 1 and 3.5 into 3, and an invalid
value falls back without a warning (#5865).
Delegate to deerflow.community.search_max_results.coerce_max_results, which
owns what invalid means for the folded providers, and keep Serper's
10-result cap at the call site the way You.com does, because clamping policy
is deliberately per-provider.
Invalid configured counts now warn under the Serper label and fall back to
the default instead of being silently coerced.
E2BSandbox.grep() applied re.escape() to the caller's pattern and then
handed the escaped text to grep -F. Fixed-string grep treats the added
backslashes as part of the text to find, so an exact literal such as
'alpha.beta' never matched. Align with the boxlite/tenki/opensandbox
seams: grep receives the RAW pattern (-F matches it literally, -E as a
regex), and only a non-literal pattern is compile-validated locally.
A shared-contract regression covers dot/space/bracket/backslash/plus
literals through every shell-backed provider.
* fix(runtime): defer host cancellation through the whole terminal finalization
Cancelling run_agent() during an early terminal-finalization await (e.g.
the delivery-receipt write) escaped the surrounding `except Exception`
guards, because asyncio.CancelledError derives from BaseException. The
cancellation then skipped the remaining terminal tail: terminal status
persistence, the completion hook, task-stop notification, and the stream
END marker.
Add `_await_despite_host_cancellation`, which shields each terminal step
so the started awaitable keeps running under host cancellation, defers the
interruption (uncancelling the task), and re-awaits the shield until the
step completes. Apply it to the durable finalization stages; the original
interruption is still re-raised after publish_end.
* fix(runtime): bound terminal deferral and shield abort-path cancellation
* fix(runtime): retain owned finalization through cancellation
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* chore(ci): refresh the shard duration baseline
backend/.test_durations was last regenerated on 2026-09-22 (61ae228b0, #5656)
and now covers 40.1% of the collected suite, so `least_duration` plans 19,415
tests from the imputed average instead of measured cost. Replaying CI's 4-way
split with the committed file yields shard loads of 203.5/217.4/234.3/222.9s
(1.15x heaviest-to-lightest); with this refresh the same replay is 219.6s each.
Regenerated with the documented target: `make test-shard-durations`, i.e.
`pytest -m "not live" --ignore=tests/blocking_io --store-durations
--clean-durations --durations-path=.test_durations tests/ -q` on main at
8d994cc1e. Data-only diff; the shard jobs keep reading the file unchanged.
* chore(ci): cover the four tests added by 6b60903d7
The checkpointer/postgres-schema helper suites gained 4 cases while this
refresh was in flight, which would have left the baseline 4 entries short of
the base tip. Re-ran just those two files through the same store path, so the
file now covers 32,396/32,396 collected tests with no gap. Re-measuring those
files also refreshed 113 neighbouring entries.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* test(runtime): cover the driver suffix in sync and store DSNs
* fix(runtime): strip the SQLAlchemy driver suffix from sync and store DSNs
With `postgres_url: postgresql+asyncpg://...` and the default empty
`postgres_schema`, the sync checkpointer, the sync store and the async
store passed the DSN to psycopg's `from_conn_string` unchanged, and
libpq cannot parse the `+asyncpg` scheme. `dsn_with_search_path` only
drops the suffix when it adds a schema, so a non-empty schema hid the
bug. The async checkpointer already calls `normalize_libpq_dsn` first.
Call `normalize_libpq_dsn` at these three call sites as well.
* refactor(persistence): drop the driver suffix inside dsn_with_search_path
dsn_with_search_path dropped a SQLAlchemy `+driver` suffix only when it
added a search_path, so every libpq caller had to wrap the DSN in
normalize_libpq_dsn to cover an empty schema, and flattening that
nesting later would bring the bug back.
Normalize at the top of dsn_with_search_path so both paths drop the
suffix and check the scheme in one place, and remove the outer call
from its five callers. The sync checkpointer and both stores are back
to their code on main.
* fix(search): surface DuckDuckGo failures instead of reporting an empty result
DDGS execution failures (rate limits, timeouts, blocked HTML/vqd extraction,
SDK errors, and the missing ddgs dependency) were caught in _search_text and
converted into an empty list, so web_search reported "No results found" for
every failure. The agent then read an outage as a query that matched nothing
and rewrote the query instead of trying an alternative.
_search_text now raises DDGSearchError carrying the exception type and message
for those failures, and web_search_tool answers them with a structured
"DuckDuckGo search failed: ..." error. A genuinely empty result set (the SDK's
exact DDGSException("No results found.") sentinel) still returns the existing
"No results found" response, and tool_result_meta classifies the two cases
differently (transient/config vs no_results).
Tests: tests/test_ddg_search_tools.py (three new failure tests red on main),
tests/test_agent_guidance_check.py, tests/test_tool_result_meta.py; ruff check
and ruff format --check clean.
* fix(search): keep DDGS client construction inside the error boundary
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* test(image-search): expect source_url in DuckDuckGo and Serper results
* fix(image-search): keep the source page URL in DuckDuckGo and Serper results
The DuckDuckGo and Serper image_search tools returned only title,
image_url and thumbnail_url and dropped the page each image comes from
(DDGS `url`, Serper `link`). Brave already returns it as `source_url`,
and the workspace UI links every image thumbnail through `source_url`,
so DuckDuckGo (the default provider) and Serper results rendered as
"Unsafe link omitted" and the agent lost the image's source page.
Return `source_url` from both providers, screened with
`safe_public_result_url` like the image and thumbnail URLs.
* docs: add a Korean README translation
* docs(changelog): reference #6909 for the Korean README
* docs(readme-ko): restore blank lines before headings and sync with README.md
Six headings sat directly under the preceding paragraph or code fence where
README.md keeps a blank line. Also translate the README.md additions from
#6898 (Codex tool_choice), #6886 (mixed-provider citation cleanup), and #6701
(authenticated HTML preview resources).
* fix(community): reserve Browserless capture names before publishing
Name selection and publication were two separate operations:
`_dedupe_output_name` probed with `Path.exists()` and then
`publish_bytes_atomically` ran `os.replace`, so two `web_capture` calls in the
same thread outputs directory could both settle on `report.png` before either
wrote, and the second publication replaced the first capture while both reported
success for the same artifact path (#6883). `web_capture_tool` runs the writer
through `asyncio.to_thread`, and same-second default names collide the same way
because the generated stem's timestamp has second resolution.
Claim the name instead of checking for it: an `O_CREAT | O_EXCL` open either
creates an empty placeholder or reports the name held, so the probe walk
(-1, -2, ... then the microsecond timestamp) is race-free. `_write_capture_output`
releases the placeholder when publication fails, catching BaseException so an
interruption also frees the name, and a failing unlink is logged rather than
allowed to mask the original error - the same error-masking family #6871 is
addressing in the shared publisher, which this change deliberately does not
touch.
When even the timestamped names are held, the function now raises instead of
returning a name it cannot own; returning one would reintroduce the silent
overwrite. `tests/test_browser_outputs_atomic.py` pins the publisher call as
source text, so its expectation moved to the new call shape in the same commit.
Refs #6883
* fix(community): open the reserved capture name with new-file permissions
An `os.open` without an explicit mode creates the file with `0o777 & ~umask`, and
`publish_bytes_atomically` preserves an existing destination's mode, so the
reservation added in the previous commit made every capture executable (`0o755`
under the usual umask) where main produced `0o666 & ~umask` (`0o644`) through the
publisher's new-file branch. Pass `0o666` explicitly and pin the mode against a
`Path.write_bytes` reference in the same directory, which is the permission the
publisher documents for new files.
Refs #6883
* fix(browserless): warn when capture reservation cleanup fails
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(serper): report a format error for results with no usable objects
`_response_items` skipped non-object entries but returned the filtered
list even when it was empty, so a non-empty `organic`/`images` array
holding no objects fell through to "No results found" / "No images
found" -- telling the agent the query legitimately matched nothing
instead of surfacing the malformed payload.
Treat a non-empty container with no object entries as malformed, matching
the containment rule #6603 documents for this provider family (brave
#6550, groundroute #6603, tavily #6645, webz). `web_search` and
`image_search` share this helper, so both now report the provider's
format error. Missing, null, and empty containers keep reading as an
empty search, and mixed lists still keep their valid entries in order.
* fix: address review and validation findings for PR 6779
* fix(opensandbox): report the command deadline as Exit Code: 124
`execute_command` had no timeout branch, so a command that hit its deadline
was reported as whatever the SDK happened to surface. OpenSandbox is the last
provider without the `Exit Code: 124` marker that Local/AIO/Tenki/E2B/BoxLite
emit, and which #6847 established the evidence chain parses to recover the
real shell status.
The deadline is enforced by execd, not by the client: the timeout travels to
the server in `RunCommandOpts`, the SSE read timeout is disabled, and the
process group is reaped with SIGKILL. So a timed-out command does not raise --
it comes back as a `CommandExecError` result with the signal death reported as
exit code -1, which the adapter rendered as `Exit Code: -1`.
Normalize that pairing to the 124 marker, keeping the partial output the
command produced before the kill. The guard is deliberately narrow: a command
that exits non-zero on its own never carries the error event, and a failed
launch shares the same error event with no usable code, so neither is
relabelled a timeout.
Tests: three cases pin the deadline paths (client-side TimeoutError from a
bridge, the server-side -1 kill with output, and the free-text shape that must
stay on the existing missing-exit-code path as a start failure), plus a case
pinning that ordinary exits keep their own code.
RED/GREEN: the deadline cases fail on the unmodified source and pass with it;
test_opensandbox_provider.py 114 passed (110 pre-existing unchanged); the four
sandbox provider suites 601 passed; ruff check and format clean.
* fix(opensandbox): recognize explicit deadline results
Signed-off-by: yetuge <2219677952@qq.com>
---------
Signed-off-by: yetuge <2219677952@qq.com>
* fix(sandbox): truncate quarantine path keys to stay under Windows MAX_PATH
The quarantine nests three hashed path components (scope, sandbox id,
container id). Three full 64-char SHA-256 hexdigests exceed Windows'
260-char MAX_PATH once the quarantine root lives under a long path, so
mark() fails with ENOENT creating the generation record. Truncate the
digest to 16 hex chars, matching the sandbox-naming convention used
elsewhere in this module.
* test(sandbox): pin quarantine key truncation to 16 hex chars
Guard against a regression that reverts the key digest to a full 64-char
SHA-256 hexdigest: three nested 64-char components exceed Windows' 260-char
MAX_PATH and re-break record creation, while Linux CI still passes.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(client): preserve text appended before the first state snapshot
* test(client): exercise first-snapshot reconciliation with stable IDs
---------
Co-authored-by: BaiHeng Peng <191328900+Aldenshile@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* feat(channels): generalize IM group targets and add task/goal run notifications
- Generalize proactive group notification capability (proactive_group_notifications), binding validation, and scheduled task group selection across Feishu, QQ, and WeCom while keeping WeChat personal-only.
- Add QQ group active notification delivery via POST /v2/groups/{group_openid}/messages (msg_type=2) and WeCom group active notification delivery via WS Client.sendMessage(chatid, body) with bot_id:group:chatid workspace isolation.
- Generalize notification outbox sources via migration 0043_runtime_im_notices (source_kind, source_key, run_id, nullable task_id/task_run_id) with backward-compatible SQLite/PostgreSQL upgrades.
- Add opt-in ordinary task & goal run IM notifications (run_completed, run_failed, human_input_requested) on the shared outbox, delivery worker, renderer, and settings UI.
* fix(notifications): address review feedback and group/runtime notification edge cases
* test(migrations): update CURRENT_HEAD to 0043_runtime_im_notices across migration chain tests
* fix(channels): preserve group binding names and legacy authorization
Carry owner-chosen group names through existing connect state, keep private bindings out of legacy group fallback, and restore completion metadata retries. Cover real binding and worker finalization paths and synchronize notification docs.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(models): route gpt-6 tool calls through the OpenAI Responses API by default
The locked langchain-openai==1.2.1 routes gpt-6 models configured with
langchain_openai:ChatOpenAI through /v1/chat/completions, where OpenAI
rejects function tools for the gpt-6 family with an HTTP 400. Upstream
only added the gpt-6 + tools -> Responses API branch in 1.7.x, so
backport that routing decision at the model factory instead of bumping
the dependency (large uv.lock churn, wider regression surface).
_apply_openai_responses_api_default() injects use_responses_api: true +
output_version: responses/v1 for BaseChatOpenAI subclasses whose model
starts with gpt-6, unless the operator configured an explicit value.
Fixes#6841
* fix(models): narrow gpt-6 Responses API default per review
Addresses review findings on #6863:
- Custom endpoints (base_url/openai_api_base) are never touched, so
OpenAI-compatible gateways/proxies (LiteLLM, vLLM, one-api) keep their
chat-completions routing instead of being silently re-pointed at /responses.
- AzureChatOpenAI is skipped too (MRO-based, so env-configured Azure and any
subclass are covered): Azure's Responses API requires api-version >=
2025-04-01-preview.
- An explicit use_responses_api still always wins; a lone explicit
output_version is preserved while use_responses_api: true is still injected,
so gpt-6 no longer silently falls back to /v1/chat/completions.
- The default injection is logged so operators can trace the /responses switch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(models): treat env-var-wired gateways as custom endpoints for gpt-6
Addresses the remaining review findings on #6863:
- A gateway wired purely via OPENAI_BASE_URL/OPENAI_API_BASE (the openai SDK
fallback when no base_url is passed) is now treated like an explicit
endpoint, so a gpt-6 model behind an env-var-wired gateway is not silently
re-pointed at <gateway>/responses. The known api_base-subclass blind spot is
documented in the comment block.
- Regression tests pin the env-var endpoint gate and the explicit-wins
precedence (an explicit use_responses_api still wins on a custom endpoint).
* test(models): isolate Responses routing from endpoint environment
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(mcp): skip tasks a cancel released while the poll claim was in flight
claim_due_tasks selected due rows with FOR UPDATE SKIP LOCKED, then set
the lease through an ORM flush that updates by id alone. FOR UPDATE is a
no-op on SQLite, so a request_cancel that committed between the SELECT
and the flush had its cleared poll lease written back. The cancellation
worker only claims rows without a live lease, so the remote cancel waited
for mcp_tasks.lease_seconds (120 s by default), and the task was polled
once more for a result apply_snapshot then discarded.
The claim now selects candidate ids and leases each one with a
conditional UPDATE that re-checks the claim predicates (pollable status,
no cancel request, due next_poll_at, no live lease), returning only the
rows it actually leased, the same atomic fence release_claim already
uses. On Postgres the row lock still orders the two writers.
* docs(changelog): reference #6859 for the MCP task claim fence
* docs(mcp): note that a short poll claim batch is not topped up
* fix(knowledge): scope ragflow link cleanup to its native citation namespace
budget_source_artifact cleaned a delegated task summary with two regexes that
matched any #knowledge-... destination. When a task result carried both the
ragflow-native contract (#knowledge-<id>-<n>) and the unified contract from the
retrieval refactor (#knowledge-source-<id>), those regexes also flattened the
unified citation to plain text. budget_source_artifact only rebuilds the native
knowledge_sources, so the now-linkless unified evidence then failed the
prune_sources substring check and retrieval_sources was emptied. The native
citations in the same result survived, so a mixed result lost attribution for
its unified sources while keeping it for ragflow ones, even with budget headroom
to spare.
Anchor both regexes to the native namespace with a (?!source-) lookahead so they
leave #knowledge-source-<id> for prune_sources to reconcile downstream. Native
ids are 32-hex and never begin with "source-", so native link cleanup is
unchanged.
Add a regression that drives the real middleware entry point on a mixed-contract
task result and asserts the unified citation stays clickable and its
retrieval_sources record survives alongside the native one.
Signed-off-by: CorgiBoyG <111257566+CorgiBoyG@users.noreply.github.com>
* fix(knowledge): remove dangling citation fragments from task summaries
---------
Signed-off-by: CorgiBoyG <111257566+CorgiBoyG@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(llm-error): retry provider errors reported inside a 200 stream
An OpenAI-compatible gateway that answers 200 early, to keep a slow
request alive with SSE keepalives, reports a later provider failure (a
504, say) as an SSE error event. The openai SDK raises that as the base
APIError, with no status code and no response, so _classify_error found
no status, no transient class name and no busy text, and called it
generic: the run failed on its first attempt.
An error with no status now takes an effective status from its own
contents, and the first source that yields one decides: a numeric code
on the error, or a numeric code, status or status_code in its body or
the body's error object; an explicit status in its message ([504],
HTTP 502, status 503); or type server_error (500). Its busy check also
reads each run of _, - and whitespace as one space, so rate-limit and
RATE_LIMIT_EXECUTION_TIMEOUT match "rate limit". Retry budget, backoff
and circuit breaker then treat it as the equivalent HTTP error. Errors
that carry a status are classified exactly as before, and the quota,
auth and burst-rate checks still run first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(llm-error): note how status-less errors are classified
Add one sentence to item 9 of the middlewares guide, and an llm entry
under Fixed in both changelogs, for the retry of provider errors
reported inside a 200 stream.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(llm-error): pin the in-band status source order and leftmost match
Two rules of _extract_in_band_status had no test: a numeric status
field decides before the message is read, and within the message the
leftmost in-range status wins. Either rule could be inverted with every
test still green. Add one stays-generic case for each: code 400 with
"[503]" in the message, and "HTTP 400 after [503]".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(llm-error): do not read an array index as an in-band status
The bracketed alternative of the in-band status regex had no lookbehind,
so `messages[502]` or `tools[0][503]` matched and their number decided
the error's status. The bracketed form now starts with the lookbehind
`(?<![\w\]])`: a `[` directly after a word character or a `]` opens an
index, not a status. The `http` and `status` alternatives are unchanged;
`[504]: ...` in running text still counts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(llm-error): let an in-band status preempt the separator busy check
The separator-normalised busy check ran for every status-less error,
even when the in-band lookup had already found a status: "rate-limit
header malformed" with `code: 400` was retried as busy, while HTTP 400
is not. It now runs only when no source yields a status, so an in-band
4xx decides like an HTTP one. The same text with no status anywhere
still counts as busy.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(llm-error): let only 4xx and 5xx values decide the in-band status
The in-band lookup accepted 100-599 values from its first two sources,
so a 1xx-3xx status could decide the classification: `HTTP 200` in a
message whose server_error type means 500, or `code: 200` alongside
`status: 503`. Only 400-599 now count: `_as_http_status` skips a
smaller value as it skips a non-numeric one, so the candidate loop
moves on, and an out-of-range message match is skipped so the scan
continues. An error that carries a status stays as it was, and the
server_error source is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(llm-error): describe the narrowed in-band status rules
The guide, the CHANGELOG and the CHANGELOG_zh now describe the three
narrowed rules: only a 4xx or 5xx value decides, an index such as
`messages[502]` is not a status, and the separator-matched busy check
runs only when no source yields a status. Only the sentences that
describe the affected forms changed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(llm-error): pin the remaining in-band status guards
Cover the nested error.status_code field, a code above 599 that yields
to a later status field, a code written in non-ASCII digits, and an
in-band 4xx whose plain busy text still decides, as it does for an HTTP
4xx. These pin current behaviour; no code changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(llm-error): say which in-band fields are read on the error itself
Only `code` is read on the exception; `status` and `status_code` are
read in its body and in the body's `error` object.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(changelog): link the in-stream retry entry to #6897
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(llm-error): require a separator and ASCII digits in an in-band status
The message regex read `status503` and `statuscode503` as a status
because the separator after `status` was optional, and it read
Arabic-Indic or fullwidth digits because `\d` and `int()` accept every
Unicode decimal digit. A status-less error with such text was retried
as a 503.
The `status` form now needs spaces or tabs, or a `:` or `=`, before
the number, and all three forms read only ASCII digits.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(llm-error): pin the retry of an in-stream error after partial output
An `error` event that follows content chunks is retried like one that
comes first: the whole call re-runs and its result holds only the new
answer, while the tokens the first attempt already streamed to the
callbacks stay delivered, as with a mid-stream `ReadError`. The new
test pins that outcome, and a comment in `_classify_error` notes it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Handle transient errors based on status codes
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* fix(slack): accept file_share events and download inbound files
Slack was dropping every Events API message with a subtype, including
user uploads (subtype=file_share). Allowlist file_share, attach event
files to the inbound message, and download private URLs in receive_file
with the bot Bearer token (bytes via INBOUND_FILE_CONTENT_KEY).
Fixes#6853
* fix(slack): tighten inbound file download guards
Address review on #6855: allowlist https files.slack.com before Bearer
fetch, cap aggregate in-memory bytes per file_share message, report
Content-Encoding refusals as download failed, and share one
_web_client_for_connection helper for inbound/outbound.
* fix(slack): satisfy ruff format and AGENTS.md size gate
Reformat test_slack_file_share.py and drop the AGENTS.md file_share
note so channels/AGENTS.md stays under the 49152-byte hard limit
(file was already near the ceiling without it).
* fix(lark): retry the Windows install lock instead of failing after ~10s
msvcrt.LK_LOCK gives up after roughly ten seconds and surfaces contention
as Permission denied, while a real install (download + setup of the
lark/sandbox CLI) can hold the lock far longer. Retry the non-blocking
acquisition on the Windows fallback branch so concurrent Gateway workers
serialize instead of failing, mirroring the extensions manager's lock
introduced in #6020. The POSIX flock branch is unchanged.
Closes#6879
* test(lark): inject msvcrt stub with raising=False for Linux runners
The regression test patches the module-level ``msvcrt`` attribute, which only
exists on Windows (the POSIX branch imports ``fcntl`` instead), so
``monkeypatch.setattr`` with the default ``raising=True`` raised AttributeError
on Linux CI (backend-unit-tests shard 4).
Pass ``raising=False`` so the stub is injected on POSIX runners: ``fcntl`` is
patched to ``None`` first, and ``_exclusive_install_lock`` then takes the
Windows fallback branch against the fake. Windows behavior is unchanged.
Scope: backend/tests/test_lark_cli_install_lock.py, 1 line.
Verification: pytest tests/test_lark_cli_install_lock.py on Windows
(Python 3.12, pytest 9.0.3) -- 3 passed incl. the real-contention case;
Linux CI rerun is the authoritative check.
* fix(lark): harden and share advisory lock retries
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
`web_search_tool` skipped non-object entries but returned the filtered list
even when it was empty, so a non-empty `results`/`entries`/`articles`
container holding no objects fell through to "No results found". That tells
the agent the query legitimately matched nothing instead of surfacing the
malformed payload -- the failure mode the sibling providers already contain
(brave #6550, groundroute #6603, tavily #6645, sofya #6719): a 200 body
wrapped around an upstream error page ends a research loop as "no hits".
Extract `_response_rows`, which treats a missing or null container as an
empty search and a container that is not a list, or a non-empty list holding
no objects, as the provider's format error, keeping non-object entries
skipped in order so valid rows survive. Both call sites (all three verticals
share one helper) now return the existing structured error.
RED/GREEN: the 7 new cases fail on main with "No results found" and pass
with the fix; test_serply_tools.py 34 passed, the five provider suites
(serply/serper/tavily/sofya/tencent_wsa) 284 passed, ruff check and format
clean.
* fix(discord): accept inbound attachments and file-only messages
Empty content no longer drops messages that have attachments. Capture
CDN descriptors on inbound, download in receive_file via Bot auth, and
hand bytes through INBOUND_FILE_CONTENT_KEY like Slack/Telegram.
Closes#6856
* fix(discord): gate bot auth and share inbound file helpers
Address review on #6857: attach Bot Authorization only for Discord CDN
hosts, report Content-Encoding refusals as download failed, and hoist
filename/size helpers into app.channels.inbound_files (Telegram delegates
to the same module).
* fix(discord): sort telegram imports for ruff I001
CI lint-backend failed on unsorted imports in telegram.py after the
shared inbound_files helper was introduced.
* style: ruff-format discord inbound attachments test
* fix(discord): show the enforced attachment download limit
---------
Co-authored-by: Li-john1021 <Li-john1021@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(channels): keep IM runs alive when the creating request disconnects
The channel manager holds a `runs.wait` / `runs.stream` request open for
the whole run, and that request dies with the Gateway instance that
received the IM message: a leader hand-off, a rolling restart, a crash.
The Gateway cancels a run whose creating request disconnects unless the
request asked for `on_disconnect: continue`, so every IM run in flight
was cancelled on failover.
Channel runs now ask for `continue`, like scheduled-task and MCP-task
runs already do. The run finishes on the Gateway executing it and its
thread state is persisted, so the answer stays readable in the web UI
even though the IM reply is lost with the old transport. The key is set
after the fire-and-forget branch because the SDK's `runs.create` has no
`on_disconnect` parameter.
Docs: CONFIGURATION.md and the Helm/deployment guides no longer list
"failover cancels IM runs" as a known limit; IM_CHANNEL_CONNECTIONS.md
and the channels AGENTS.md describe the policy.
* docs(changelog): link #6876
* fix(channels): keep the inbound dedupe key once a run may be executing
With `on_disconnect: continue` a run outlives the manager worker that
requested it, but the worker loop released the inbound dedupe key on
cancellation, so a provider redelivery on the replacement instance
admitted the same event again and executed the turn twice (review on
#6876).
Each inbound message now carries an admission record: `requested`
flips before the run request leaves the manager, `confirmed` once the
Gateway proved it admitted the run (a run id, a stream frame, a
completed response). The generic release paths (worker cancellation,
worker/handler exceptions, a broken stream) keep the key when the run
may continue: after confirmation, on cancellation after the request
was sent, and on post-request transport failures (read timeout, read
error, protocol error). Pre-request failures, Gateway rejections
(busy thread, stale pin, status errors, connection failures) and the
stream's terminal `error` frame still release it so the redelivery
can retry.
* fix(persistence): compare safe_add_column drift as the database spells it
safe_add_column's drift check compared the model's generic type and
default spellings (String -> STRING, BigInteger -> BIGINTEGER,
server_default="0" -> "DefaultClause('0', ...)", sa.false()) with the
reflected dialect spellings (VARCHAR, BIGINT, Postgres TIMESTAMP, '0').
Only JSON/JSONB ever matched, so every column a revision found already
present was reported as drift:
- any database created before 0023 logged a runs.change_seq warning on
upgrade, because 0025 re-runs 0023's safe_add_column;
- a schema whose head stamp was lost (the empty branch commits create_all
before stamping) logged 29 warnings on SQLite and 36 on Postgres, burying
the real TEXT-vs-JSON drift the check exists to surface.
Render both sides through the migration connection's dialect before
comparing: types via TypeEngine.compile (parameters stripped, the
Postgres time-zone suffix kept), defaults by unwrapping DefaultClause,
compiling SQL expressions and stripping one layer of string quoting.
Types the dialect cannot render (SQLite NullType) stay "missing info".
* docs(changelog): reference #6865 for the drift comparator fix
* fix(persistence): keep an empty-string default distinct from no default
Stripping one layer of quotes turned a reflected DEFAULT '' into "", and
`return text or None` then collapsed it to None. A desired
server_default="" (passed as a bare "") also normalized to None, so
_check_column_drift stopped warning when a column had no default but the
model wanted '' (or the reverse).
Render a plain-string DefaultClause argument as the quoted literal the DDL
emits, and return the unquoted value even when it is empty. None now means
only "no default".
On Windows, two threads/processes racing to create a not-yet-existing
lock file with an append open can transiently fail on open() itself with
PermissionError (EACCES), before the byte-range lock is ever attempted.
The open was outside the retry loop, so the error surfaced from
load()/save() instead of being retried. Move the open inside the loop and
close the handle on any OSError before retrying.
* fix(buzz): page a saturated history instead of skipping the oldest
buzz-relay serves at most RELAY_HISTORY_LIMIT (2000) stored events per
subscription, NEWEST-FIRST, even when the filter carries a `since`. A channel
that banked more than the cap across one disconnect therefore had its OLDEST
unread messages neither sent nor marked: the live page delivered the newest
2000, the watermark advanced past them, and the rest were lost -- the one
remaining path in this connector that could skip a message outright.
A chat/backlog page's EOSE now drives a descending `until` walk. When a page
comes back AT the cap it may have older matches held back, so the connector
re-asks with `until` set to that page's oldest `created_at` -- on a SEPARATE
`buzz-backlog-<uuid>` subscription id, never the live one -- and repeats until
a page comes back under the cap or reaches the `since` the live REQ was sent
with, bounded by MAX_BACKLOG_PAGES (50, warned when hit). Delivery is charged
to the subscription that carried it before any gate, so a page's saturation is
measured against what the relay sent, not what the allowlist/mention gates kept.
The walk is strictly additive: the live subscription, its filter, and the
watermark are never touched; a CLOSED for a backlog page only ends that walk;
re-opening the live subscription closes any superseded page rather than
orphaning it; and every failure simply leaves the pre-existing behaviour for the
next reconnect to retry.
* fix(buzz): skip the backlog walk when the channel has no read floor
Reviewer willem-bd noted the descending-until walk has no semantic stop when
`floor is None`: that is exactly a channel's first connect (no stored
watermark) and again after a MAX_CACHED_CHANNELS eviction of its cursor. On
those connects the walk would fetch up to MAX_BACKLOG_PAGES * RELAY_HISTORY_LIMIT
events per channel instead of the newest 2000 the connector used to take.
Require a floor before opening any backlog page. The backlog-loss case this walk
exists for always has a watermark (the cursor survives reconnects and restarts),
so it always has a floor; first connect and an evicted cursor keep the previous
behaviour. Covers the skip with a new test, retargets the page-bound test to a
real walk above its floor, and documents the floor requirement.
* fix(buzz): stop orphaned and stalled backlog walks
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(deploy): exec the gateway server so SIGTERM reaches uvicorn
The production compose command, both CMDs in backend/Dockerfile and the
Helm chart start the Gateway as `sh -c "cd backend && ... uv run ..."`.
dash forks uv and stays PID 1 without a SIGTERM handler, so the kernel
drops the stop signal: uvicorn never shuts down, the lifespan teardown
(memory flush, run drain, service stops) never runs, and every stop waits
out the grace period and ends in SIGKILL (exit 137).
exec the server in every production launch path, as
docker/dev-entrypoint.sh already does, and give the compose gateway
`init: true` so orphaned grandchildren are still reaped once sh is no
longer PID 1. A regression test pins exec in each launch path.
Fixes#6835
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(changelog): link the gateway SIGTERM entry to #6836
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(gateway): decouple dependency sync from exec launch form
* docs: compact startup guidance to pass inherited size limits
* docs: trim inherited startup guidance after main merge
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(scripts): feed config-upgrade inline python on stdin, not argv
On Windows setups where python/uv resolve through shim layers that
reparse arguments with cmd.exe (e.g. pyenv-win), the multi-line
`python -c "..."` payloads in config-upgrade.sh get mangled mid-flight
(observed as `IndentationError: unexpected indent` on a stray
`|| goto :error` line) and `make dev` fails before the Gateway starts.
Feed both inline scripts on stdin instead, which is safe across every
spawn chain, and load the checkout .env explicitly inside the resolver
probe: the default load_dotenv() discovery walks up from cwd/__file__,
which a stdin-fed script (`python -`) cannot satisfy. Pin both with a
static regression test.
* test(config): isolate upgrade fixtures from inherited dotenv selectors
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>