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.
`_parse_results` skipped malformed page entries but returned the collected
list even when it was empty, so a non-empty `Pages` container holding no
usable page 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), and the rule the
tavily changelog entry already lists `tencent_wsa` as applying.
A missing or null `Pages` stays an empty search, a non-list container keeps
the existing structured format error, and individual malformed entries in an
otherwise valid container are still skipped so the valid rows survive. Only
the non-empty-container-with-no-usable-page case now returns the provider's
format error and logs the container, never the query or payload values.
RED/GREEN: the 5 new cases fail on main with "No results found" and pass with
the fix (stash-compared source-only); test_tencent_wsa_tools.py 33 passed,
the five provider suites 281 passed, ruff check and format clean.
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* 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>
* fix(sandbox): append the Exit Code: 124 marker on E2B/BoxLite bash timeouts
When a bash command exceeds its timeout on the E2B or BoxLite providers,
the SDK raises (TimeoutException / TimeoutError) and the generic except
returned a bare "Error: <message>" with no exit marker; a bridge-future
timeout on BoxLite stringifies to an empty message, so the model saw just
"Error: ". Local, AIO, and Tenki all end timed-out output with the
"Exit Code: 124" marker that _truncate_bash_output preserves and the
acceptance-checklist harvest parses to recover the real shell status.
Catch the timeout exceptions ahead of the generic handler in both
providers and return the Tenki-style "Error: command timed out" +
"Exit Code: 124" ending. With #6782 forwarding bash_command_timeout to
every provider, this path is now a first-class configured behavior.
OpenSandbox routes timeouts through the same generic except, but its SDK
is an optional extra whose timeout behavior I could not verify locally,
so it is left out of this change.
Fixes#6847
Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
* test(boxlite): clarify bridge timeout coverage and SDK failure limits
---------
Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(persistence): retry feedback and conversation upserts that lose a race
FeedbackRepository.upsert and ChannelConnectionRepository.set_thread_id
looked up the row and inserted it when missing, with no conflict
handling. Two first writers for one key both missed the lookup, and the
second INSERT violated uq_feedback_thread_run_user or
uq_channel_conversation_connection_external: a 500 for the second of two
concurrent first ratings, and a failed message when a conversation's
first message raced /new.
Both now catch IntegrityError, roll back and retry, so the losing writer
re-reads the committed row and updates it, as upsert_connection already
does. A retry rather than ON CONFLICT keeps the NULL-user feedback path
updating its single row, since a NULL key never conflicts.
* docs(changelog): reference #6852 for the upsert race fix
* test(persistence): pin upsert retry exhaustion and share the miss-select double
Both retry loops now have a test that makes every SELECT miss against a
pre-seeded row, so all attempts hit the unique key: the original
IntegrityError must propagate after exactly _UPSERT_MAX_ATTEMPTS
attempts and the stored row must stay unchanged.
The "first SELECT misses" session double lived in four copies (one per
retry test, including upsert_connection's). It now lives once in
tests/_repository_race_helpers.py, with a misses count so the same
double drives both the single lost race and exhaustion.
* 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(agents): bound viewed image context per model request
Keep recent image payloads within a count and byte budget, and move re-viewed paths to the newest position. Add focused regressions for budget enforcement and state ordering.
* test(agents): verify cumulative image budgets and re-view recovery
* fix(agents): clarify image omission and read-attempt limits
* docs(middleware): shorten image context guidance for CI budget
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(sandbox): restore saved thread files when creating E2B sandboxes
After an E2B sandbox expires between turns, a replacement starts without
the thread files already saved by the Gateway, so later reads fail.
Restore owner-scoped workspace, uploads, outputs and ACP files before a
new sandbox becomes available. Keep live sandbox reuse unchanged, share
the existing upload budget, and discard incomplete sandboxes on restore
failure. Skip links and unpublished temporary files during restoration.
Add offline regressions for release, expiry and recreation, sync and async
acquisition, owner isolation, live reuse, upload limits and failure cleanup.
* fix(sandbox): make thread restore failures actionable
* fix(sandbox): use neutral upload limit diagnostics
---------
Co-authored-by: conghua1013 <16472796+conghua1013@users.noreply.github.com>
* fix(subagents): release batch executions the worker stops supervising
_execute_item cleaned up its execution in a finally block, but the
executor registry only removes terminal entries. When stop() cancelled
an item task, the subagent was still running at that point: the
cancellation request only signals it, so cleanup kept the entry, and
nothing removed it once the run turned cancelled. Each in-flight item
left its SubagentResult, transcript included, in the process-wide
registry for every SDK SubagentRuntime start/stop cycle.
A repository error while polling took the same exit without even
requesting cancellation, so the item was failed or requeued while its
subagent kept running unsupervised, and a retry could run it twice.
On either exit, cancel a run that is not terminal yet and remove its
entry from the persistent subagent loop once it stops. Cancellation is
cooperative, so the wait is bounded by the item's lease, after which
another worker owns the item and the entry is removed regardless.
* docs(changelog): reference #6785
* fix(subagents): log a scheduled batch release that fails
A concurrent future never reports an exception nobody retrieved, so a
release that raised on the subagent loop leaked its registry entry with
no log trace. Log it at error level with the execution id, as the task
tool's deferred cleanup already does.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(mindie): degrade unserializable tool args to str() in XML replay
The XML parser's literal_eval fallback puts values json.dumps rejects
(e.g. Python sets from set-literal arguments) into AIMessage tool_calls
args. Replaying that message from history hit the unguarded json.dumps
in _fix_messages' args join, raising TypeError and failing the whole
request — and every later turn, since the poisoned message rides the
history. Mirror the tool-result json block handling already in the same
function: degrade the value to str() so one unserializable argument
cannot fail the request.
* fix(mindie): reject non-finite floats in literal sets and tuples
* fix(mindie): validate literal dictionary keys recursively
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(sandbox): surface missing-file AIO reads as actionable errors
When read_file targets a nonexistent file, the AIO server returns an error
envelope with data: {}, which the agent-sandbox SDK cannot parse into its
required FileReadResult fields. The resulting pydantic ValidationError text
became the model-visible tool result ("Error: 2 validation errors for
ResponseFileReadResult ..."), so the agent could not tell the file simply
does not exist and kept retrying or "fixing" nonexistent problems.
Catch ValidationError in AioSandbox.read_file and return an explicit
"Error: file not found or unreadable: <path>" instead. The branch is logged
at debug level: a missing file is an expected outcome, since
ReadBeforeWriteMiddleware probes every new-file write before allowing it, and
the previous error-level logging produced noisy gateway logs on that benign
path. Other exceptions keep the error log and the generic error string.
Fixesbytedance/deer-flow#6833
* docs(aio): align regression test reference style
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(models): tolerate null template keys in the legacy thinking disable path
`ModelConfig.when_thinking_enabled` is a plain `dict | None` with no
per-key validation, so a template key left blank in config.yaml
(`extra_body:`, `thinking:`) loads as an explicit None. The legacy
disable path read those keys with `effective_wte.get(key, {})`, whose
default only applies to an absent key — so it called `.get` on None and
every create_chat_model for the profile crashed with
AttributeError: 'NoneType' object has no attribute 'get', on the
default thinking_enabled=False path (title generation, summarization,
subagent and lead-agent builds).
Guard the three reads with the same `or {}` idiom the sibling accesses
in this module already use (`_infer_dialect`, the contract path), so a
null value falls through the dialect heuristics like an absent one and
the legacy path matches the contract path, which already tolerates the
same profile shape.
* fix(models): tolerate null nested thinking templates
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* feat(mcp): add per-server discovery cache scaffolding
Introduce the per-server tool cache entry, canonical parsed-config snapshots on the applied revision, and the shared _binding_matches reuse predicate. Identity reuse is object-identity based on the pool's current binding (no value equality); snapshots and ownership handles stay out of repr. No selection, eviction or publication is wired yet.
* feat(mcp): selectively evict stale per-server tool cache entries
Evict per-server discovery results during selective reconciliation, after the new binding epoch is installed, so unchanged servers keep their exact entry objects while changed, removed or re-bound servers (including lifecycle-token rotations) are dropped. Delegate the binding verdict to _binding_matches so reuse and eviction cannot diverge.
Clear _server_tool_cache on every full reset, and count retained entries in the reconciliation state predicates so a lingering entry can never let a later config change be silently skipped.
* feat(mcp): reuse unchanged per-server results during initialization
Initialize MCP tools from per-server discovery instead of a single full pass: select reusable entries under the claim lock, discover only the missing servers with get_mcp_tools_by_server, then publish atomically after re-validating every candidate binding against the pool's current binding via _binding_matches. Failed servers stay absent (never cached as empty), and superseded or partially bound candidates are discarded whole. Test fixtures now drive the grouped entry point.
* fix(mcp): close T4 review gaps in per-server cache publication
Address four findings from the adversarial review: treat retained entries as unverifiable when the applied baseline is None so an interceptor change still forces a full reset; count retained entries in the claim-time shared-reset predicate; refuse to publish a false-success empty cache when every enabled server failed discovery; and hoist the lazy imports inside the initializer's try/finally so an import failure releases the initialization claim.
* fix(mcp): sanitize cache failure logging and document per-server cache
The outer lazy-initialization handler now logs only the exception type, never the raw message or traceback, so a discovery error cannot print a resolved $VAR credential. The committed-writer planner mirrors the reader's no-baseline guard so retained entries are never reused under an unknown interceptor chain. Document the per-server tool cache (reuse, invalidation, failure handling, remote tool changes) in MCP_SERVER.md and mcp/AGENTS.md, and drop phase-staging wording from the new code and tests.
* docs(mcp): keep the agent-guidance chain under its hard limit
F2's cache notes pushed the effective AGENTS.md instruction chain 905 bytes past its 98304-byte hard limit, failing the agent-guidance CI job. Compress the selective-reconciliation bullet to its invariants (the full cache behaviour lives in docs/MCP_SERVER.md), and reword the total-failure comment so it no longer implies that build-invalid connections were attempted.
* fix(mcp): fence cached tools by interceptor chain
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(uploads): stop Windows replacement uploads from being skipped by the multi-link guard
NTFS never decrements st_nlink when one of a file's names is removed, so
every upload published through os.link(staging, dest) + unlink keeps
reading st_nlink == 2 on Windows. The non-exclusive replacement guards
then reject that destination forever, and DeerFlowClient.upload_files
turns the UnsafeUploadPathError into a log line - a replacement upload
is silently skipped (Closes#6820).
- Add _replacement_destination_is_hardlinked: POSIX-authoritative
st_nlink > 1, skipped on Windows, used only on the not exclusive
replacement path. The exclusive (first-claim) path keeps the strict
count check, as does the POSIX O_NOFOLLOW branch.
- Decide "published alias" in cleanup_stale_upload_staging_files by
inode identity against siblings (st_dev/st_ino via os.lstat) instead
of the link count; DirEntry.stat reports zeros for both on Windows,
matching the existing _staging_entry_stat note.
- Guard the POSIX-only st_nlink assertions in the affected tests and
mark the three inbound-attachment mode-bit tests POSIX-only, mirroring
the existing skipif in the same class.
* fix(uploads): restore the exclusive post-open link guard and harden the staging sweep
Address the three review findings on 5353715:
- Restore the strict post-open st_nlink != 1 check for exclusive=True
on the no-O_NOFOLLOW (Windows fallback) branch, which the previous
commit dropped there against what the PR description claimed. Only
the non-exclusive replacement path defers to the Windows-aware guard.
- Never match a degenerate st_ino == 0 in the sibling-inode sweep: on
FAT/exFAT or some SMB volumes every entry would look like a published
alias and in-flight uploads would be deleted at any age. Such volumes
fall back to the min_age guard only.
- Build one {name: stat} map per uploads directory before the entry
loop so the sweep scans each directory once instead of rescanning it
for every staging file (O(N) instead of O(N^2) lstat calls).
Tests: 407 passed / 27 skipped across test_uploads_manager.py,
test_uploads_router.py, test_upload_copy_publication.py,
test_upload_windows_staging_aliases.py, test_channel_file_attachments.py
(Windows 11, Python 3.13 venv). The three new regression tests fail on
5353715 and pass with this change.
* style(uploads): apply Ruff formatting
* fix(uploads): report staging directory scan failures
---------
Co-authored-by: sjr666666 <sjr666666@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(subagents): leave full results out of the polled batch item list
The item list endpoint returns only previews, but list_items selected
whole rows, so every page loaded and decoded each item's full report,
evidence JSON and prompt before discarding them. The workspace polls
this list every 2 seconds while a batch runs; with the default
max_result_chars a 100-item page held about 34 MB and blocked the event
loop for about 70 ms (170 MB and 300 ms at limit=500).
Defer those columns unless the caller asks for them, with raiseload so
an accidental read fails loudly instead of lazy-loading. A 100-item page
now peaks under 1 MB; responses are unchanged.
* test(subagents): pin the item list route to compact columns
Drive list_batch_items against a real SQLite repository so a route that
starts requesting full results fails, not only a repository change.
* docs(changelog): reference #6781
* test(subagents): scope the route test's seed update to its batch
wait_for_sandbox_ready polls a sandbox's health endpoint until ready or a
timeout-second budget elapses, but measured that budget on the wall clock
(start_time = time.time(), while time.time() - start_time < timeout). A
backward wall-clock step (NTP correction, VM resume, manual change) while
polling keeps the loop running far past timeout -- and this runs on the
sandbox acquisition path, so it pins the acquiring thread instead of giving
up -- while a forward jump ends the wait early and destroys a sandbox that
was about to become ready.
The loop also only checked the deadline at the top and then blocked on a
fixed 5s request timeout plus a fixed 1s sleep, so a single slow probe could
overshoot timeout by up to ~6s; on the 5s acquisition path (local_backend)
that roughly doubles the budget.
Rework the wait to a monotonic deadline that clamps both the per-probe
request timeout and the inter-probe sleep to the remaining budget
(min(5.0, remaining) / min(1.0, remaining)), making the sync twin a faithful
mirror of wait_for_sandbox_ready_async, which already does this on
loop.time().
Add two deterministic regressions: one advances a scripted monotonic clock
past the deadline while a fake wall clock steps backward (red on the old
wall-clock loop), and one asserts the per-probe timeout and sleep are clamped
to the remaining budget (red on the old fixed 5s/1s values).
Signed-off-by: CorgiBoyG <111257566+CorgiBoyG@users.noreply.github.com>
* fix(view-image): bound re-injected image context with per-request budgets
`viewed_images` only grows (#6825): every model call that follows a
`view_image` round re-encodes and re-sends every entry ever viewed, so a
long PDF-inspection thread pushes the request body past provider limits
(e.g. Anthropic's 32 MB) even though each single image is capped.
Admission now runs newest-first against two knobs on ViewImageMiddleware:
- `max_injected_images` (default 10)
- `max_injected_total_chars` (default 32M characters, so a single
max-size image stays injectable: 20 MiB source -> ~27 MB base64)
Over-budget entries keep their text bullet with a placeholder ("call
view_image again to re-view this image") and are never read from disk or
sandbox, bounding both the request size and the middleware's per-call
work. Existing threads with small image counts and sizes are unaffected.
Refs #6825
* fix(view-image): make re-viewing an omitted image actually rescue it
The placeholder told the model to "call view_image again to re-view"
an over-budget image, but the recovery advice could not work:
merge_viewed_images merged with `{**existing, **new}`, and updating an
existing key in Python preserves its original insertion position. The
admission pass walks `reversed(viewed_images.items())` purely by
position, so the re-viewed entry stayed at its old (oldest) slot, was
admitted last, and was omitted again — a wasted tool round.
merge_viewed_images now re-inserts updated keys at the end, so dict
order tracks recency and a re-viewed image is admitted first on the
next injection. Also fix the `_create_image_details_message` docstring,
which said entries are "admitted oldest-first" while admission runs
newest-first (content blocks are emitted oldest-first; the admission
decision is not).
Wiring the budget knobs into config.yaml stays a follow-up: both
production instantiation sites construct ViewImageMiddleware() with no
arguments today, and this PR keeps the surface minimal.
Tests: reducer unit tests for re-insertion order plus an end-to-end
regression that re-views an omitted image and asserts it is admitted
on the next injection (#6829 review).
* fix(mcp): keep resolved credentials out of discovery failure logs
A failed per-server MCP discovery logged the raw exception message and traceback. ExtensionsConfig resolves $VAR values before validation, so a discovery error can embed a resolved-credential request URL (for example an httpx error carrying a URL with userinfo). Log only the server name and the exception type instead, at both the per-server and whole-phase handlers, and pin it with a caplog regression test using a simulated credential.
* test(mcp): assert the exact retained server name when redacting logs
The redaction regression test asserted a bare "A" in the captured log, which any unrelated A would satisfy. Assert the full "server 'A'" fragment so the test actually proves the offending server name is preserved while the credential is not.
* test(mcp): assert structured discovery warning arguments
The redaction change made the per-server discovery warning a parameterized log call, so asserting the server name inside the raw format string no longer matches. Assert the structured arguments instead: the server name, the exception type, the fully formatted message, and the absence of exc_info.
The LLM error-handling middleware's circuit breaker stored its recovery
deadline as time.time() + recovery_timeout_sec and fast-failed while
time.time() < _circuit_open_until. That is a duration deadline measured on
the wall clock, so a backward wall-clock step (NTP correction, VM resume,
manual change) while the circuit is open holds the fast-fail window open far
past recovery_timeout_sec -- shedding every LLM call until the clock catches
up -- while a forward jump ends it early.
Measure the recovery window with time.monotonic() instead, matching the rest
of the harness (task_tool deadlines, batch lease renewal, guardrail TTLs,
secret-file settle windows, journal latency). The clock is held on the
instance so tests can inject a deterministic one. The adjacent Retry-After
math stays on time.time(): it computes target.timestamp() - time.time() for
an absolute HTTP-date, which is genuinely wall-clock.
Add two regression tests (an injected-clock recovery test and a backward
wall-clock-step test) that go red without the fix, and update the two
existing recovery tests to drive the breaker through the same injectable
clock seam instead of patching time.time.
Signed-off-by: CorgiBoyG <111257566+CorgiBoyG@users.noreply.github.com>
* fix(loop-detection): let a warned agent save collected results before its final answer
The warn-level loop messages told the agent to "Stop calling tools and
produce your final answer now." Subagents read this literally: one whose
task names an output file stops all tool calls, including the single
write that delivers its result, and finishes as "completed" with nothing
on disk.
Only the two warn-level texts change. They now ask the agent to stop
repeating the looping call and wrap up, first saving what it has
collected through a file-writing tool if its task asks for a file. The
per-tool warning names the looping tool. Thresholds and the hard-stop
path (which strips tool calls) are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(loop-detection): bound final-save recovery after warnings
* docs(middleware): keep inherited guidance within CI budget
---------
Co-authored-by: huangzz <110116@nd.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(llm-error): retry streamed 200-OK responses that carry no generation chunk
The non-streaming empty-generations glitch (IndexError) is already
classified as transient and retried. The streaming path reports the same
upstream glitch as ValueError("No generations found in stream.") from
langchain_core's generate_from_stream; it fell through to "generic" and
failed after one attempt regardless of llm_call.retry_max_attempts.
Classify that ValueError as transient so it uses the configured retry
budget. Unrelated ValueErrors stay generic.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* style(llm-error): keep comments adjacent to their constants
---------
Co-authored-by: huangzz <110116@nd.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(browser): publish captured screenshots and fetches atomically
_write_screenshot and _write_capture_output wrote the final path with a truncating
write and then returned its name, so a failure part-way through left a partial
capture that the caller reported as saved - and the leftovers then made the dedupe
helper pick a new name for the next capture. Publish through a temporary file plus
os.replace.
* fix(browser): preserve permissions on atomic capture publication
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(sandbox): decode cmd.exe output with the ANSI code page under UTF-8 mode
The cmd.exe branch left its pipe decoder to the `encoding=None` default of
`_run_windows_command`, which follows Python text-mode semantics via
`locale.getpreferredencoding(False)`. Under Python UTF-8 mode
(`PYTHONUTF8=1`, `-X utf8`, the Windows installer's UTF-8 opt-in, and the
default once PEP 686 lands) that call reports `"utf-8"`, while cmd.exe and
the children it spawns keep writing the host ANSI code page - so every
non-ASCII byte in cmd output collapses to U+FFFD, with no fallback path.
Measured on a real zh-CN host under `PYTHONUTF8=1` (`getpreferredencoding`
-> `utf-8`, `getencoding` -> `cp936`):
- through the `encoding=None` default: `'��� �����Z
'`
- pinned to the ANSI code page: `'你好 日本語
'`
This branch pins the cmd.exe pipe decoder to `locale.getencoding()`
(3.11+, repo floor is 3.12), the same selection the MSYS and PowerShell
branches already use for their fallback (#6727, #6548).
## Scope note
The `encoding=None` default itself is deliberately unchanged: it mirrors
Python's own `text=True` semantics and is pinned by
`test_windows_capture_matches_subprocess_text_mode_encoding`. Only the
cmd.exe branch - whose output is known to be the ANSI code page - overrides
it, the same way the MSYS and PowerShell branches already pin their own
decoders.
Follow-up to willem-bd's review note on #6727 (inline comment 4247281255)
flagging this path.
## Testing
- new (POSIX-runnable, mocked): the cmd branch passes
`encoding=locale.getencoding()` to `_run_windows_command`, verified under
a simulated UTF-8-mode host (`getpreferredencoding` -> `utf-8`,
`getencoding` -> `cp936`).
- updated: `test_execute_command_uses_cmd_command_mode_on_windows` now also
pins the new `encoding` kwarg.
- new (Windows; skipped when the console output CP does not match the ANSI
page - sibling tests can change it process-wide): real `cmd.exe /c echo`
roundtrip through `execute_command` returns the text intact (passes on a
zh-CN host when run standalone).
- full `tests/test_local_sandbox_encoding.py`: 40 passed, 2 skipped under
both `PYTHONUTF8=0` and `PYTHONUTF8=1` (Windows 11, Python 3.13.12, repo
venv); the text-mode contract test stays green in all three parametrized
modes (`locale-code-page`, `PYTHONUTF8`, `-X-utf8`). Red run against
unmodified main: both new/updated cmd-branch tests fail (`KeyError:
'encoding'`).
- `ruff check` + `ruff format --check` clean on both files.
* fix(sandbox): use the cmd console output code page
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(runtime): preserve canonical delta-history order in cache
CachedHistorySaver composed a parent checkpoint's channel writes from
CheckpointTuple.pending_writes in storage order, but the saver's
authoritative get_delta_channel_history orders writes by (task_id, idx).
On the in-memory backend pending_writes follows task-arrival order, so a
fan-out super-step (several parallel tasks writing one channel in a single
checkpoint) was materialized in a different order with the cache enabled
than with the bare saver, breaking the cache's transparency invariant.
Sort a checkpoint's channel writes by task_id in _channel_writes, restoring
the canonical (task_id, idx) order while preserving each task's internal idx
sequence. The sort is a no-op on sqlite and postgres, whose pending_writes
already arrive ORDER BY task_id, idx.
Add async and sync regression tests over a real InMemorySaver seeded with a
fan-out checkpoint whose writes arrive out of task_id order; both diverge
from the inner saver's native walk without the fix and match with it.
Signed-off-by: CorgiBoyG <111257566+CorgiBoyG@users.noreply.github.com>
* docs(runtime): clarify cached saver ordering contract
---------
Signed-off-by: CorgiBoyG <111257566+CorgiBoyG@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* feat(channels): add channels.leader_lease and gate it under multi-process signals
IM channels other than GitHub connect outbound to their platform. With more
than one Gateway instance each one opens that connection with the same bot
credentials: Telegram rejects the second poller with 409, Discord answers every
message twice and per-process ChannelManager state diverges. The multi-process
startup gate said nothing about channels; the only guard was a comment in the
Helm values.
Add the `channels.leader_lease` section (`enabled: auto|true|false`,
`lease_seconds`, `grace_seconds`; startup-only, registered in the reload
boundary) that the per-provider leader lease will read, validated by a Pydantic
model from the non-schema `channels` mapping. The gate now refuses
`enabled: false` under a multi-process signal while a non-GitHub provider is
enabled under `channels.*` or connectable through `channel_connections`, naming
the providers and the rollback; a malformed block is refused on every topology.
`auto` follows the multi-process signal, so a single instance is unchanged.
ChannelService strips the block before its per-provider loops and exposes it
as `leader_lease`; `ensure_ready_channels` only visits registered providers.
The two-Gateway harness overlay leaves the block alone. config_version 60 in
the example, the Helm chart and its README; docs replace the comment-only
"keep 1 replica" guidance with the gate.
* docs(changelog): link #6746
* fix(channels): stop recommending the unimplemented leader lease as a remedy
Review on #6746: the refusal told operators to switch channels.leader_lease
from false to auto/true "so one instance owns each provider's connection", but
the transport arbitration ships separately, so that change only bypassed the
clause and every worker still started the same bot.
The refusal now offers only the remedies that work today (a single instance, or
disabling the channels) and states that auto/true are reserved for the lease
that ships separately. A regression helper in the gate tests asserts the
remedies start with the rollback and that auto/true are never offered. The
config example, Helm values/README, CONFIGURATION.md, README, the deployment
guide (en/zh), the model docstrings and both changelogs describe auto/true as
reserved settings rather than an ownership guarantee.
* fix(channels): warn on an enabled unknown channel key; reword the changelog entry
Review on #6746: the registry guard in ensure_ready_channels skipped a
misspelled provider key (channels.telegam) silently, where it previously
reached _start_channel and logged 'Unknown channel type'. An enabled dict
block that is neither a registered provider nor leader_lease now logs a
warning that lists the registry.
The changelog entry claimed the chart and docs drop the "keep 1 replica"
guidance; they keep it (auto/true do not make several replicas safe yet) and
only stop relying on the comment alone.
* fix(dev): disable UI channel providers in multi-instance overlay
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(memory): read the complete scope in the substring search fallback
_search_substring iterated list_facts without paginating, so the
adapter-less fallback only ever saw the default page of 100 facts.
With 150 matching facts in a scope (max_facts allows up to 500),
memory_search returned 100 results for top_k=150 and silently dropped
every match past the first page; DeerMem.search keeps these results
whenever they are non-empty, so its uncapped fallback never ran. Read
the complete scope instead, as the scoped index rebuild already does.
* test(memory): drop in-page fact-100 from the past-page assertion
fact-100 was the last id inside list_facts' old first page
(``matched[0:100]`` in insertion order), so its presence does not
distinguish the fixed search from the pre-fix page; fact-101 and
fact-150 (plus the ``len(results) == 150`` check) carry the regression
signal. Keep a boundary note so the remaining ids are not misread.
* fix(memory): keep non-core fact categories instead of rewriting to other
_normalize_category rewrote any category outside CORE_CATEGORIES to
"other" and parked the original label in a write-only
categoryExtension field, so memory_add(category="personal") -- an
example label in the tool's own docstring -- persisted as "other" and
memory_search(category="personal") ("Only facts with this exact category
are returned") never matched it. get_memory documents kept reporting
"other" as well.
Keep the caller's label as the stored category so it round-trips
through search, documents, and the retrieval index.
* fix(memory): finish migrations interrupted across the category change
A pre-native-category Gateway committed a per-agent legacy JSON migration
as category="other" + categoryExtension=<label> and could stop before
deleting the legacy JSON (crash / failed unlink). The retained JSON holds
the label natively, so _migrate_locked compares "personal" against its
own committed "other"/"personal" alias, raises MemoryStorageCorruption,
and fails every subsequent read, reload and migration retry for that
agent (HTTP 500 at the Gateway) -- a recoverable state the previous
build recovered from.
Fold the legacy alias into _migration_equivalent's category comparison
("other"/<label> equals <label>/no-extension) so retries recognize and
complete recovery, while a genuinely different label still stops the
migration with the legacy file preserved.
Also drop a stale categoryExtension when a fact is normalized away from
"other": _normalize_fact deep-copies unknown fields through, so a legacy
fact updated to its native category kept surfacing the leftover
extension, contradicting the Gateway contract that the field is only
set "when category is 'other'".
Both regressions are pinned by replaying the historical writer in tests.
---------
Co-authored-by: BlueX888 <BlueX888@users.noreply.github.com>
* docs(agents): trim restated rationale to restore AG002 chain headroom
The agents/middlewares AGENTS.md chain crossed the 96 KiB AG002 hard
limit at 3f7a0769 (98441 > 98304 bytes); that push produced no lint-check
run, and later pushes compare equal to their base, so main stayed green
while a full-worktree check_agent_guidance run reports an error. Trim
restated rationale:
- backend/AGENTS.md: the Feishu button-card sentence restated semantics
app/channels/AGENTS.md already owns (originator-validated, one-time,
fail-closed clicks); keep the flag visibility and point at the module
guide.
- agents/middlewares/AGENTS.md: drop the membership-probe rationale
duplicated by the model_response.py paragraph, the ToolOutputBudget
ordering narration, and the lead-only append note restated by the
lead-only middlewares heading.
Chain: 98441 -> 98198 bytes (106 under the hard limit).
* docs(channels): own the Feishu card fail-closed invariant in the module guide
Review follow-up on #6759: the backend/AGENTS.md trim dropped two things
app/channels/AGENTS.md item 7 does not own - the fail-closed-across-workers
guarantee and the owning file's name - so the invariant left every
instruction chain and the pointer dead-ended one level above the
implementation. State both in item 7, paid by dropping the therefore-clause
restated by the single-active-owner invariant above it (the partial unique
index and the losing-writer retry already imply deterministic resolution).
channels/AGENTS.md: 49134 -> 49129 bytes (AG001 headroom 18 -> 23).
---------
Co-authored-by: xiaodu55 <xiaodu55@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(client): convert the captured upload source
* test(uploads): cover ACL sources and scope capture failures
* fix(uploads): preserve concurrent uploads from one captured source
Combine the embedded client's source capture with no-replace publication for
first-time filenames and Markdown companions. Hold one private capture across
collision retries, retain original descriptor metadata, and keep replacement
behavior for names present when the call starts.
Port concurrent-upload and staging-publication regressions from PR #6687.
Extend source-edit/removal and real XLSX cases to both publication paths, and
cover edits or removal during two filename collisions in ordinary and event-loop
calls. Document capture, retry, publication, and cleanup contracts.
Validation: related suites 469 passed, 24 skipped; full offline suite 28,788
passed, 488 skipped; blocking-I/O suite 245 passed, 1 skipped; Ruff, guidance,
and diff checks passed. Nine race/collision cases failed on the #6632 baseline.
Co-authored-by: haotianqi666 <250384991+haotianqi666@users.noreply.github.com>
* docs: keep agent guidance within CI budgets
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: haotianqi666 <250384991+haotianqi666@users.noreply.github.com>
* fix(sandbox): decode PowerShell native-child output with the locale fallback
Fixes#6646
## What
The Windows PowerShell branch of `LocalSandbox.execute_command` passed `encoding="utf-8"` to `_run_windows_command` with no `fallback_encoding`. The guarded console-UTF-8 preamble covers PowerShell's *own* output, but native children it runs (`python.exe`, CLI shims) inherit the pipe and keep writing the host code page, so the pipe decoder's `errors="replace"` turned every such byte into U+FFFD. This passes `fallback_encoding=locale.getpreferredencoding(False)` there, mirroring what the MSYS branch above already applies (#6548).
## Why
On Windows hosts whose ANSI code page is not UTF-8 (GBK on zh-CN hosts, cp932, cp949, cp1251, ...), non-ASCII tool results printed by native children reach the model garbled (#6646). PowerShell's own output stays correct because the preamble pins it to UTF-8, so the fallback only engages when a buffer is not valid UTF-8 — exactly the case where a native child wrote host-code-page bytes. Pure-ASCII and PowerShell-emitted UTF-8 buffers never touch the fallback, matching the tradeoff the MSYS branch already accepts.
## How checked
Reproduced on a real zh-CN Windows host (ANSI code page 936):
- Red on `main` (3f7a0769): a native `python.exe` child printing CJK text through `powershell.exe` / `pwsh.exe` returned `'\ufffd\ufffd\ufffd \ufffd\u0571\ufffd\ufffdZ\n'` — the new regression test failed on both shells:
```
FAILED tests/test_local_sandbox_encoding.py::test_windows_powershell_native_child_cjk_roundtrip[powershell.exe]
FAILED tests/test_local_sandbox_encoding.py::test_windows_powershell_native_child_cjk_roundtrip[pwsh.exe]
```
- Green with the fix: both pass, and the child's text round-trips exactly (`'\u4f60\u597d \u65e5\u672c\u8a9e\n'`).
- `tests/test_local_sandbox_encoding.py`: 34 passed, 1 skipped (Windows 11, CPython 3.13, `uv sync --frozen` environment). The two pre-existing PowerShell unit tests were extended so their `_run_windows_command` fakes accept the new `fallback_encoding` kwarg and assert it is the locale code page.
- `ruff check` and `ruff format --check` pass on both changed files.
* fix(sandbox): decode fallback must survive Python UTF-8 mode and mid-character captures
Follow-up to review feedback on #6727.
- Use ``locale.getencoding()`` (3.11+, repo floor is 3.12) instead of
``locale.getpreferredencoding(False)`` for both fallback call sites
(MSYS and the native-child guard): ``getpreferredencoding`` reports
``"utf-8"`` whenever the Gateway runs under Python UTF-8 mode
(``PYTHONUTF8=1``, ``-X utf8``), silently disabling the fallback and
leaving #6646 unfixed on those hosts. ``getencoding()`` deliberately
ignores UTF-8 mode and returns the code page the child's CRT used.
- After a failed strict UTF-8 decode, retry once without a trailing
incomplete multi-byte sequence, so a capture cut mid-character (the
10MB capture-cap boundary, or a timeout kill interrupting the child
mid-write) cannot cascade the whole buffer to the locale fallback.
Complete non-UTF-8 buffers still fail the retried attempt and reach
the fallback untouched.
- Tests: the Windows roundtrip probes now run under ``PYTHONUTF8=1``
with ``locale.getencoding`` pinned, so a green run proves the fallback
source ignores UTF-8 mode; added POSIX-runnable unit tests for the
cap-split and timeout-kill partial tails.
Test results (Windows host, ACP=936, repo venv via ``uv sync --frozen``):
``tests/test_local_sandbox_encoding.py`` 36 passed, 1 skipped, both with
``PYTHONUTF8=0`` and ``PYTHONUTF8=1``; ``ruff check`` and
``ruff format --check`` clean on both files.
* fix(sandbox): trim only on cut captures, preserving complete locale tails
Willem-bd's follow-up review pinned a real P2: on a cp1252 host the MSYS
branch receives b'caf\xe9' (a complete e-acute), the trailing E9 is trimmed
as an incomplete UTF-8 lead, and the ASCII prefix is accepted as valid UTF-8
- so the locale fallback never runs and the byte is silently dropped. No
capture cap or timeout is needed to trigger it.
Fix: the strict-retry trim is now authorized only when the capture may
actually have been cut mid-character - a byte-cap cut (signalled by the
capture's own truncated state) or a timeout kill (new mark_split_tail(),
called from _run_windows_command right after the process tree is killed). A
complete buffer, even one ending in a byte shaped like a bare UTF-8 lead,
now goes to the fallback with every byte intact.
Tests:
- new: cp1252 complete-tail roundtrip (red on b62ef75: returned 'caf').
- new: the same partial-shaped tail without a cut signal must fall back
intact instead of being accepted as a truncated UTF-8 prefix.
- new: POSIX-runnable _run_windows_command timeout integration test - the
child writes a partial UTF-8 sequence, hangs, is killed at the 1s
timeout; the trimmed prefix survives (this goes red if the kill path
forgets to authorize the trim, since the whole buffer cascades to the
fallback).
- updated: the timeout-kill unit test now marks the capture explicitly,
mirroring the real call site.
Evidence: Windows 11, Python 3.13.12 (repo venv), pytest
tests/test_local_sandbox_encoding.py -> 39 passed, 1 skipped under both
PYTHONUTF8=0 and PYTHONUTF8=1; ruff check + ruff format --check clean.
Red run against b62ef75: the three new/updated tests fail exactly as
described above.
* fix(sofya): report a format error for results with no usable objects
`_response_results` skipped non-object entries but returned the filtered
list even when it was empty, so a non-empty `results` array holding no
objects fell through to "No results found" -- telling the agent the query
legitimately matched nothing instead of surfacing the malformed payload.
Treat a non-empty list with no object entries as malformed, matching the
containment rule #6603 documents for this provider family (brave #6550,
groundroute #6603, tavily #6645, webz). Both `web_search` and
`web_fetch` share this helper, so both now report the provider's format
error. Missing, null, and empty `results` keep returning "No results
found", and mixed lists still keep their valid entries in order.
* test(sofya): cover mixed fetch result entries
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(uploads): convert the same source version the upload saved (#6600)
DeerFlowClient.upload_files copied the caller's file into the uploads
directory, then reopened the original path for Markdown conversion. A
save landing between the two reads left the saved upload at version A
while the companion registered version B, and resolve_companion still
accepted the mismatched Markdown.
Snapshot convertible sources into a private temp dir up front and run
both the upload copy and the conversion from that one read. The snapshot
keeps the #5611 invariant (conversion never reopens a name inside the
sandbox-writable uploads dir), preserves metadata via copy2, and keeps
the re-upload SameFileError rejection through an explicit same-file
probe before the snapshot.
Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
* fix(uploads): pin the snapshot copy to one held source descriptor
Review on #6621 found two gaps in the snapshot step: stat-by-name then
copy2-by-name split identity from content across two path resolutions,
and copy2 drops ACLs so a source readable only via ACL made the private
snapshot unreadable. Open the source once, fstat it for the same-file
rejection, and copyfileobj out of the held fd; restore the mtime from
that same stat so the upload keeps the source's timestamps.
Tests: the version-parity regression's injection now also fires when a
future regression reads the caller's path during conversion, and a real
macOS ACL case (mode 000 + read grant) uploads and converts cleanly.
Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
* test: catch upload conversion source regressions
---------
Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(skillscan): match fork bombs by shape, not spelling
`resource-fork-bomb` and the fork-bomb branch of `shell-destructive-command`
only matched the canonical `:(){ :|:& };:` rendering, so renamed or respaced
payloads produced no findings and passed the blocking gate.
Match the general shape instead - a shell function whose body recurses through
a pipe into a background job - keeping the pre-LLM static gate at least as
wide as the runtime sandbox audit middleware it backstops.
Closes#6651.
* fix(skillscan): bound the fork-bomb pattern and require self-recursion
Addresses the three review findings on #6689:
- Bound every quantifier in the shared fork-bomb pattern so a whitespace-free
blob costs O(n) instead of O(n^2) (measured ~0.15s at 200KB; the previous
spelling took ~5.5s at just 50KB).
- Require the function name to recur after the pipe (backreference), so a
benign pipeline such as `backup() { rsync -a src/ | logger & }` no longer
blocks as a CRITICAL finding.
- Keep both gates byte-identical again: skillscan's _FORK_BOMB_PATTERN and the
sandbox_audit_middleware fork-bomb entry, pinned by a parity test.
- The code comment now describes exactly what the pattern checks; pipe-less
self-backgrounding recursion and the spaced `|&` spelling are documented as
out of scope.
- New regression tests: near-miss pipelines in both suites, the parity
invariant, and a 200KB single-token scan-time ceiling.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(acp): resolve an absolute Windows agent command before spawning
A command configured as an absolute path without an extension -- npm's global
shim layout, e.g. C:\npm\mcode beside mcode.cmd -- was handed to the ACP SDK
unchanged and always failed: create_subprocess_exec applies no PATHEXT, so it
ran npm's extensionless shell script and died with WinError 193 (or raised
FileNotFoundError when only the .cmd exists). The separator guard from #6598
was too broad: only relative paths need to stay with the spawn, because their
meaning is owned by the ACP-workspace cwd, while an absolute path does not
depend on the cwd.
Absolute paths now go through the same shutil.which lookup as bare names: the
lookup searches the command's own directory and probes PATHEXT, and the
configured value is kept when nothing matches so the not-found remediation
still fires. Relative paths (bin/agent, ./bin/agent) still go to the spawn
unchanged.
Docs: README.md and backend/packages/harness/deerflow/tools/AGENTS.md now
describe the bare/absolute vs relative split.
AI disclosure: this change was drafted with AI assistance at my request, under
my direction and review.
* fix(acp): keep already-absolute lookup results unchanged
Review feedback on #6656 (willem-bd): os.path.abspath collapsed a `..` that
follows a symlink, so an absolute POSIX command like
`/srv/current/../shared/agent` (with `current -> releases/v1`) was rewritten to
`/srv/shared/agent` -- a path that either does not exist or names a different
launcher. The anchoring to the Gateway cwd exists only for relative PATH
lookup results, where shutil.which cannot know the cwd the spawn will use; an
already-absolute result needs no rewrite.
Regressions: a platform-independent stub-lookup test pinning the literal
parent-segment path, and a POSIX-only test that builds the symlink layout and
asserts the configured command is returned unchanged.
* fix(runtime): mark cancel takeovers with the orphan-recovery stop reason
A cross-worker cancel that takes over an expired-lease run writes error but
left stop_reason unset. `orphan_recovered` is the only signal that lets a
store-only SSE/`wait` consumer synthesize END after a heartbeat, because the
producer is known to be gone. Without it, a subscriber that outlives the
crashed owner waits forever for an END no surviving worker publishes; periodic
reconciliation ignores the row because it is already terminal.
Mark the takeover like periodic orphan recovery: claim_for_takeover already
accepts the stop reason and the reconcile path already passes it. The new
tests pin the takeover row's marker and prove a real takeover wakes a waiting
consumer; three existing takeover test doubles now forward the store argument.
* docs(gateway): keep guidance within the 48KB budget
The takeover stop-reason sentence pushed backend/app/gateway/AGENTS.md over its 48KB hard limit, failing agent-guidance and the backend guidance-budget test. Trim the sentence; the rationale stays in the manager.py comment.
* fix(gateway): complete streams after scheduler orphan recovery
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>