Commit graph

420 commits

Author SHA1 Message Date
haochen
b4614845f0
fix(web-shell): delete attachments from an empty composer (#13947)
* fix(web-shell): delete attachments from an empty composer

* fix: address review feedback for #13947

* fix: follow up review findings for #13947

* fix(web-shell): preserve draft boundaries during keyboard deletion

---------

Co-authored-by: Zhangchenhao-111 <285911435+Zhangchenhao-111@users.noreply.github.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
2026-10-16 18:23:26 +00:00
易良
808722d9d7
feat(web-shell): add side task archive controls (#14300)
Some checks are pending
Qwen Code CI / Classify PR (push) Waiting to run
Qwen Code CI / Test (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Lint & Static (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / web-shell E2E Smoke (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (macos-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (windows-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Integration Tests (no-AK, No Sandbox) (push) Blocked by required conditions
Qwen Code CI / Integration Tests (CLI, No Sandbox) (push) Blocked by required conditions
E2E Tests / Build for E2E (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 1/1 (push) Blocked by required conditions
E2E Tests / E2E Test (Linux) - sandbox:none - shard 1/1 (push) Blocked by required conditions
E2E Tests / E2E Test - macOS - shard 1/2 (push) Blocked by required conditions
E2E Tests / E2E Test - macOS - shard 2/2 (push) Blocked by required conditions
E2E Tests / E2E Interactive - OpenTUI renderer (bun) (push) Blocked by required conditions
E2E Tests / channel-plugin E2E (nightly) (push) Blocked by required conditions
E2E Tests / cron-interactive E2E (nightly) (push) Blocked by required conditions
E2E Tests / web-shell Browser Regression (push) Waiting to run
* feat(web-shell): add side task archive controls

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(serve): harden side task archive controls

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(integration): pin session_archive_idle_guard in the serve capability baseline

capabilities.ts registers session_archive_idle_guard and server.test.ts already
lists it, but the second baseline list in the no-AK integration gate was not
updated, so the deep-equal diff failed on exactly that one capability.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-conflict/jmv9k9mxh7h

* fix(web-shell): archive and restore side tasks in their owning workspace

The side-task archive/restore callbacks called the unscoped client, whose
routes resolve ids against the legacy-primary catalog. A task owned by a
secondary workspace answered notFound without any state migration, and
because both callbacks counted notFound as a successful removal the UI
cleared state anyway. Group each request by the task's workspaceCwd and
call through the workspace-qualified route (standalone tasks keep the
legacy path); notFound now surfaces as a per-session error and the task
stays in the catalog.

Regression pins: qualified routing for archive and restore with a
secondary-workspace task and a standalone task in one call, and a
notFound answer that keeps the tab and reports the session id; the three
pre-existing archive tests move to workspace-scoped spies.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(web-shell): address review follow-ups - separator, removal-set reset, overview batch archive

* docs(design): sync side-task archive design with workspace-qualified routing

Review pointed out that the design pair still described the pre-fix
contract: it listed not-found among the outcomes that leave the active
list, and never stated that archive and restore must be
workspace-qualified. That missing sentence is the same omission that
allowed the secondary-Workspace bug, so the docs now carry it.

Both languages state that each callback groups ids by the task's own
workspaceCwd and calls the workspace-qualified route, that ids with no
recorded workspace keep the legacy unscoped call, and that a notFound
answer now keeps the task listed and reports a per-session error instead
of clearing UI state for an operation the daemon never performed.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-10-17 00:05:57 +08:00
易良
a84dbff23e
feat(providers): add QwenCloud as the first /auth entry (#14073)
* feat(providers): add 千问AI平台 / QwenCloud as the first /auth entry

Adds QwenCloud as a new top-level provider group ahead of Alibaba
ModelStudio, following the product ruling in #14025: the two entries
coexist rather than one replacing the other, so saved ModelStudio
credentials, endpoints and model selections keep resolving to their
existing presets and no migration is needed.

Two new presets share the ModelStudio wizard shape (Access Method ->
Platform -> API Key -> models):

- qwencloud-standard  (pay-as-you-go, `sk-` / `sk-ws-` keys)
- qwencloud-token-plan (Credit subscription, `sk-sp-` keys)

Each offers China (千问AI平台) and International (QwenCloud) as an
explicit platform choice, so the UI language never decides the endpoint.
Coding Plan is not offered here — it is a ModelStudio product.

Endpoints were taken from the platform's own documentation and probed
before landing (all four return the DashScope `invalid_api_key` error
envelope):

- https://maas.qianwenaiapi.com/compatible-mode/v1
- https://token-plan.maas.qianwenaiapi.com/compatible-mode/v1
- https://maas.qwencloudapi.com/compatible-mode/v1
- https://token-plan.maas.qwencloudapi.com/compatible-mode/v1

The new hosts are added to the DashScope provider detection and the
web-search endpoint allowlist so the built-in `web_search` backend keeps
working; both new env keys are added to the no-AK CI gate's cleared-env
list. Group wiring is mirrored across the ink and OpenTUI dialogs, the
VS Code provider picker, and the serve catalog consumed by Web Shell.

Closes #14025

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): add QwenCloud auth strings to en and zh-TW locales

The new auth entries introduced in the previous commit were only added to
zh.js. Since en.js is the source of translation keys and both zh and zh-TW
enforce strict key parity, the i18n check failed with "Extra key in zh.js"
for all eight strings.

Add the same keys to en.js as identity mappings and to zh-TW.js with
Traditional Chinese values, so all three locale files have an identical key
set.

* test(cli): follow the QwenCloud row in the OpenTUI auth menu tests

The new first /auth entry shifts the main menu by one row, but the tests
addressed the sub-menus by position, so most flows landed a row early and
the region-height assertions still named the old first row.

* feat(providers): localize the QwenCloud /auth entry label per locale

The top-level /auth entry was the only mixed CJK/Latin label in the menu
("千问AI平台 / QwenCloud"), and because en/zh mapped that key to itself it
was effectively hardcoded mixed in every locale. Route it through i18n
instead: en and other locales show "QwenCloud", zh shows "千问AI平台",
zh-TW shows "千問AI平台". Locale-neutral surfaces (serve catalog, VS Code
provider picker) carry the single Latin brand; the Chinese brand stays
visible in the explicit platform step ("China — 千问AI平台") and the docs.
The step-title flowTitle now goes through t() so the localized brand also
appears in "… · Step 1/3 · …".

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(providers): register QwenCloud hosts in telemetry and reasoning classifiers

The QwenCloud MaaS hosts and env keys were registered in the two host gates
this PR edits, but two further independent classifiers of the same hosts did
not learn about them, and both misclassify the entry this PR makes the
first-position default:

- telemetry/gen-ai-provider.ts had no providerFromHostname match for
  maas.qianwenaiapi.com / maas.qwencloudapi.com and no PROVIDER_BY_ENV_KEY
  entry for QWENCLOUD_API_KEY / QWENCLOUD_TOKEN_PLAN_API_KEY, so
  resolveGenAiProviderName fell through to its `default: return 'openai'`
  arm. Every span and log record for a QwenCloud install was attributed to
  OpenAI, while the identical ModelStudio path resolved to 'dashscope'.
- core/reasoning-overrides.ts keeps its own inline hostname regex without the
  new hosts, so a user-authored partial reasoning override on a qwen* model
  behind a QwenCloud baseUrl selected 'qwen-chat-template' -> toggleOnly ->
  invalidReasoning, which captureLatestReasoning catches and warns. The
  override was silently discarded; the same declaration on
  dashscope.aliyuncs.com resolves to 'dashscope-effort'.

Also widen the SDK-side mirror DaemonAuthProviderCatalog.groups[].id to
include 'qwencloud', which packages/cli/src/serve/types.ts and
auth-provider-helpers.ts already emit as the first group, and derive
web-shell's AuthGroupId from that union instead of re-declaring it, so the
SDK type is the single source of truth. A second literal union in web-shell
is why nothing reddened in-repo while the daemon emitted a group id its own
published wire type declared impossible.

The new suffixes go inside the existing anchored (^|\.)...$ group, so a forged
maas.qianwenaiapi.com.evil.com cannot match while the legitimate token-plan.
subscription subdomains still do. Both env keys map to the existing
'dashscope' provider value rather than a new one.

The hosts stay literals in gen-ai-provider.ts rather than importing
QWENCLOUD_MAAS_HOSTS: that module is a zero-import telemetry leaf, and
dashscope.ts already imports core/reasoning-overrides at runtime, so importing
back would cycle.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmv8fwg2z5f

* test(providers): pin QwenCloud host classification and registry membership

Cover behaviour this PR added that no test observed:

- dashscope.test.ts: the QwenCloud MaaS pay-as-you-go and token-plan hosts
  are DashScope origins, plus dot-boundary lookalike negatives.
- web-search.test.ts: the same four hosts pass the web-search gate.
- qwencloud-standard/token-plan.test.ts: both presets are registered in
  ALL_PROVIDERS and QWENCLOUD_PROVIDERS, and the qwencloud access-method
  group excludes Coding Plan (replaces a constant-true id/label check).

Each new assertion is mutation-verified: dropping isQwenCloudOrigin from the
dashscope return, weakening its host match, dropping the QWENCLOUD_MAAS_HOSTS
spread, removing the two registry entries, or flipping codingPlanProvider
uiGroup each turns exactly the intended test red.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmv8mbxgn5t

* test(providers,cli): pin QwenCloud preset separation and the auth catalog group

Coverage for lines this PR itself added (review round 1: R1-21, R1-26, R1-15):

- qwencloudStandardProvider.ownsModel is cross-checked against its QwenCloud
  Token Plan sibling and against a foreign envKey on its own endpoint. The two
  presets share uiGroup, catalog and protocol bucket, so that envKey gate is
  the only discriminator; dropping it kept the suite green while
  applyModelProvidersPatch would claim a Token Plan install.
- buildAuthProviderCatalog gets its first test: the QwenCloud group is first
  and carries both access methods. A misspelled group filter stayed green.
- The zero-row main-menu paint test now names row 0, which this PR re-pointed
  from Alibaba ModelStudio to QwenCloud while only rows 1 and 2 stayed pinned.

Each assertion was confirmed load-bearing by one mutation that the
pre-existing tests did not catch.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmv8oh38g5y

* docs(users): sweep the /auth menu enumeration in overview and quickstart

This PR re-numbered the /auth menu to four options in auth.md and
model-providers.md, but overview.md and quickstart.md still described three
entries with Alibaba ModelStudio as the recommended one (review round 1:
R1-4). The doc set contradicted itself, and a quickstart reader holding a
QwenCloud key was routed to the ModelStudio entry, whose endpoints reject it.

Both pages now list QwenCloud first and reserve the "recommended setup" claim
for it, matching auth.md.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmv8oh38g5y

* fix(providers): validate daemon credentials and close QwenCloud follow-ups (#14302)

* fix(serve): validate API key format before persisting in daemon install path

The CLI, ACP and VS Code surfaces each call provider.validateApiKey
before persisting credentials. The daemon route (POST
/workspace/auth/provider) did not, because validateApiKey is a function
on ProviderConfig and cannot be serialized to the wire — so no client
of this route can apply the check either.

Without this guard a key the provider's own entry rejects (e.g. a
Token Plan sk-sp- key aimed at the Standard API Key entry) gets
persisted silently, and the user only discovers the mismatch at the
first authenticated call.

Fix: call validateApiKey in the route after protocol validation and
before invoking installAuthProvider. Return 400 with code
'invalid_api_key' on failure, matching the other three surfaces.

Closes #14257 (item S3 / R2-13).

* fix(providers): address #14257 deferred Suggestions (D1/D2, T1/T3-T9, I1, P5, S6/S7)

Batch fix for actionable items from #14257:

Docs:
- D1: Add settings.json example to QwenCloud headless setup section
- D2: Add QwenCloud rows to web-search.md auto-activation table

Tests:
- T1: Pin flowTitle locale resolution in both dialog suites
- T3/T4: Assert QwenCloud sub-menu contents (Token Plan + Standard, no Coding Plan)
- T5: Add missing token-plan.maas.qwencloudapi.com endpoint to dashscope.test.ts
- T6: Add QwenCloud host tests to reasoning-overrides.test.ts
- T7: Assert providerState in qwencloud-standard preset test
- T8: Test ownsModel baseUrl conjunct independently of envKey
- T9: Test providerMatchesCredentials URL gate independently of envKey
- S7: Assert QwenCloud group in VS Code AuthMessageHandler picker

i18n:
- I1: Add validateApiKey messages to all 9 locale files

Product:
- P5: Fix qwen3.7-plus modalities to include video (matches alibaba-token-plan)

Code:
- S6: Update docstring enumerations in dashscope.ts and web-search.ts

Remaining items need human decisions: P1-P4 (wording/labels), S1-S5 (API surface),
T2 (ink suite gating is a pre-existing harness pattern).

* fix(auth): translate provider titles at render time

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(providers): handle optional preset models

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(providers): pin Token Plan providerState and ownsModel baseUrl leg

Addresses four review Suggestions on this PR:

- R1-10 (#14257 T7): the providerState stamp was asserted for the Standard
  preset only. Token Plan has two baseUrl options and a baseUrl-dependent
  modelNamePrefix, so stamp both routes.
- R1-11 (#14257 T8): the deciding baseUrl negative for ownsModel was added
  to the Standard suite only. The Token Plan negatives added here differ in
  envKey, so the baseUrl conjunct was never the deciding factor.
- R1-1: the web-search host note contradicted the two QwenCloud rows this
  PR adds to the table on the same page. Name the Model Studio and
  QwenCloud MaaS families the code actually accepts.
- R1-4: the daemon route comment claimed parity with the CLI/ACP/VS Code
  copies. Those resolve the endpoint first and validate the resolved URL;
  this route validates the client-supplied baseUrl. Comment only.

Both new Token Plan rows were mutation-checked one mutant at a time:
dropping baseUrl from resolveProviderState, stamping the provider default
endpoint instead of the requested one, and relaxing ownsModel to envKey-only
each turn exactly the intended row red. An additional cross-route
"versions must differ" row stayed green under all of them (model configs
already embed baseUrl), so it was dropped rather than shipped inert.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmv9zmmcj86

* test: make the QwenCloud witnesses discriminating; trim daemon key once

R1-9: the two Token Plan cases and the four lookalike cases never reached
the dashscope host regex - both blocks short-circuited in
validateReasoningDeclaration/validateReasoningCapabilities before
route.baseUrl was parsed, and the block comment misstated the Token Plan
endpoints as unregistered. Fold the Token Plan hosts into the QwenCloud
host table with a declaration that omits `thinking`/`profile`, so all four
registered endpoints now pin the anchored suffixes.

R1-5/R1-6: the Ink VIEW_TITLES fix had no CI-executed guard (every
keystroke case is skipped under CI=true) and its T1 case asserted only the
already-lazy flowTitle path. Add one ungated, keystroke-free render
assertion and assert the sub-menu heading between the two keystrokes.

R1-3: validate and forward the same trimmed key rather than validating the
trimmed value while forwarding the raw one.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmva623ox8i

* fix(auth): guard zh locale test cleanup, note hand-written provider caveat

R1-7: wrap the QwenCloud zh flow-title test body in try/finally so unmount()
and the locale reset still run when an assertion rejects, matching the shape
of the opentui twin added in the same change.

R1-2: note in the settings.json alternative that a hand-written provider entry
carries no capabilities stamp, so /effort offers the generic tiers rather than
the QwenCloud curated set.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmvaacf8g8p

* fix(providers): address #14073 round-3 deferred Suggestions

- docs/design/auth/motivation.md: QwenCloud-first /auth enumeration (S1)
- docs/users/quickstart.md: name QwenCloud credentials in Before you begin (S3)
- docs/users/configuration/auth.md: scope the Token Plan prefix rule to
  ModelStudio vs QwenCloud so Option 1 and Option 4 no longer read as
  contradictory (S4)
- qwencloud-standard.test.ts: correct the ownsModel comment — envKey and
  baseUrl conjuncts each exclude the sibling independently (S5)
- auth-provider-helpers.test.ts + alibaba-standard.test.ts: pin every
  catalog group's providerIds and the alibaba uiGroup (S6)
- web-search.test.ts: forged-suffix boundary cases for the QwenCloud host
  gate (S7)

S2 (6-latin-locale backfill) and S8 (web-shell group dict) verified as
no-ops: the sibling ModelStudio strings rely on the identical raw-key
fallback in the same locales/dictionary.

* fix(auth): localize provider API key errors

* fix(providers): seed Token Plan only with models its endpoint serves

A live GET /models on the China Token Plan route (real subscription key,
2026-10-16) serves neither kimi-k3 nor deepseek-v4-pro-0813 — the endpoint
offers kimi-k2.x and deepseek-v4-pro instead, matching the platform doc's
personal-edition table. The shared QWENCLOUD_MODELS seed list therefore
pre-filled two ids the wizard would install as dead entries whenever
discovery succeeded, and offered them in the offline fallback.

Filter the shared catalog down to the five confirmed-served models for the
Token Plan preset instead of duplicating stamps; the Standard (pay-as-you-go)
catalog keeps the full list, which no live data has falsified. Pinned by a
new preset test asserting the exact seed ids.

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(auth): preserve explicit models during provider discovery

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-10-16 21:34:43 +08:00
Boulanouar Walid
87de74860b
docs(tools): add looot to the web search MCP services (#13999)
Co-authored-by: 易良 <1204183885@qq.com>
2026-10-16 15:33:15 +08:00
ytahdn
87dcc4a708
feat(web-shell): add file-tree deletion with confirmation (#14158)
* feat(web-shell): add confirmed file deletion from file tree

* test(serve): include file deletion in capability baseline

* fix(web-shell): address file deletion review suggestions

* test(cli): synchronize child cancellation tests

* test(web-shell): refresh transcript budget after file actions

---------

Co-authored-by: 钉萁 <dingqi.jww@alibaba-inc.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
2026-10-16 14:41:34 +08:00
ytahdn
17d5f774ea
feat(web-shell): add file creation and confirmed folder deletion (#14169)
* feat(web-shell): add file creation and confirmed folder deletion

* fix(web-shell): address file tree review and transcript regression

---------

Co-authored-by: 钉萁 <dingqi.jww@alibaba-inc.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: 易良 <1204183885@qq.com>
2026-10-16 02:43:59 +00:00
Lucien
74033d0bd2
fix(sdk): release reconnect transports created after disposal (#13864)
* fix(sdk): release reconnect transports created after disposal

* fix(sdk): preserve closure errors during reconnect cleanup

* test(sdk): satisfy lint in reconnect disposal coverage

* test(sdk): model reconnect subscription failures during iteration
2026-10-14 14:33:22 +00:00
Long Jin
1ce7d71633
fix(core): preserve fuzzy edit line boundaries (#13484)
* fix(core): preserve blank lines after fuzzy edits

* test(core): cover repeated fuzzy edit matches

* fix(core): keep fuzzy edits within whole-line matches
2026-10-14 14:11:23 +00:00
Lucien
3319e819b9
fix(sdk-python): resolve Windows command shims on the effective PATH (#13862)
* fix(sdk-python): resolve Windows command shims on the effective PATH

* style(sdk-python): format Windows shim regression assertion

* fix(sdk-python): secure Windows command lookup and cleanup

* style(sdk-python): align test import grouping with CI

---------

Co-authored-by: 易良 <1204183885@qq.com>
2026-10-14 14:03:28 +00:00
Pulkit Chowdry
7326ac6e0b
Add SerpApi to web search MCP services list (#13104)
* Add SerpApi to web search MCP services list

* Updated best for, added a information that is sent to SerpApi.

* Moved the information sent to SerpApi part in the description to align with the format followed by other listings.
2026-10-13 16:21:47 +00:00
qqqys
c3c38cf583
feat(web-shell): bridge trajectory history and live lifecycle state (#13952)
* feat(web-shell): bridge trajectory history and live lifecycle state

* fix(web-shell): preserve trajectory recovery and bound transcript bundle

* fix(acp-bridge): preserve request lifecycle frames across compaction

* fix(web-shell): address trajectory recovery review findings

---------

Co-authored-by: qqqys <266654365+qqqys@users.noreply.github.com>
2026-10-13 11:28:30 +00:00
haochen
008c0de746
docs(sdk): clarify empty core-tool allowlists across SDKs (#13949)
Co-authored-by: Zhangchenhao-111 <285911435+Zhangchenhao-111@users.noreply.github.com>
2026-10-12 13:19:59 +00:00
Long Jin
f8c99414b2
fix(core): report monitor startup failures as tool errors (#13460)
* fix(core): report monitor startup failures as tool errors

* fix(core): classify monitor startup failures consistently
2026-10-11 14:21:47 +00:00
Shaojin Wen
23274922f5
feat(web-shell): add Send now for queued mid-turn messages (#13681)
* feat(web-shell): add Send now for queued mid-turn messages

A message typed while a turn runs waits for the model response that is
streaming and for its tool batch, so a correction typed into a long answer
reached the model seconds later, and Stop was the only faster path.

Typing keeps that behavior: the message stays queued, editable and
deletable until the next tool boundary. A new Send now action on a queued
row asks the daemon to deliver the queued messages at once.
POST /session/:id/mid-turn-messages/send-now, advertised by
session_mid_turn_send_now, sends the agent craft/midTurnSendNow. While a
foreground turn streams a response that has not started a tool call, the
agent drains the user's messages, cuts the response short, keeps the part
already streamed, and answers in the same turn. Running tools are never
interrupted, a response that started a tool call is never cut, queue-only
steering is not taken early, and a request is scoped to the turn that
received it. Input taken by a turn that is then stopped or fails is kept
in that turn's conversation.

The TypeScript SDK gains sendMidTurnMessagesNow on both clients.

* fix(web-shell): address review findings on Send now

- Restart the Stop-hook state when a continuation answers input that
  cut its response short, so the next Stop check is not hook-forced and
  the consecutive-block count and reasons start over.
- Give input a finished turn keeps unsent a 2 s resolution deadline of
  its own; the deadline also cancels media bridges.
- Requeue a failed send-now drain in its original order, and log a
  send-now request the agent rejects.
- In Web Shell, reconcile a row whose message already left the queue
  and ignore a repeated click while a request is out.
- State the tool-call carve-out wherever Send now is described, and
  document goal turns, the narrow no-answer window and token accounting.
- Add tests for the review's gaps, drive the drain deadline with fake
  timers, and add the route to the multi-workspace failure tables.

* fix(web-shell): keep send-now input a stopped tool boundary leaves unbuilt

When a turn is stopped while a tool-boundary drain resolves a message,
the drain now hands back the messages it has not built that an early
send-now drain took, so the end-of-turn keep records them. Input the
boundary drain took for itself is still dropped there, as before.

- Tests: the stopped boundary keeps that input once and carries nothing
  into the next turn; a Goal continuation is cut; a background
  notification turn is not; a failed send-now drain does not requeue a
  message that left the queue meanwhile.
- Bridge and SDK contracts: the cut applies to a foreground turn's
  streaming response, and `requested: true` does not mean a cut.
- Protocol and design docs: the timed-out send-now drain, resending a
  settled id, the 10-message drain limit, and the tool-boundary drop.

* test(web-shell): pin send-now behavior questioned in review

- Assert that cron and background-notification turns never open their
  response to interruption and that a send-now request arms nothing
  while they stream.
- A response cut short by queued input still answers that input when it
  ends with an error, while an error from a response its drain did not
  cut remains the turn's error. Explain in `watch` and the design doc why
  the cut response's error is absorbed: the cut does not always arrive as
  an AbortError, and the response was given up.
- A message whose deletion missed while a failing drain held it is still
  delivered, which matches the `{ removed: false }` answer the client
  got. Document this in the protocol reference.
- Rename a requeue test to say it covers a queue-only entry.

* docs(web-shell): scope the abort invariant and drain-held removals

- The main prompt loop's catch states that other AbortErrors surface.
  Note that a response cut short by queued input reaches that catch only
  when the turn itself is stopped meanwhile, because `watch` absorbs
  whatever a cut response throws.
- In the force-drain paragraph, add the drain-held case of
  `{ removed: false }`. Such an id must not be re-posted, and its listing
  in `settledMessageIds` is not final, because a failed drain returns it
  to the queue without an event.
2026-10-11 06:24:48 +00:00
Shaojin Wen
e31d5500f1
feat(managed-agent): H6a schedule and automation run record contract (#13536)
* feat(managed-agent): H6a schedule and automation run record contract

The H6a slice of #12827, ahead of any producer: both bodies register and
replay in TypeScript and Java while both domains stay disabled for
submission.

- managed-schedule v1 (chain scheduleId): owner scope, bounded goal, a
  five-field cron with an IANA timezone, append-only definition revisions
  (each successor exactly +1) with their digest, the prompt resource
  reference, the persistent/per_run target mode with the target frozen
  exactly for persistent, the skip/queue_one/allow overlap and
  none/latest/bounded catch-up vocabularies (the limit set exactly for
  bounded), an enabled flag, and a purely logical run block that freezes
  the definition for good once terminal.
- managed-automation_run v1 (chain automationRunId): the pinned
  definition revision, the closed occurrenceKey union — schedule:<slot>
  as a canonical UTC instant to the second (open question 1 answered),
  manual:<commandId>, webhook:<eventId> registered and refused — the
  frozen sessionMode/target, and the H0b run block: a durable dispatchId
  from the first revision, no executionCallId or definition pin, the
  delivery line past planned only once the run ended so a delivery never
  re-runs the model.
- The shared managed-automation-record/1 fixtures pin keys, fixed keys,
  vocabularies, 56 body cases and 22 successor transitions, replayed by
  the TypeScript gate and the Java contract test; the seven timezones
  anchor in each host tz database.
- The projection registry and its v1 fixtures grow the two bodies under
  additionalRecordBodies, as do the Java RECORD_BODIES; the domains stay
  out of MANAGED_SESSION_ENABLED_DOMAINS and commitExtensionRecord still
  refuses them — enablement is the H6b producer slice's explicit step.

Design: docs/design/2026-10-04-managed-automation.md (bilingual update).

* fix(managed-agent): close the H6a review round's contract findings

Round-1 review of the H6a record contract (#13536): one Critical, the rest
Suggestions, each verified against this head.

- R1-1 (Critical): Java boundedText charged one byte per unpaired
  surrogate (the default encoder folds it to '?') while Node's
  Buffer.byteLength charges the three of U+FFFD, so the two validators
  disagreed on identical wire bytes for a 1366-surrogate goal. Java now
  charges the same three bytes per unpaired surrogate. Shared boundary
  cases pin both sides (1365 accepted, 1366 high and low refused).
- R1-6/R1-7: the cron validator now checks per-field atoms semantically
  — digit/list/range/step atoms only, values inside each field's bounds,
  steps within 1–max, ranges never wrapping — with one honest refusal
  message per rule instead of one message for all. Fixtures pin
  */0, */60, - - - - -, 99 99 99 99 99, 0 24 and 17-9.
- R1-8: the timezone refusal no longer claims IANA validation; the
  contract is shape-only on purpose (hosts disagree which names resolve),
  named in the body comment and the design document.
- R1-9: the seven anchored timezones now replay through the validator as
  body cases, so the host tz anchors witness implementation behaviour,
  not only host-DB membership.
- R1-11: Java runs the catchUpLimit value check before the bounded
  pairing rule, the same order as TypeScript.
- R1-12: pinned that an automation_run task row reads definitionRevision
  null (the run block holds no definition pin); the authoritative
  revision lives on the record — asserted in both contract suites and
  named in the design document.
- R1-3: design documents synced to the shipped contract in both
  languages (occurrence identity, automationRunId chain identity,
  terminal freeze, count_limit framing, delivery target, per_run target,
  provenance marker, DST canonicalization tables reassigned to H6b).

Not changed, with replies on the threads: promptRef store verification
stays out of the record contract (cross-scope reference, resolved at H6b
definition admission), and timezone names stay shape-checked (host tz
databases disagree; semantic resolution pinned to H6b admission).
2026-10-07 08:29:15 +00:00
LUOSENGWA
8505479d87
docs(serve): fix stale permission-route note and document pending-prompt events (#13281)
docs/users/qwen-serve.md still described session-scoped permission routing
as Stage 1.5 future work and pointed integrators at the legacy process-global
vote route. Rewrite the note: the session-scoped route ships (Wave 4 / F3) and
is the recommended form for new multi-workspace integrations; the legacy route
remains for pre-F3 single-workspace clients with its 404 semantics spelled out
(the same body as a lost vote for a session owned by another runtime).

Add event-reference entries for pending_prompt_added / pending_prompt_started /
pending_prompt_completed to docs/developers/daemon/09-event-schema.md: payload
fields, the state union, the idle-session no-event caveat, and the "queue-view
bookkeeping, not a turn terminal" guidance (correlate turn_complete /
turn_error by promptId for completion).

Derived from the field notes and triage in #13279.
2026-10-07 04:45:41 +00:00
Shaojin Wen
4970bfa172
feat(managed-agent): adopt the next Hosted Harness generation (G3) (#13174)
* feat(managed-agent): adopt the next Hosted Harness generation (G3)

A Hosted Session no longer dies with the Harness process generation
that first served it (#12952 G3, Steps 1+2). On a generation signal
the control plane renegotiates once through the connector instead of
failing every bound Session until its own restart; pending Turns
re-attach through the takeover load as their retries come due.
Takeover refusals split into a typed terminal decline (deterministic
in the journal) and the existing retriable code; the takeover load is
idempotent against a lost reply and gets its own timeout; negotiation
requires the journal-contract feature token so a rolled-back Harness
is refused once; a marked-but-never-admitted Turn withdraws its
submission mark, backstopped by journal commandId idempotency.

The failover runner gains a Harness-only restart arm on all three
scenarios (harness_boot_id moves with no Java restart) and a
SIGSTOP/SIGCONT frozen former-owner fencing arm, wired into
hosted-harness-mysql together with the previously local-only basic
mode. Design docs included in English and Chinese.

* fix(managed-agent): tolerate the bootstrap 404 of a restarting Hosted Harness

The serve delegating app answers every routed path with a bare
pre-contract 404 while its runtime is still starting. The Harness-restart
arms raced that window: a prompt submitted milliseconds after the new
process bound the port could meet the bare 404, which the client read as
a generation contract violation and ended the Turn as
hosted_harness_protocol_error without any retry. A missing boot header on
404 now maps to a transient transport error so the coordinator retries
through the window, and the failover runner waits on deep health
(?deep=1) before driving turns into a restarted Harness. Terminal turn
failures now also log their underlying exception.

* fix(managed-agent): freeze only the Harness writer in the former-owner fencing arm

SIGSTOPping both original owners left the original Spring's workspace
binding unreclaimable: the reclaim proves death from /proc liveness, and
a stopped JVM still reads as alive there, so the replacement's reconcile
timed out (runtime_broker_reconcile_timeout) and the Turn never
completed. The arm now stops only the original Harness (reclaiming its
workspace binding requires the Spring to die anyway — the continuation
mode's crashProcess), completes the Turn with a full replacement, then
wakes the frozen writer and asserts the journal, transcript, and
session binding are untouched. Design D7 updated in both languages with
the discovered boundary: resource-level takeover of a surviving owner is
structurally out of reach today, which is exactly what this issue's
sequential-generation scope excludes.

* ci: retrigger sdk-java workflow after event delivery stall

* fix(managed-agent): pin the fencing proof on writer identity, not head revision

The first merge-base run of the freeze arm failed its own gate: after
waking the frozen Harness the journal head moved (rev 30 -> 45) while
the writer generation, boot binding, transcript, and terminal count all
held. The moved revisions were the replacement's own legal churn, not
old-writer mutation, so the head-equality assertion conflated the two.
The exit-check claim is identity: no transaction out of the old writer
generation after it wakes. Count that straight off
qwen_managed_session_journal_tx (which carries writer_generation), keep
the binding/transcript/terminal assertions, and align design D7 in both
languages. The error string now also reports the old-writer tx count.

* fix(managed-agent): qualify the journal tx schema in the freeze-arm gate

The identity-based old-writer count queried qwen_managed_session_journal_tx
unqualified; the runner's MySQL connection has no default schema, so the
arm died with ERROR 1046 instead of evaluating the fence. Qualify it as
qwen_managed_agent.qwen_managed_session_journal_tx like every other query
in this runner. No behavior change to the assertions themselves.

* fix(managed-agent): harden takeover correctness from the R1 review

- settle turn_settled checkpoints through the load route's own tail
  instead of a terminal decline, and keep transient store verdicts
  (missing_state/missing_checkpoint) retriable on both authorization
  reads
- exempt only takeover-shaped 409s from the pre-admission retry budget;
  every other failure kind meets it
- scope the 120s load-timeout to recovery loads so plain attaches under
  the CHM bin locks stay on 30s
- adoptGeneration: move map churn out of the connector monitor
  (lock-order inversion), evict only stale-boot entries on equal-boot
  mismatches
- answer a duplicate submit of an already-settled prompt with a 202
  replay anchored at its input.accepted sequence
- withdrawSubmissionAttempted: guard on the same status window as its
  peer CAS methods; complete capability mismatch terminally in both
  remaining coordinator layers
- runner: freeze-arm pre-freeze liveness assertion, /health-proven
  wake non-vacuity, teardown SIGCONT, exact-string dispatch-generation
  bounds
- CI: job ceiling 95 with corrected ceiling math; pin all seven
  failover arms in hosted-process-ci.test.js
- sync both design-doc languages with the shipped behavior

* fix(managed-agent): close the R2 review round on the replaceable-host-harness slice

- withdraw + cancel interference (R2-1, Critical): a CANCELLING Turn
  whose never-admitted mark was withdrawn is fast-cancelled instead of
  being re-dispatched into a second live execution at the adopted
  generation
- the generation-mismatch catch escapes the pre-admission budget only
  on a bound Session, like every sibling arm (R2-3)
- bound the remote-supplied decline reason to the error_message column
  so the terminal write itself never throws (R2-5)
- the last fail(...) in the catch ladder is failTerminally with the
  exception logged, like its siblings (R2-6); the withdrawal itself is
  logged too (R2-7)
- freeze arm: the replacement Spring inherits the original port so the
  woken writer meets a live fencing control plane, not a dead socket
  (R2-2); the wake block asserts the head actually advanced past the
  frozen owner's writer generation, drops the wrong heartbeat claim
  (R1-8 follow-up), separates the two disk-audit meanings, and asserts
  the retained Harness home (R2-8)
- the hosted /capabilities feature list derives from the registry
  instead of a hand-maintained literal (R2-9)
- docs: D7 scoped to the journal half of the Q2 exit check, the binding
  half and the pre-contract-404 classification recorded in Boundaries
  (R2-4, R2-10)

* docs(sdk-java): correct the load-timeout coverage comment in the client test

* fix(managed-agent): reference the frozen Harness through the children registry in teardown

The R1-4 teardown SIGCONT referenced the try-block-scoped `harness`
binding from the finally block, so every freeze-arm run died with
ReferenceError after all fencing assertions had already passed (CI run
37009843147). Reach the child through the children registry by its
start() name instead.

* docs(managed-agent): scope the adoption sentence to this slice's takeover reach

* fix(managed-agent): close the R3 review round at its five shared roots

- retract the turn_settled settle tail to a typed decline: the tail
  answered 200 with no recovery watermark (Java then failed the bind,
  orphaned a completed journal turn), fabricated state:'completed' on
  phase alone, ran mutating work under a passive load, and retained no
  snapshot for the lost-reply retry. D10 already owns
  rebind-and-keep-reading as the Step 3 row (R1-25-re, R3-2 .. R3-5)
- the load route's restore guard applies the same durable-vs-transient
  verdict read, because the bundle collapses both into one blocked bit
  and the durable decline was unreachable from production (R1-26-re)
- the pre-admission exemption keys on lease-shaped 409 codes only:
  configuration-shaped 409s (e.g. hosted_tool_profile_conflict) meet
  the budget, and the same 409 exempts nothing on an unbound Session
  (R1-1-re, R1-30-re, R3-13)
- equal-boot adoption keeps pendingRecovery: the markers were minted by
  the same live client (R1-29-re)
- lifecycle capability mismatch: revert completing-as-unconfirmed —
  that skipped the drain, flipped the session, and recorded a clean row
  with no FAILED vocabulary to tell it apart; the reason-loud retry is
  the honest end of the line until an operator realigns versions, and
  the README sentence says so. Action-response outbox keeps its honest
  FAILED-with-code terminal (R1-45-re, R3-5, R3-15)
- test hardening: concurrency close-once now surfaces worker assertion
  failures via Future.get and pins that no real client build happened
  (R3-12); loadTimeout-vs-requestTimeout discriminated at the call site
  (R3-9); single-walk acceptedInputSequence (R3-18)
- runner: 5-column execution queries pin runtime_session_id unchanged
  under --harness-only (R3-14); frozen-arm teardown looks the child up
  by a shared label constant and fails the run on a drift (R3-17)
- CI: ceiling 105 covers the nine summed step ceilings (92) plus the
  uncapped setup steps (R3-6); the seven-arm pin also asserts each
  script's flags and the install step's existence (R3-16)
- docs: both languages re-synced (D5 retraction, D9a code allow-list,
  D7 teardown, D8 runtime identity, honest validation digests, the four
  missing changes-table rows, README retry clause)

* fix(managed-agent): replay takeover snapshots only to a same-shape request

A passive load only asks broker.status(), so its snapshot keeps
phase='await_runtime'; replaying it to a drive load handed the
coordinator a checkpoint that isContinuationReady() can never accept,
terminally failing an otherwise continuable Turn. Record the request
shape with the snapshot and replay only on a mode match; the clear
conditions stay promptId-only.

Also make the adoption race test deterministic: a package-private
createClient() seam lets the test inject the replacement instead of a
real /capabilities call (proven flaky on CI), worker assertion failures
now surface through Future.get, and the pendingRecovery fixture seeds
its marker through the recovery path.

* fix(managed-agent): close the self-audit round on the replaceable-harness slice

Three rounds of undirected plus adversarial audit over the whole PR diff
(31 files), with three parallel deep-dive passes over the Java, TypeScript
and runner/docs surfaces. Every finding was re-verified against the tree
before acting; five reported defects were rejected on evidence.

Correctness:
- the load route read the blocked-restore verdict AFTER managed.close();
  sealing the journal makes every later store read fail as "writer is not
  active", which the authority erases into missing_state, so the durable
  branch this PR added could never fire and a corrupt checkpoint stayed in
  the retry-forever class. Read the verdict first, and pin the order with
  a witness that goes red when the read moves back (the local authority's
  seal does not disable reads, so only the ordering assertion catches it).
- drop hosted_session_already_attached from the lease-bounded 409 set: the
  daemon removes an attachment only on an explicit detach or delete and this
  control plane never detaches, so a Spring restart against a surviving
  Harness retried a permanent refusal forever instead of ending the Turn.
- adoptGeneration evicts by boot identity in both branches instead of
  clearing, so a marker minted by a concurrent rebuild can no longer be
  erased between two adjacent clears (which left a live attachment with a
  null recovery report), and markers follow their attachment.
- the post-withdrawal fast-cancel reads the row again: a cancel landing
  after the claim is invisible in the claimed snapshot, and acting on it
  submitted a live execution only to cancel it below.
- a durable verdict re-read after settling now declines as
  checkpoint_blocked, matching the identical pre-settle classification.
- an unrecognized blocked reason throws instead of declining, so a reason
  core adds later cannot silently become a terminal Turn failure.
- the operator-settable load-timeout is validated at connector
  construction; a bad value previously threw on every client() call and
  surfaced as an endless transient retry naming only the exception class.

Tests: already_attached and lost-withdrawal-CAS witnesses; the fast-cancel
witness now pins the re-read (cancel after claim); the concurrent-adoption
test surfaces worker assertions through Future.get and injects the
replacement through a package-private createClient() seam instead of
standing up a live /capabilities call; the CI pin gains each arm's mode
flag and a ceiling-sum invariant.

Docs (both languages): D1 now describes the boot-identity eviction and
lists what this slice really added to HostedHarnessClient; D3 no longer
claims the bind-refusal path stops being reached (it is D4's trigger); D6
records the same-shape replay clause; D7's fence rationale no longer blames
heartbeats (lease renewal touches writer_lease_until only); D9a names the
two exempt codes and why already_attached is not one; the validation digest
states which claims are pinned and which are not; the CI arithmetic reads
12 + 8x10 = 92; the stale workflow nit is dropped.

* chore(ide): regenerate NOTICES for the merged dependency set (email channel)

* fix(ide): replace the workspace-polluted NOTICES with the pristine regeneration

The file committed in f7a960c559 was generated under a polluted module
layout left by an aborted install: its resolution hoisted
is-fullwidth-code-point@5.1.0 and produced 663 total dependencies, so
CI's own regeneration (pristine: 662) mismatched and the up-to-date gate
failed. Replace it with the pristine worktree's output, which the gate's
own regeneration produces to a no-diff state.

* fix(managed-agent): close the R4 review finds on the takeover and refusal paths

- classify missing_checkpoint and a bare missing_state as durable verdicts
  so a permanently unrecoverable session declines instead of being retried
  past the lease window; only a missing_state carrying the erased store
  error's message stays retriable
- map a fenced predecessor's managed_session_writer_conflict to the coded
  retriable 409 in the load route so the wait provably ends with the lease,
  and allowlist that code in LEASE_BOUNDED_409_CODES (dropping the inert
  hosted_prompt_recovery_required, which can only arrive post-mark where
  the exemption gate is already bypassed); null-guard the membership test
  against codeless error bodies
- replay a takeover snapshot only to a request whose parsed store identity
  matches the attached session's, never to any takeover-shaped caller
- fetch the harness client AFTER resolving the attachment at the six entry
  points, so an adoption completed during a create/load round trip cannot
  strand a call on the closed client
- pin the reply watermark of the journal-replayed prompt admission, the
  construction-time loadTimeout guard, the marker-eviction half of
  adoptGeneration, and the withdrawal CAS ordering with witnesses, and
  widen the load-timeout test's durations so the capabilities negotiation
  keeps headroom on contended lanes
- narrow the README's typed-decline sentence to the surviving row and
  generation binding, and mirror the exempt-code list in both design docs

* fix(managed-agent): answer the round-5 waits without terminalizing them

- a takeover now splits three ways: drivable answers recovered, a
  deterministically unrecoverable drive keeps its typed decline, and
  anything whose wait belongs to someone else (a requested approval, or
  the whole non-drivable space on a cancellation-only load) answers
  inapplicable so the route falls through to the plain attach — a
  user-cancelled Turn cancels instead of failing as
  managed_runtime_recovery_blocked, and an approval wait survives its
  generation rather than dying while the answer is still deliverable;
  the coordinator keeps the await_action guard as a retriable path even
  if a Harness still declines it
- an unconfirmed cancellation re-drives with its snapshot restored: the
  cancel admission's rollback is now symmetric, so the retrying takeover
  load replays instead of being refused already-attached
- an unrecognised blocked reason at the final re-read stays retriable on
  the drive side like the pre-settle read, instead of terminalizing on
  unresolved_after_settle; only durable verdicts may end a Turn
- the hosted persona's curated tag set is declared once in the registry
  (hostedPersona descriptor flag) instead of being filtered by a third
  hand-maintained list at the route, pinned by the docs-contract test
- the harness-only arms pin a surviving worker through the runtime
  session row's own liveness metric, not the insert-frozen id column
- witnesses: declined reason on the wire, cancel-report replay after a
  lost answer, inapplicable shapes at the recovery kernel (both loads),
  the drive disjunct of the load-timeout predicate, the one rebuild per
  adoption race, and coordinator corners for cancelling/await_action

* fix(ci): qualify the runtime-session heartbeat query with its schema

The harness-only arms' liveness beat read qwen_runtime_session bare;
mysql runs without a default database, so the Harness-restart in-flight
arm died on ERROR 1046 with 'No database selected'. Every other probe in
the runner already addresses qwen_managed_agent.<table> — this one now
does too.

* fix(managed-agent): close the round-6 shape and wedge findings

- the pre-kernel no-tool refusal and the restore guard now honour the
  same shape rule the kernel does: a cancellation-only load answers
  inapplicable and attaches plain (or the baseline retriable refusal
  where no attach is possible) instead of terminalizing a Turn the user
  asked to cancel; a takeover that answers inapplicable no longer trips
  the bare-load refusal on its empty recovery
- the lease-bounded exemption is keyed on the writer lease's own wire
  code only: hosted_turn_recovery_required covers arbitrary takeover
  failures, and exempting it wedged durable refusals in an unbounded
  pre-admission retry; a refusal carrying that code now meets the budget
  like every other body shape while managed_session_writer_conflict
  keeps its D9a window
- witnesses: the no-tool plain-attach coincidence for cancel vs the
  typed model_start decline for drive, the durable restore verdict on
  the cancellation arm, and the budget-hit for a durable 409 on the
  bound path (the old takeover-409 premise rewritten, its writer-
  conflict twin already covered)

* fix(managed-agent): settle a cancel first on a plain-attached replacement

A real-stack round-4 wedge: cancelling a Turn whose Harness died mid
model wedge (Work realized after the merge) failed as
hosted_harness_generation_mismatch — the plain attach leaves the bind
CAS unwinnable (boot-old != boot-new), the cancel was never issued, and
everything queued behind the Turn then retry-forever: later prompts
409, a close 409, no public-API escape.

runClaimed's recurrence without recovery now cancels before it binds:
a CANCELLING Turn does not submit, and a bind exists for submissions —
the next Turn's takeover adopt moves the generation when something
actually continues. cancelledOnAttach suppresses the tail's later
re-cancel. The R5 witness now also pins: bindHarness is never asked,
withdrawSubmissionAttempted is never touched, and the Turn is neither
recovery-blocked nor generation-mismatched. Design docs D3/D4 gain the
bypass rule in both languages.

* fix(managed-agent): close the inapplicable aftermath the round-8 review found

- an inapplicable takeover must not latch session.blocked — a 200 answer
  that refuses every settlement route is a lie (R5-2's latch regression:
  nothing outside settleCancelledHookTurn clears it for a non-hooks
  session). The blocked latch now splits the same shape rule: only a
  genuinely unsettled takeover parks there, not an inapplicable answer.
- a plain attach of a bound Session after adoption rebinds the Session
  row with its own CAS (owner + live lease + expected = the row's boot),
  since bindHarness provably refuses a turn that already posted its epoch
  — the Turn is neither generation-mismatched nor stranded (R8-1).
- a Turn written turn_settled completes, so it is never stamped as
  managed_runtime_recovery_blocked: the kernel answers inapplicable on
  BOTH load shapes (the plain attach lets the daemon's own projection,
  not a declined verdict, write the terminal record), retaining the
  rebind-and-keep-reading rationale for Step 3 (R8-2).
- the #13388 pinning test's mock endpoint now advertises
  managed_session_journal_delta_v1 like every other Harness fixture of
  this branch — a merger's follow-up angry about the gate (D9c), not an
  upstream flake.
- witnesses: turn_settled pinned as inapplicable on both shapes at the
  kernel; plainAttachAfterAdoptionRebindsBeforeItSubmits asserting the
  store CAS adoption; R5-2's corner witnessing 200 without
  recoveryRequired; coordinator 46/46, hosted client + pinning tests,
  TS session+recovery fully green.

* fix(managed-agent): close the inapplicable aftermath the round-9 review found

- the no-tool arm now refuses with the baseline retriable one again
  instead of minting a plain attach whose parked Turn nothing may
  resolve (R5-2'), and a takeover load without broker options shares
  that refusal;
- the journal prompt replay answers 202 only when the request body's
  digest proves identity against the admission its input.accepted
  holds; carrying the same Id with different bytes is a real
  prompt_conflict, never a silent swap of watermark (R9-1);
- a plain attach after adoption moves the Turn's stream epoch through
  recordRecoveryAdmission to the attach's epoch, on both the binding
  ladder and the cancel-on-attach arm — a stream opened with the
  claimed epoch would be rejected on the new generation as epoch
  mismatch (R8-1').
- witnesses: the no-tool cancel arm refusing retriably while drive
  declines with its typed reason, the digest mismatched replay timing
  out as hosted_prompt_conflict, and R8-1 streaming the adopted
  generation's epoch on both arms (plainAttachMoves* and
  cancelOnAttachStreams*) with recordRecoveryAdmission pinned.

* deflake: carry #13403's pinning fixture under the journal-delta gate + harden the 13328 mount-holder waitFor

The merge of #13403 turned the Java CI lanes red with
DaemonProtocolException: Endpoint does not advertise
managed_session_journal_delta_v1 on every burst caller: this branch's
D9c capability gate (from #13388's pinning work) rejects endpoints
without that token, and #13403's new /capabilities fixture did not
advertise it. Same fixture-parity fix as #13388's — the token is a
fixture property, not production behavior; the deliberate negative
stub (capabilitiesJsonWithoutJournalToken) stays as the gate's
witness.

The ubuntu Test lane red on the same head was the issue-13328
mount-holder vi.waitFor defaulting to its 1 s window against the
admission pipeline on the loaded runner ("expected undefined to be
defined", repeated across both retries). Give it an explicit 10 s
timeout, assertions unchanged — the same deflake pattern as
#13411/#13323/#13430.

* fix(managed-agent): close the R11 review round (inapplicable pays only what it can)

R11 (qwen-code-ci-bot review, 5 threads, 3 new + 2 re-checks) landed on
the shared consequence of R10's fixes: an inapplicable/plain-attach answer
stays honest only where a settlement route can pay.

- R11-1: the prompt route's session-scope guard now answers the
  SESSION-level wedge code (hosted_turn_recovery_required); the
  prompt-scoped code names only the requested prompt's own unsettled
  duplicate, so the duplicate-admission adopt branch can no longer be
  forged by a refusal about someone else's parked Turn — and the budget
  treats the session-level code as pre-admission whenever the Turn
  provably recorded no epoch, recording the wedge's own code on
  exhaustion (provenance-gated on an existing submission mark).
- R11-2 / R10-1 re-check: both takeover arms compute the bare branch's
  settle projection inline (`settleProjectablePromptId`): approval waits
  keep the plain attach (resolve pays), turn_settled is settled by the
  load itself via the extracted `runSettleProjection`, and an unpayable
  park keeps the baseline retriable 409 instead of a healthy-looking 200.
- R10-2 re-check: the kernel's passive inapplicable is narrowed to those
  two payable states; initial / durably blocked / model-start / unknown
  phase / wrong-checkpoint throw into the retriable refusal.
- R11-3: createOrLoad's final attachment put restores the pendingRecovery
  marker for snapshot-carrying passive re-attaches, or the cached branch
  of a later recoverManagedRuntime answers "nothing parked" over a
  snapshot it holds.

Witnesses: 245/245 CLI across the three hosted suites (2 new route
witnesses + updated session-code assertion + 2 recovery-suite flips),
QwenHostedHarnessConnectorTest 34/34 (marker witness),
HarnessCoordinatorTest 52/52 (session-busy witness; R6 budget witnesses
pin the load side as recorded). Docs updated in both languages.

* fix(managed-agent): settle the no-tool cancellation takeover instead of refusing forever (Arm B)

The round-6 real-stack wedge: a Turn with no Runtime work parked mid
model round, its Harness generation dead, cancelled — the no-tool arm of
the takeover branch answered the baseline retriable 409 on every
cancellation-side redispatch, and with the Turn still carrying its
submission mark, the retry budget never fired. Turn 2 stayed CANCELLING
forever and every later Turn met the same wall.

The fix keeps the wire honest and the settlement explicit:

- LoadHarnessSession carries `cancellationTakeover`, set ONLY by
  recoverManagedRuntime's cancellation arm. A plain passive re-attach
  shares the passive wire shape and deliberately never sets it — nothing
  may mint a canned CANCELLED record for a wait whose owner could still
  exist.
- With the signal present, the no-tool arm of the takeover branch
  settles the ownerless park into the daemon's journal itself
  (turn_result/cancelled, landing turn.settled); the writer fence on the
  very load is the safety boundary (it proves the producing generation
  can never write again). On write failure the arm keeps the baseline
  retriable 409. Without the signal the arm keeps its merge-baseline
  answers — retriable 409 on cancel, typed model_start decline on
  drive — so the collision guard holds by construction.

Witnesses: `settles the cancellation of an ownerless parked no-tool Turn
on the takeover load` (200 plain attach + durable settle tag + cancel
reads settled-at-tail as 204 + next prompt admits; the revert flips the
verdicts red) with the collision-guard passive-only rerun still at 409,
plus wire-side `carriesTheCancellationTakeoverFlagOnlyOnTheCancellationLoad`
asserting the flag travels only with the signal.

Verification: TS hosted suites 259/259, qwencode 32/32,
QwenHostedHarnessConnectorTest 34/34, HarnessCoordinatorTest 52/52,
typecheck, eslint, prettier. Design doc updated in both languages.

* fix(managed-agent): converge P1-1 (tool-config vs unpaid Runtime work) and P1-2 (stale approval copies)

Round-7 re-review of 275f934824 found two remaining wedge families
inside the cancellation settlements this PR introduced. Both converge on
durable proof, never on the stale checkpoint copy.

P1-1: a takeover cancellation must separate "the Session is configured
for tools" from "the Turn owes unpaid Runtime work". A files-profile
session whose owner died BEFORE its first tool call carried no unsettled
Runtime work (checkpointless or a bootstrap checkpoint naming no Turn) —
and the kernel's different-Turn check threw on the bootstrap's null
turnId, so the load answered the retriable 409 forever. The load route
now hoists that split ahead of the kernel: with the explicit
cancellationTakeover signal (and only with it — the collision guard
stays), any ownerless park without unpaid Runtime work settles into the
journal itself; work in flight follows the recovery-cancel report; the
no-signal shape keeps its baseline refusal unchanged.

P1-2: the durable action record — not the checkpoint's stale copy of the
approval — decides an owner-died approval. When the record says the wait
ended, the recovery kernel no longer maps it to the inapplicable plain
attach: expired/cancelled is transient (route refusal), decided is
advanced through its own durable gate ONLY on a drive load (a
cancellation never crosses it), and every other shape (still requested,
or undefined) keeps the plain attach a resolve route can pay. The
cancel/expired arrive by the same separation (settle with the signal;
decided is settleable with the signal too — a CANCELLING takeover does
not resume approved work no matter which side the user took).

Witnesses: `settles the cancellation of an ownerless parked Turn on the
takeover load (profile=null|FILE_PROFILE)` parameterized exactly as the
re-review demanded; three recovery-kernel witnesses for the cross-read
(resolves-inapplicable only while requested; throws for ended-without-a-
decision; drive declines checkpoint_blocked on an undrivable decided
wait); route-level `settles a cancellation takeover whose durable
approval already ended (state=cancelled|decided)`. Design doc updated in
both languages. Suites: hosted-harness-session 222→(+10 new),
hosted-runtime-recovery 36→(+3 new), issue-13328 33→, all green;
typecheck and eslint clean.

* chore(vscode-ide-companion): regenerate NOTICES for the merged dependency layout

* fix(managed-agent): read owed Runtime work off the work itself and pay coded plain-cancel refusals with the cancellation takeover (R9)

Round-9 real-stack measurement found the cancellation separation still
wedging on two families: every cancelled Turn after the first, on both
profiles, and every cancel row of P1-2 — where no load ever left the
coordinator, because a plain-cancel retry can never settle a parked
Turn and no redispatch was forthcoming.

- The park gate now reads owed work off the work itself: a checkpoint
  naming an earlier Turn whose tool items all settled and were consumed
  owes nothing to a cancelled Turn that never reached a tool call; and
  a no-tool Session cannot owe Runtime work by definition, so its
  cancellation arm settles unconditionally again (the round-8 behavior
  the gate had displaced onto a blocked restore basis).
- The admitted-cancel path turns a plain cancel's coded 409
  hosted_turn_recovery_required into an immediate cancellation
  takeover load over the same attachment (recoverManagedCancellation),
  and the connector sends it even with a healthy cached attachment —
  the shortcut would swallow it and the load would never leave the
  process. A recovered Runtime park it reports is cancelled through
  its checkpoint admission; the already-running stream lands the
  settle either way.

Witnesses: hosted-harness-session settles the second-Turn cancellation
park on both profiles (the round-9 rig witness adopted); the
coordinator drives the takeover load exactly on the coded refusal
(plain park and recovered park) and on no other answer; the connector
always sends the cancellation load over a cached attachment. Suites:
hosted 229, HarnessCoordinatorTest 55, QwenHostedHarnessConnectorTest
35, ManagedAgentServerIntegrationTest 29.

The decided-approval drive arm of P1-2 (rows where the user answers
allow or the approval expires unanswered) remains open: it needs the
plain-attached replacement to drive the parked Turn itself once the
durable wait ends, and lands as its own slice.

* fix(managed-agent): pay the cancellation signal identically on an attached re-answer (R9-2)

A cancellation takeover load the coordinator forces onto a Session
ALREADY attached in this daemon used to skip the entire settle
separation: the attached branch called the passive kernel directly,
which never advances a durable wait passively — so a park whose
approval ended after the attach (the round-10 allow, then cancel
repro) was thrown back as an unknown phase and refused retry-invariant,
while the first-load branch settled the identical shape. Hoist the
separation above the attachment split: the attached branch now reads
the signal too and runs the same no-tool arm, owed-work gate and
durable cross-read, settling through settleCancelledHarnessTurn into
the journal the already-running stream lands.

Witnesses parameterize both ended states: attach plain while the
durable approval says requested, end the wait after the attach with
the stale checkpoint copy untouched, then redrive with the cancel
signal — 200 with the redriven-load settle log, and the plain cancel
route reads settled-at-tail. Suites: hosted session 231, recovery 44.

* fix(managed-agent): close the durable wait before the cancelled terminal lands (R9-3)

settleCancelledHarnessTurn only wrote turn_result/cancelled into the
journal, and the record sink's turn.settled only advances next-turn
checkpoints at a model-start phase — so a cancelled Turn parked at an
approval wait ended in the journal while its checkpoint stayed at
await_action, and the Session's next prompt met
`ManagedHarnessBlockedError: await_action is not a model-start phase`
(the round-11 repro: old Turn completed cleanly, new Turn failed as
hosted_turn_failed before the model was ever called, closing 1 P1).

The cancelled settle now closes the ended wait first through the
wait's own durable gate (resolveDurableWait → model_output_committed,
a model-start family phase) whenever — and exactly when — the durable
approval already ended, mirroring the cross-read the caller's gate
performed. The decision remains the USER's and nothing resumes: the
Turn dies immediately after. Runs of requested waits or of in-flight
Runtime work keep the kernel's existing paths.

Witness (both answers): requested approval attaches plain on the
passive takeover, the record ends after the attach with the stale
copy untouched, and the attached cancellation takeover settles —
200 with the redriven-load settle log, plain cancel reads 204, and
the settle provably commits the model_output_committed boundary
ahead of the terminal record. Suites: hosted session 231, recovery
44; typecheck, prettier, eslint clean.

The unit suite cannot stage "approval-first park + literal owner
death" today (DELETE refuses active Turns, and abort unwinds the
wait cleanly), so the end-to-end relief-Turn acceptance stays with
the real-stack probe; the annex-level witnesses cover the settlement
payment and the wait-close commit.

* chore(vscode-ide-companion): regenerate NOTICES from a clean-room install

The previously committed file was generated in the long-lived
worktree whose pnpm layout had drifted (is-fullwidth-code-point@5.1.0
+ get-east-asian-width@1.5.0 + fdir/picomatch slot differences) even
though pnpm-lock.yaml is byte-identical to main. Regenerated in a
fresh worktree with a frozen-lockfile clean install: 662 deps, and
the output matches the lint lane's renewed file byte for byte, which
the polluted layout could not reproduce.

* fix(managed-agent): pace forced takeover loads per Turn and make the cancelled settle idempotent (R9-4)

Two round-10 verdict items:

1. The coded-409 escalation (R9-P1-2) ran on every ~500ms lease-renewal
   tick, so a Turn cancelled while its wait still owns a live durable
   record paid ~4 requests/s of daemon work forever (21 forced loads and
   20 cancels in the verdict's captured window). cancelAdmittedTurn now
   paces the forced cancellation takeover load per Turn (5s minimum
   interval): the cheap plain-cancel retry keeps carrying the wait, and
   a wait that ends inside the interval still settles on the next paced
   attempt — far below the approval timeout's 45s budget.

2. A settle that already landed was not idempotent: the coordinator's
   next forced load inside the stream's landing window met
   `event id turn:<id> is already committed` (3/3). The cancelled settle
   now re-reads the journal's own turn.settled for this prompt before
   writing, so the redriven load is a re-answer, never a second
   turn_result racing the event-id CAS.

Witnesses: three paced coded-refusals (t0 / t+500ms / t+5s) yield three
plain cancels but two forced loads (HarnessCoordinatorTest 60); the
round-9-2 witness pair now races a second identical load inside the
landing window and asserts one cancelled record and no
takeover_unavailable line (hosted suites 275).

* test(managed-agent): commit authority-valid deltas in the restore byte-budget test

How: holdsRestorePagesInsideThePerPageByteBudget now builds each dense
transaction from 210 well-formed message.delta lines (text at the
reader's 4096-byte cap, ~1 MB per transaction) plus the commit marker,
through a shared TurnEventLines composer, and asserts that revisions
1..9 fit the 8 MiB page budget while a tenth does not. The reader replay
IT now also replays that delta shape. The class turns off MockMvc
print-on-failure.

Why: #13348 added the test with placeholder lines ({"subtype":"event_v1"}
plus a padded commit_v1 line), and #13355 (merged later, never run
against it) made the commit gate refuse any non-event line inside the
event range, so every commit after genesis answered 409 and main's SDK
Java lanes went red. The failure was masked as timeouts: Spring Boot
printed the failed test's ~1.3 MB request body as one log line, which
stalled the Actions log pipeline for ~45 min, so the Hosted Verify and
O4 steps blew their ceilings and the MariaDB lane hit its job timeout.

Test: ManagedSessionStoreIntegrationTest and ManagedExtensionRecordStoreTest
green; full managed-agent-server surefire (1077) and checkstyle green;
HostedCommittedEventLineReplayIT green through the real TS reader and
red when the delta text exceeds 4096 bytes; dropping the page byte
budget in ManagedSessionStore fails the test (10 rows instead of 9)
with no log line over 400 chars.

* fix(managed-agent): propagate the settle's own wait-check fault instead of degrading it to a successful cancel (R9-5)

The cancellation settle's authorization re-read was wrapped in
`.catch(() => undefined)`, so a transient store fault surfacing
between the caller's gate and the settle's own wait-check degraded
into 'nothing to close' — the cancelled terminal landed over a wait
the fault hid, and the Session's next prompt then found the same
obsolete checkpoint that family wedges on (the round-11 fault
injection at this exact boundary: authorization flips
runnable → blocked/missing_state → runnable once, the settle still
mints turn_result, and the run dies on `await_action is not a
model-start phase`).

A read failure is a retriable store fault, not 'nothing to close': it
now propagates into the caller's try-catch, which keeps the baseline
retriable refusal — the fault's own retry ladder owns the retry, and
the replayed shape then settles identically.

Witness mirrors the probe: the wait-gate reads fine, the settle's own
check faults once — load refuses hosted_turn_recovery_required with no
Cancelled record and an honest 409 on the plain cancel, then the same
shape settles after the fault clears (settle log, the
model_output_committed boundary, plain cancel 204). Suites 276/276
(hosted session 232, recovery 44), typecheck/prettier/eslint clean.

* fix(managed-agent): refuse the cancelled settle on fault-shaped authorization verdicts too (R9-5')

R9-5 propagated THROWN authorization faults into the caller's retriable
refusal, but the authority never throws the same class of failure: the
real path takes a retry-exhausted ManagedSessionStoreTransportError and
converts it in place into `{status: 'blocked', reason: 'missing_state'}`.
From there the cancelled settle still marched on — it skipped the wait
advance and wrote the cancelled terminal anyway, spending the Session's
next prompt on the obsolete `await_action` checkpoint the verdict hid
(the round-11 verdict at `964b87a50a`, reproduced with the production
error type: 200 on the takeover, the terminal lands, and the next turn
dies `await_action is not a model-start phase`).

Fault-shaped authorizations are never "nothing to close":
`missing_state` (the TransportError conversion), `opaque_state` and
`invalid_state` now all answer the same retriable refusal — the settle
refuses rather than minting what it cannot prove. `missing_checkpoint`
stays payable on purpose: it is the Arm B park's honest form (a no-tool
Session with history provably has no checkpoint) and its settle is the
already-verified behaviour.

The witness covers both faces of the store fault
(`it.each(['exception', 'missing_state'])`): same park, same stage,
either a thrown fault or a folded `blocked/missing_state` — the load
refuses with zero settle and an honest plain cancel 409, and the replay
settles the moment the fault clears. Suites 277/277 (hosted session
233, recovery 44), typecheck/prettier/eslint clean; design docs note
the boundary in both languages.

* docs(design): split the R9-5' verdict paragraph into its own bullet

The R9-5' text rode mid-bullet after R9-5's run-on, which Prettier
then flattened to column zero — breaking the list structure the lint
lane rejects outright. Both languages carry the split now, and the
lint-facing check passes again.
2026-10-07 03:38:25 +00:00
ytahdn
f2e069061a
feat(web-shell): add adaptive navigation rail and unified Live settings (#12943)
* feat(web-shell): add adaptive navigation rail and unified Live settings

* style(web-shell): add space below the Home action section

* Revert "style(web-shell): add space below the Home action section"

This reverts commit a2e1c7622336cceb174faf1814b057a727f6a589.

* fix(web-shell): Preserve navigation and Live drafts across refreshes

* fix(web-shell): keep rail navigation state, drag widths, and Live settings drafts consistent

Address the Critical findings from the latest review rounds:

- Build the standalone shell sidebar config from the sidebar's exported
  defaults so the Agents entry can no longer silently drop out.
- Gate rail navigation's persisted uncollapse behind restoresColumn so
  opening a full page (Settings, Plugins, ...) no longer rewrites the
  user's collapse preference; only Home/Channels/Live restore the column.
- Reset the sidebar section when a panel closes and stop leaking 'split'
  as a sidebar page, so a wide split keeps the home column and a working
  collapse control.
- Clamp drag widths to the container-aware cap the CSS already renders,
  floor the visual drag at the layout minimum, and restore the persisted
  width from state instead of a mid-animation/container-capped rect.
- Cap the compact drawer's min-width at the container width.
- Keep the Live voice trigger reachable from the Live page header when
  the sidebar's slot is hidden.
- Settle Live settings draft fields (and their conflict baselines) that a
  refresh converges onto, so a later edit is not blocked by a stale
  baseline.

* fix(web-shell): keep rail panel sections, status dots, and Live drafts consistent

Address review findings on the navigation-rail change: retain the
Channels/Live sidebar section when its panel closes (an explicit Home
choice sticks on the first click), load secondary-workspace attention
status whenever the Home column is hidden (idling the poll when nothing
runs), ignore zero-displacement presses on the resize handle, settle
padded Live settings drafts on trimmed refresh convergence, disable the
shortcut capture until status loads, key the empty-chat welcome chrome
off the shell container signal, and document /live in the pre-auth path
lists.

* fix(web-shell): close rail-era gaps in shortcut, dock, and status handling

Address the round-5 review findings on the navigation-rail change. The
Cmd/Ctrl+B handler resolved its target with e.target, which shadow-DOM
retargeting replaces with the portal host, so the editable-target
exemption never fired for portaled dialogs; it now reads
composedPath()[0] like its siblings. The same handler toggled the raw
sidebarCollapsed state, silently inverting the persisted preference in
the split-view fold band where the sidebar renders folded regardless;
the shortcut, the sidebar prop, and the environment-panel dock budget
now share one effective-collapsed expression. The rail's 56px is
excluded from the dock budget so the environment panel keeps docking at
1440px windows instead of being force-closed on load, with the e2e step
re-pinned at 1440 and the consequence recorded in the sidebar docs. The
secondary-workspace status queries now stay active while the Live
section column is open (the rail Home button is the only status surface
there), and the rail branch resets the project search when Live unmounts
its autoFocus field so a Home round-trip cannot steal focus. The Live
Voice card gates key removal and the required marker on the saved key
state, so a model edit leaving a route can no longer offer removal of a
key that was never stored.

* fix(web-shell): stop silent collapse-preference and drag-width rewrites

Address the round-5 review findings on the navigation-rail change, plus
the residual direction of the round-4 fold-band fix:

- The Cmd/Ctrl+B toggle computed from the effective folded state is a
  constant expansion in the split-view fold band; it now rewrites the
  unchanged preference there instead of flipping a stored `true` to
  `false` on a keypress that changes nothing on screen.
- The rail's column restore fires `onCollapsedChange(false)` only when
  the sidebar is actually collapsed, so a drawer tap on Channels/Live no
  longer silently clears the desktop preference from the forced-open
  drawer.
- The Channels rail entry restores the column only when the Channels
  column exists (`channelNavigationEnabled`), matching openNavigation's
  rule for full-page entries.
- The App's rail-Home callback drops its redundant preference write; the
  sidebar's openNavigation already owns column restoration.
- A drag released at or past the container cap while the cap already
  bound the start width no longer persists the capped width over a wider
  stored preference, and releasing at the drag start restores the
  pre-drag state width as well.
- The Live settings settle effect now runs only on a genuine status
  change, so typing a value that briefly trims onto the saved one no
  longer loses the just-typed characters.

Each behaviour is pinned by a unit case that goes red without its fix.

* fix(web-shell): settle Live drafts only on genuinely changed values

Follow-up to the round-5 settle fix: the daemon SDK returns a fresh
status object per fetch, so keying the settle effect on object identity
still let a 1s install poll or focus refresh with unchanged data
converge a padded draft the user was typing. Compare the settle-relevant
saved values against the previous status instead: only a refresh that
actually moved a saved value settles, while draft-only re-renders and
unchanged polls leave the in-progress text alone. Pinned by `does not
settle a padded draft when a poll returns unchanged values`, red against
the identity comparison and green with value comparison; the convergence
cases stay green.

* fix(web-shell): keep capped drags window-scoped and drop detached rail hints

Address the two round-6 Critical findings:

- The container-cap guard added last round only covered a release past the
  cap. While the cap binds, the rendered edge cannot follow the pointer, so
  the rect-based delta is container-scoped in both directions: a 2px
  leftward jitter stored 298 over a 420 preference, and a later host
  widening rendered the narrow value. Map the pointer delta onto the state
  width whenever the cap bound at drag start, so persistence stays
  window-scoped (418), while the past-cap branch keeps priority and an
  uncapped drag still persists what the user saw.
- navigationHint held a live rail button and was only cleared by DOM
  handlers on the nav. Removed elements fire neither pointerout nor blur,
  so an unmounted rail - or just an unmounted hinted button, when its
  feature gate flips - left the tooltip Popover open against a detached
  node, painting the previous icon label near the viewport origin when the
  rail returned. Clear the hint when the rail is gone or its anchor is
  detached; the check runs every render because a dependency list cannot
  observe a child unmounting while both deps stay unchanged.

Both are pinned by cases that go red without their fix: a leftward jitter
stores 418 (298 without the mapping, with the two neighbouring width cases
still green), and a rail tooltip does not survive a rail -> single -> rail
round trip.

* fix(web-shell): re-baseline hidden-surface and failed-save trackers

Address the two round-7 Critical findings:

- The secondary-workspace completion tracker only re-baselined while the
  status surface was hidden, leaving a frozen running snapshot across a
  visible Home period. Comparing the next refetch against it repainted a
  completion the user watched happen as an unread one. The visible path now
  nulls the per-source baseline, so the first hidden cycle after a visible
  period is baseline-only: only completions actually observed while hidden
  produce a dot. The always-hidden sibling path (and its existing test in
  WebShellSidebar.collapse-persist.test.tsx) is untouched.
- A save rejected after the daemon persisted some of its writes (its
  settings writes land before setEnabled, which rolls back only `enabled`)
  was reported as settings.liveSetup.conflict: the stale apiKey baseline
  compared against the refreshed keyConfigured/storedKey flipped by the
  card's own request. The rejected save now queues a re-baseline of the
  request keys and refreshes, and conflict with those keys is suspended
  until the refreshed status lands and re-anchors them (state, not a ref,
  so the re-baseline provokes the render that clears it).

Both pinned by cases that go red without the fix: no completed badge after
a plugins -> home -> plugins round trip while the sibling always-hidden
case stays green, and no conflict plus a retryable Save after a partially
landed save.

* fix(web-shell): stop certifying unfreshed secondary workspace status

Address the round-8 Critical (body-level, independently confirmed by
qqqys): the secondary catalog page is loaded and polled only while the
Home column is hidden, but the rail Home button folded its retained
snapshots into the indicator unconditionally. An approval resolved
elsewhere kept painting the amber dot and the "Waiting for approval"
accessible name until the user hid the Home column again — two render
sites for one stale certification, and a regression over the pre-PR
surface, which could only render the dot from a live, fetchable path.

build the rail indicator from the primary page plus only those secondary
sessions whose queries are currently armed. While the Home column is
visible the secondary contribution is dropped entirely; hiding the Home
column re-arms the queries and the still-waiting state comes back. On
live-state daemons the secondary catalog path is unloaded regardless, so
nothing live is hidden by this gate. Pinned by a plugins -> home ->
plugins round trip asserting both the dot attribute and the accessible
name, red without the gate while the always-hidden attention case stays
green.

* fix(web-shell): gate the completion tracker baseline on page freshness

Address round-9 Critical R9-1: nulling the secondary baseline on the
visible path closes only half the leak. On re-entering the hidden
surface the tracker installed the store's retained, never-refetched
catalog page as the new baseline (the guard rejected only loading and
error, never a stale page), so the poll arriving seconds later was
diffed against a snapshot taken before the visible window and a
completion the user watched was repainted as unread — exactly what the
visible-path null was supposed to prevent.

Stamp the moment the surface becomes hidden and refuse to install or
compare any secondary snapshot that is not provably fresh
(`(snapshot.updatedAt ?? 0) < hiddenSince`, an unstamped page counting
as not-fresh). The first after-hidden poll then installs the baseline
from truly fresh data. The strengthened round-trip case drives retained
running -> stale re-entry -> fresh idle; it is red without the gate
(the completion badge and the Finished accessible name both appear) and
green with it, while the always-hidden completion case stays green.

* fix(web-shell): clamp every collapse entrance and clear More on rail teardown

Address round-9 Criticals R9-2 and R9-3 (body-level):

- The split-view fold-band clamp lived only at the Cmd+B entrance; the
  rail's Collapse/Expand button reached handleSidebarCollapsedChange
  unclamped, so a click that visibly does nothing still overwrote the
  stored preference and a stored `true` could never be re-written inside
  the band. The clamp now lives once in the shared writer: in the fold
  band it re-stores the current raw preference regardless of which value
  was requested. (`sidebarCollapsed` and `splitFoldedSidebar` reach the
  stable []-callback through refs — the former would close over the
  mount-time value and the latter is declared later, TDZ.) The Cmd+B
  site shrinks back to passing the toggle intent. Pinned by a third arm
  in the fold-band App test that clicks the collapse toggle at 1100px
  and finds the stored `true` untouched — red with the clamp removed.
- The More reset effect did not watch layout, so a rail -> single ->
  rail round trip left moreOpen=true against an unmounted owner and the
  rail returned with a phantom open menu (content in the document,
  focus inside, never reopened). `layout` joins the dependency list and
  a round-trip case in WebShellSidebar.collapse-persist.test.tsx goes
  red without it.

* fix(web-shell): let exit-split navigation restore the column under the fold clamp

Address maintainer P2 inline: after the shared-writer clamp landed, a
Home click from the split-view fold band was still intercepted — the
rail's restoresColumn `onCollapsedChange(false)` wrote the folded raw
value back (correct for taps that stay in split view), and `onOpenHome`
then closed split view with the raw preference unchanged, leaving the
Home column folded until a second click.

Split the intent at the shared writer: `exitFoldBand` marks entrances
that reorganize the layout away from split view, bypassing preservation,
and the rail Home handler uses it when the fold band was the overriding
state at click time. Taps that stay in the fold (Cmd/Ctrl+B, the rail
Collapse/Expand button) keep their preference protection untouched, and
the write still never happens inside the mobile drawer, where
splitFoldedSidebar is forced false by construction — so the drawer case
is unaffected (drawer protection is preserved).

Pinned by a fourth arm in `does not invert the stored collapse
preference in the split-view fold band`: stored `true` at 1100px split
view, click `[data-testid="open-home"]` once, and the column is expanded
with the stored key `false`; dropping the bypass inverts that arm while
the three protected arms stay green.

* fix(web-shell): cover Channels and Live in the exit-split fold exception

Address maintainer P2 follow-up: the exitFoldBand carve-out applied only
to Home, so Channels and Live — whose `openPanel` call exits split view
through the web-shell page set (`setMainView('chat')`) the same way —
still had their restoresColumn write clamped when the fold band owned
the sidebar, leaving the section column folded until a second rail tap.

Extent the same bypass to those two entrances, with each entry's own
column-existing gate: Channels applies it only when channelSidebarEnabled
would actually produce a column (mirroring the rail's
channelNavigationEnabled rule, which still leaves the full-page channels
variant folded), and the Live handler inherits the liveSidebarEnabled
guard it's already defined under.

Pinned by two parallel cases beside the fold-band keyboard/click arms:
stored `true` at 1100px split view → one rail tap on `[data-testid=
"open-channels"]` (with `session_source_metadata`) and one on
`[data-testid="open-live"]` (with showLive) — each expands the column and
writes `false`; dropping both bypasses turns both cases red while the
protected Home arm stays green.

---------

Co-authored-by: 钉萁 <dingqi.jww@alibaba-inc.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: Shaojin Wen <szujobs@gmail.com>
2026-10-05 08:13:30 +00:00
易良
fde56a8203
fix(serve): let Web Shell trust a workspace without a terminal (#13146)
* fix(serve): let Web Shell trust a workspace without a terminal

Folder trust fails closed: with `security.folderTrust.enabled`, a folder
that no rule decides is untrusted, and the only way to record a decision
is the terminal prompt or `/permissions`. When `trustedFolders.json` is
missing or unreadable, every registered workspace resolves untrusted at
once and a Web Shell or Desktop client has no way back in. The existing
`POST /workspace/trust/request` only publishes a request for a local
operator, which those clients cannot satisfy.

Add an operator-authority route that records the decision itself:

  POST /workspace/trust/grant
  POST /workspaces/:workspace/trust/grant
  _qwen/workspace/trust/grant

It reuses the strict mutation gate and the existing trusted-folder
writer, always targets the workspace the request already resolved to,
and returns the status the workspace reports after the write. Refusals
mirror the request route: folder trust disabled, managed scratch, live
conversation, inactive runtime, unreadable trust file. Grant only; there
is no revoke counterpart.

The route is registered unconditionally and advertised through a new
`workspace_trust_grant` capability that reuses the trust hot-reload
toggle, so the Web Shell offers the action only where the decision
reaches the running runtime without a restart, and an older daemon shows
no affordance at all.

Fixes #13130

* test(serve): add workspace_trust_grant to the integration capability baseline

The route registration added workspace_trust_grant to
EXPECTED_REGISTERED_FEATURES in serve/server.test.ts but not to the second
baseline list in integration-tests/cli/qwen-serve-routes.test.ts, which
deep-equals the live server's list and therefore failed deterministically.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(serve): document workspace_trust_grant in the protocol table and route envelope

The new conditional tag was added to CONDITIONAL_SERVE_FEATURES but not to
docs/developers/qwen-serve-protocol.md nor to the advertised-feature list in
integration-tests/cli/qwen-serve-routes.test.ts, so capabilities-docs-contract
and the capabilities envelope assertion both failed.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-conflict/jmupfte0zk0

* fix(serve): admit SSH trust/grant passthrough and pin grant guards

- Add trust/grant to the REST SSH boundary POST passthrough so the
  workspace-qualified grant route matches its ACP SSH_METHODS twin;
  without it the Web Shell Trust button on an SSH workspace could only
  ever answer 501 (R1-2).
- Reword the workspace_trust_grant capability row: the tag means the
  running runtime hot-applies the decision, not that the grant routes
  exist — they are registered unconditionally (R1-1).
- Pin both grant routes' 503 workspace_runtime_unavailable guards with
  a transitioning primary and a draining secondary (R1-10).
- Make the ACP grant dispatch test's pre-write status untrusted so the
  asserted reply can only be the post-write grant result (R1-9).

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmupzgp080a

* docs(serve): narrow three workspace-trust claims to shipped behavior

Review round 4 of #13146, doc-accuracy only. Three sentences this PR
introduced stated something the code does not do:

- qwen-serve-protocol.md: the workspace_qualified_rest_core rule said a
  capability may only define a narrower *read-only* policy, but this PR
  adds a grant mutation accepted for a registered untrusted workspace.
  Name workspace_trust_grant as the exception.
- web-shell-workspace-trust-grant design doc (EN + zh-CN): the Refusals
  row promised 500 trusted_folders_invalid for a malformed, unreadable or
  non-regular trust file. A dangling symlink reads as no file and is
  replaced with a 200, corruption the write itself discovers answers
  internal_error, and the load verdict is cached with no production
  reset. Note all three; the writer is unchanged here.
- trusted-folders.md: a missing file leaves every folder untrusted, but a
  malformed or unreadable one is a fatal configuration error and the
  trust routes answer 500. Split the two cases and scope the Web Shell
  recovery sentence to the no-rule case.

No code changes. Code-side repairs for the divergences above are tracked
separately.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmuq3r0k30o

* docs(serve): correct three workspace-trust claims to measured behavior

Design doc (en + zh-CN twin):
- The "Already trusted" refusal row now names its precondition. A
  successful grant starts a rebuild that moves the entry to
  `transitioning`, and while it is there both grant routes answer 503
  `workspace_runtime_unavailable` with `Retry-After: 1` even though the
  trust record is already written, so the row's unconditional idempotent
  200 did not hold for that window.
- The write-discovered corruption sentence was inverted. A symlinked or
  otherwise non-regular trust file, a document whose root is not a JSON
  object, and a rule carrying an invalid trust level all raise
  `FatalConfigError` from the writer, which `sendTrustError` maps to 500
  `trusted_folders_invalid`. Only a syntactically invalid document that
  was still valid when the daemon first cached it reaches the write as a
  plain parse failure and answers 500 `internal_error`.

Protocol doc:
- The plural grant mutation's acceptance is not conditional on the
  `workspace_trust_grant` tag. Both grant routes are registered
  unconditionally and neither handler reads the capability, and the tag
  row already states the routes are served regardless. The tag reports
  hot-reload wiring only.

Docs only: no production code, route, or capability surface changes.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmuqkwas11i

* docs(serve): scope the trust-grant guarantees to shipped behavior

- qwen-serve-protocol.md: the workspace_trust_grant row no longer states
  live application unconditionally — a higher-precedence source (IDE trust
  for the daemon's own cwd, a settings/trust-file load error) still wins,
  and the response's effective.source names the winner; the tag-absent case
  now says no writer applies live without hot-reload wiring (the terminal
  prompt included) instead of pointing at it as a remedy (R2-3).
- trusted-folders.md: the malformed-file remedy gains the daemon-restart
  step (a running daemon caches the failed load and keeps refusing the
  Trust action), the fail-closed claim is scoped to folders no
  higher-priority signal decides, and the 500 claim is narrowed to the v1
  routes with the v2 status route's 200 + configured.state:"error" shape
  named (R1-15).

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(serve): fail a trust grant that does not take effect

grantWorkspaceTrust answered 200 on the write alone, never checking that
the decision it recorded is the one the trust evaluator reaches. With an
equal-depth DO_NOT_TRUST rule under an alias spelling of the same
directory, a settings error, or an IDE distrust of the daemon's own cwd,
the new TRUST_FOLDER entry loses and the workspace stays untrusted — the
panel spins to its 30s deadline and silently reverts. After the write,
re-evaluate with the same evaluator the reconciler drives (reading the
post-write in-memory rule set, so no disk round-trip), and answer 409
trust_grant_ineffective when the grant did not take. Refusals tables
updated in both design-doc languages.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* docs(serve): scope the trust-grant ACP and 503 claims to shipped behavior

- Endpoints: state the Qualified row's ACP precondition. An untrusted
  non-primary workspace is refused 403 untrusted_workspace at the mount
  resolver before any dispatcher exists, so for the locked-out secondary
  this design exists to recover, the ACP column is undispatchable and
  recovery is REST-only.
- Advertising: the consistency claim covers the capability tag and the REST
  surface, not the ACP counterpart.
- Refusals: the 503 during a rebuild is retryable-until-applied, but the
  reference panel reports every rejection identically (#13186). And
  re-keying a path only replaces an equal-depth rule under that spelling,
  so an alias-spelled DO_NOT_TRUST survives the write and the grant answers
  409 trust_grant_ineffective.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmuqvm3p20y

* test(serve): pin SSH admission of workspace/trust/grant

The SSH capabilities test swept the already-filtered advertised list and
asserted only arrayContaining membership plus the absence of an
ssh_workspace_operation_unsupported frame, so every assertion stayed true
when the list shrank. Dropping 'workspace/trust/grant' from SSH_METHODS left
the suite green while SSH-backed workspaces lost the only in-app trust
recovery the design doc promises.

Assert membership of '_qwen/workspace/trust/grant' in the advertised array.
The sweep loop is unchanged: it still drives each method through
Promise.allSettled against a dispatcher with no workspace service.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmuqvm3p20y

* fix(serve): reply to a trust grant whose own write retired the generation

The ACP `_qwen/workspace/trust/grant` case re-asserted the captured
generation guard after the grant. A successful grant notifies the trust
monitor, whose reconcile calls `registry.beginReplacement` and closes
that very guard, so an operation that had already written and applied the
decision was answered with `-32603` / `workspace_runtime_unavailable` /
503 while the REST twin answered 200. Keep the pre-write assert: for this
method the generation closing is the success signal. The dispatch test now
closes a real guard inside the spied grant, so re-adding the post-write
assert turns it red.

Also bring two documents back to the implemented contract: the
`workspace_trust_grant` protocol row and the Web Shell bullet in
trusted-folders.md. A refused grant answers 409 with only `error` and
`code: "trust_grant_ineffective"` and carries no `effective` member, three
causes can defeat a grant rather than two, the Trust action keys on the
advertised capability rather than on route presence, and on a daemon
without hot reload the v1 trust status does report the recorded decision
while the runtime gates wait for a restart. Drop the never-read
`state`/`source` fields from `WorkspaceTrustGrantIneffectiveError`.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmurpmcje1k

* test(serve): forward the request context in the trust-grant dispatch spy

The new `_qwen/workspace/trust/grant` dispatch test bound
`fakeWorkspace.grantWorkspaceTrust` and re-invoked it with no arguments.
`fakeWorkspace` is closed with `as unknown as DaemonWorkspaceService`, so the
bound reference carries the interface arity
`grantWorkspaceTrust(ctx: WorkspaceRequestContext)`, and `tsc --build` failed
with TS2554. That runs inside the `prepare` hook of `pnpm install`, so the
single type error took down every job at its Install dependencies step.

Forward the context the dispatcher already supplies so the mock keeps calling
the real fake with the same argument.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-conflict/jmurtjt4g1q

* fix(serve): give the ACP trust-grant refusal a branchable 409 frame

`WorkspaceTrustGrantIneffectiveError` had no arm in `toRpcError`, so the
name-keyed switch fell through to the default arm and ACP clients received
`{code: -32603, message: 'Internal error', data: {errorKind: 'internal'}}`
for a refusal the REST twin answers as `409 { code: 'trust_grant_ineffective' }`.
SDK transport negotiation prefers acp-ws/acp-http over REST, so that opaque
frame was the default client path: a client could not tell a durably recorded
but outruled grant from a daemon fault, and could not stop retrying.

Map it beside the other typed refusals, following the shape the file already
uses for a 4xx-classified conflict (`data.httpStatus` carries the 409, the
JSON-RPC code agrees with the REST classification). The message is the same
client-safe string REST already ships, so nothing new is disclosed.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmus0c5f620

* fix(daemon): Verify trust grants against persisted policy

* fix(serve): preserve effective workspace trust grants

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: yiliang114 <yiliang114@users.noreply.github.com>
2026-10-04 08:35:05 +00:00
易良
5374641323
feat(memory): Opt-in selector skip for a unique new recall hit (#13158)
* feat(memory): opt-in extraction cooldown and selector skip experiments

Moved out of #13020, where they had been bundled with unrelated
deferred-tool work. Content is the #13020 head (ffd9df8928) for these
files and hunks, applied onto current main; history and review threads
for these changes live on #13020.

- #13004: after a no-op auto-memory extraction, an opt-in cooldown
  (QWEN_CODE_MEMORY_EXTRACT_NOOP_COOLDOWN_TURNS, off by default) skips
  extraction for a bounded number of turns while the prompt stays below
  the compaction warn threshold. Skipped turns stay inside the
  extractor's window (CACHE_SAFE_HISTORY_TAIL_ENTRIES), and a run that
  makes no progress advances the window instead of freezing it.
- #13003: an opt-in flag (recall-experiment.ts, off by default) skips
  the recall selector when there is a single strong hit; the result is
  delivered as the fast phase, and a low-cardinality selector_skipped
  attribute is added to the recall log and metrics.

Refs #13004
Refs #13003

* fix(core): Preserve the zero-tool extraction cursor guard

* fix(memory): Avoid extraction from runtime reminders

* fix(memory): Preserve user text across reminder parts

* fix(memory): Bind extraction cursor to history

* test(memory): Require exact same-length replacement

* fix(memory): Wait for queued tasks during drain

* fix(memory): Consume ineligible history tails

* docs(memory): Record tail context acceptance limit

* fix(memory): Recover reported writes after extraction failure

* docs(memory): Clarify best-effort refresh recovery

* fix(memory): Suspend repeated failed extraction windows

* fix(core): close the cooldown-arm and review-round findings on the memory PR

- The no-op cooldown now arms on a completed no-op whose cursor ADVANCED
  (extract.ts reports `cursorAdvanced`), not on reaching the live end —
  the windowed arm stops short of history.length by construction, so a
  tool-heavy session never armed it and the experiment saved nothing
  exactly where #13004 measured the waste (R1-1).
- A media-only user turn counts as user content for the extraction gate;
  an empty-text read advanced the cursor past a turn no extractor ever
  sees, and the cursor only moves forward (R1-11).
- The queued trailing extraction's rejection is observed with a warn log
  instead of vanishing into track()'s in-flight removal (R2-9).
- `selector_skipped` is stamped only on structured-mode recalls, where a
  skip decision exists; legacy recalls left the field at a constant false
  and mixed into the experiment's control series. The metric dimension
  attaches only when the knob is on AND the field is a boolean (R2-14).
- The docs now say so: the structured-recall contract (both languages)
  marks selector steps conditional under the knob, and the telemetry
  inventory lists selector_skipped with its gating conditions (R2-11).
- Pins: cooldown arm on a windowed no-op + held-cursor non-arm + arm-point
  accumulation across consecutive skips (manager.test.ts); media-only user
  turn + cursorAdvanced assertions (extract.test.ts); legacy-unset event +
  dimension drop (recall.test.ts, loggers.test.ts).

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(memory): Count pending backlog from the extraction cursor

* fix(memory): Flush pending extraction before compaction

* fix(memory): Narrow selector experiment and withdraw unsafe cooldown

* fix(memory): widen the #13003 skip guard, narrow selector_skipped

Two review findings on the opt-in skip-selector guard, each reproduced at
b4e0bba5b8 before fixing.

The uniqueness test counted only the published fast list -- the top
MAX_RELEVANT_DOCS of the lexical ranking -- while the selector it suppresses
is handed modelCandidates, whose lexical half is the top
MAX_MODEL_CANDIDATE_DOCS - RECENT_MODEL_CANDIDATE_RESERVE, roughly 36x wider.
A second strong title/keyword match ranked 6th or lower was therefore
invisible to the guard: an ambiguous recall skipped the selector and still
reported selector_skipped: true, landing in the treatment arm as a "unique"
strong hit. selectModelCandidateDocuments now also returns the strong-match
count over the lexical array it already scored. The count is computed only
while the guard is live, so knob-off recalls pay nothing on a path that
carries a latency budget, and the flag is now read once per recall so the
count and the guard cannot disagree.

logRecallResult also stamped selector_skipped: false on the empty-query /
empty-corpus / non-positive-limit short circuit, where the selector is never
called. Those trivially fast recalls joined the ablation's control arm, which
the treatment arm structurally cannot contain, and recordMemoryRecallMetrics
puts only strategy and selector_skipped on the counter and histogram, so the
samples could not be sliced back out. logRecallResult now takes the caller's
decision state and omits the field when no skip decision was made; the
heuristic fallback keys off selectorStartedAt so a selector failure still
reports false as a genuine control sample.

Both fixes are mutation-checked: reverting either one reds exactly the tests
added for it (1 failure and 2 failures respectively).

Co-authored-by: Qwen-Coder <qwen-coder @alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmurnh6s41i

* style(memory): let Prettier reflow the suppressed-selector assertion

Lint & Static failed at step "Run Prettier" on 34894db1d6 with
"[warn] packages/core/src/memory/recall.test.ts / Code style issues found
in 1 file". Prettier 3.6.1 reproduces it locally and --write produces this
two-line reflow of the `mock.calls[0]?.[2] ?? []` member chain; the
expression is unchanged, so this is formatting only.

Verified: prettier --check now passes on this file and on all 13 files the
branch changes. Test execution is left to CI (no node_modules in this
worktree).

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-conflict/jmurrend91n

* fix(memory): require a token boundary for a short title in the #13003 skip gate

R3-1: the skip gate made matchesTitleOrKeyword the final authority on whether
the model selector runs at all, but its title arm is an unbounded substring
test while the keyword arm already guards short Latin strings with a
token-boundary regex. A memory titled `ai` therefore matched the query
`explain provider fallback.`, and as the unique strong hit it suppressed the
selector: every other memory the selector would have added was lost, and the
recall entered the ablation's treatment arm as a genuine "unique strong hit"
when the match was a coincidence.

The injected document itself is not what this fixes. isStrongFastMatch keeps
the loose arm, so a coincidental short-title substring was and still is
published as a fast candidate; the refined selector result is filtered against
fastDeliveredRefs (core/client.ts:1656), so it can only add to the fast phase,
never retract it. What the gate changed is that the match also cancelled the
selector, and that is what is closed here.

Reproduced at 279c5440eb: the new test fails there with the selector never
called, and passes after the fix (88/88).

The guard is applied only at the gate, so lexical ranking and the default
(knob-off) path are unchanged and the design doc's "default behavior is
unchanged" claim still holds. The gate's title test reuses the keyword arm's
existing boundary rule instead of adding a second matcher; both design docs
now state it. lexicalStrongMatchCount deliberately keeps the loose arm and now
says so, because a looser count under-fires the skip, which is the safe
direction for an ablation.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmurtwo5e1r

* fix(memory): make the #13003 selector-skip gate require a real unique hit

The strict title arm delegated to `includesKeyword`, whose token-boundary
rule is gated on `/^[a-z0-9]{1,2}$/`, so for any Latin title of three or
more characters the strict and loose arms were byte-identical: a memory
titled `log` cancelled the model selector for a query containing
`catalog`. Apply the boundary rule to any single-word Latin title when
`requireTitleBoundary` is set; multi-word and non-Latin titles keep the
loose arm, and ranking callers are untouched.

The uniqueness count also ran over `lexical`, while the selector the gate
suppresses is handed `modelCandidates` — whose `recent` reserve is built
from `eligible`. A doc whose only strong-match evidence is a keyword that
is a proper substring of a query token scores 0, never enters `lexical`,
and so was never counted, yet it sat in the suppressed selector's pool.
Count the pool the selector actually receives, and rename the field to say
what it counts (title/keyword matches, deliberately not `isStrongFastMatch`
— that predicate's loose arm would empty the experiment's treatment arm).
Both changes only ever raise the count, so the gate skips strictly less
often.

State the real gate condition in both design docs, and pin
`selectorDecided` in both directions: each constant mutant of the
heuristic-fallback argument now fails a named assertion.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmus4mgzr28

* fix(memory): require a token boundary for every non-CJK value in the #13003 skip gate

The strict arm keyed its boundary rule on value shape: `^[a-z0-9]+$` for a
title and `^[a-z0-9]{1,2}$` for a keyword. That left the coincidental inner
substring the gate exists to reject reachable through every other shape: a
keyword of three or more characters (`log` inside `catalog`, the collision the
gate's own rationale names), a multi-word Latin title (`log conventions` inside
`catalog conventions`), and a single-word non-ASCII title (`ток` inside
`поток`). Each suppressed the selector, dropped the refined phase over up to
200 candidates, and put a sample that is not a unique strong hit into the
ablation's treatment arm as `selector_skipped: true`.

Express the strictness once, on the script split this module's own tokenizer
already draws: a non-CJK value goes through `includesAtBoundary`, a CJK value
keeps `includes`, because a boundary rule needs a `[^\p{L}\p{N}]` neighbour
while Han, Kana, and Hangul are `\p{L}`. Both arms share it and it stays
behind the gate flag, so ranking callers and knob-off recall are unchanged.

Pinned by three tests that are red without the fix. The design-doc sentences
scoped the rule to single-word Latin titles; both twins now describe both arms.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmusfc9vi2o

* fix(core): preserve memory selector freshness and delivery phase

* fix(memory): reject concurrent document changes during reread

* fix(memory): tighten selector skip boundaries and log attributes

* fix(memory): clarify unstable reads and cover selector edges

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: yiliang114 <yiliang114@users.noreply.github.com>
Co-authored-by: Qwen-Coder <qwen-coder @alibabacloud.com>
2026-10-04 07:48:44 +00:00
XIQI
576689d073
fix(channels): clean up owned workers after config loss (#11071)
Some checks failed
SDK Python / Classify PR (push) Has been cancelled
SDK Python / SDK Python (3.12) (push) Has been cancelled
SDK Python / SDK Python (3.10) (push) Has been cancelled
SDK Python / SDK Python (3.11) (push) Has been cancelled
Qwen Code CI / Desktop Shell (windows-2022) (push) Blocked by required conditions
E2E Tests / Build for E2E (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 1/1 (push) Blocked by required conditions
E2E Tests / E2E Test (Linux) - sandbox:none - shard 1/1 (push) Blocked by required conditions
E2E Tests / E2E Test - macOS - shard 1/2 (push) Blocked by required conditions
E2E Tests / E2E Test - macOS - shard 2/2 (push) Blocked by required conditions
E2E Tests / E2E Interactive - OpenTUI renderer (bun) (push) Blocked by required conditions
SDK Java / ubuntu-latest / Java 17 (push) Waiting to run
SDK Java / macos-latest / Java 21 (push) Waiting to run
SDK Java / ubuntu-latest / Java 21 (push) Waiting to run
SDK Java / windows-latest / Java 21 (push) Waiting to run
SDK Java / Flyway migration version uniqueness (push) Waiting to run
E2E Tests / channel-plugin E2E (nightly) (push) Blocked by required conditions
E2E Tests / cron-interactive E2E (nightly) (push) Blocked by required conditions
E2E Tests / web-shell Browser Regression (push) Waiting to run
Android Mobile Shell / Build, unit tests, and lint (push) Waiting to run
Android Mobile Shell / Keystore and profiles (API 26) (push) Waiting to run
Android Mobile Shell / Keystore and profiles (API 36) (push) Waiting to run
SDK Java / ubuntu-latest / Java 11 (push) Waiting to run
SDK Java / Runtime Broker and Managed Agent MariaDB / Java 21 (push) Waiting to run
Verify Landlock helper / Native Windows settings and streams (push) Waiting to run
SDK Java / Hosted process fault gates / MySQL 8.4 / Java 21 (push) Waiting to run
SDK Java / Real daemon E2E / Java 11 (push) Waiting to run
Verify Landlock helper / Native Linux CLI boundary (push) Waiting to run
Verify Landlock helper / arm64 (push) Waiting to run
Verify Landlock helper / x64 (push) Waiting to run
* fix(channels): clean up owned workers after config loss

Addresses #11063 for a single missing-config owner. Preserve revision and runtime ownership checks and make absent settings removal idempotent.

* fix(channels): clarify missing-config ownership diagnostics

Address PR #11071 review with named ownership checks, distinct rejection diagnostics, and explicit passive-read assertions. Preserve the current deletion contract.

* fix(channels): gate missing-config delete convergence on persisted startup state

* fix(channels): harden missing-config delete convergence

- Re-read the merged channel config view after the worker stop window
  and fail closed with channel_settings_conflict when a configuration
  reappears at another scope mid-delete: the optimistic-concurrency
  token covers only the workspace's own scope files.
- Reject the convergence while the worker manager is mid-transition:
  a starting or reconciling manager reports the candidate selection as
  no workers and nothing committed, which reads as the silent shape
  without being it.
- Gate the "another scope" refusal on a record-valued merged entry so a
  legacy scalar entry in the workspace's own file converges instead of
  being misattributed to user or system settings.
- Pass pre-loaded settings with skipLoadEnvironment to the injected
  loadChannelsConfig so the gate read no longer writes the target
  workspace's .env into the daemon's process-global environment.
- Deduplicate the ownership invariant on the missing-config path:
  assertConvergeableRuntimeOwner now returns the confirmed owned-worker
  outcome instead of remove re-deriving it through a provably dead gate.
- Correct both doc pages: the channel_instance_not_found promise only
  holds when the selected workspace's resolved scope does not own the
  configuration, and a home-directory workspace resolves that scope to
  the shared user file.

* fix(channels): reject configured channel delete while runtime is mid-transition

The mid-transition guard lived only on the missing-config branch, so a
DELETE of a configured channel landing during the first-start window —
when committedChannelNames() still reports nothing — skipped both the
ownership check and the worker stop, deleted the configuration, and
answered 200 with runtime stopped while a worker for that name was
coming up. Hoist the settled check above the branch split so both
branches inherit it, and pin the configured shape with a test.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): narrow mid-transition channel delete guard to at-risk channels (#11071)

---------

Co-authored-by: 西岐 <fengqi.mfq@alibaba-inc.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: qwen-code-autofix <qwen-code-autofix[bot]@users.noreply.github.com>
Co-authored-by: qwen-code[bot] <qwen-code[bot]@users.noreply.github.com>
2026-10-03 12:08:38 +00:00
destire-mio
612a552959
feat(channels): add IMAP and SMTP email channel (#12939)
* feat(channels): add IMAP and SMTP email channel

* test(channels): include email in managed channel catalog expectations

---------

Co-authored-by: destire-mio <248462155+destire-mio@users.noreply.github.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
2026-10-03 02:54:25 +00:00
易良
45ee202cb1
feat(agents): add isolated remote Qwen runtime hosts (#12582)
* feat(serve): join a coordinator as a runtime with one link

`qwen serve --join <link>` takes the single link the coordinator shows
under Agent → Runtime → Add runtime (<coordinator>/join/<workspace>/<token>)
instead of --agent-host-server, --agent-host-workspace-id and the
QWEN_AGENT_HOST_ENROLLMENT_TOKEN variable typed separately. The token is
single-use and short-lived; the host saves its own credential after
enrolling, as before.

A host now advertises every program it can run (Qwen Code always, Codex
when its CLI is on PATH or was asked for) so one machine appears as one
runtime offering several programs.

* feat(web-shell): Agent page tabs and a one-line Add runtime dialog

The Agent page now always shows its three tabs (Agents, Conversations,
Runtimes); they were hidden whenever the page was opened from the
sidebar, which instead carried three sub-links. The sidebar keeps a
single Agent entry.

Add runtime is a dialog: it issues a single-use join token, shows
`qwen serve ... --join '<link>'` (and an npx variant) with copy buttons,
warns when the address is localhost or plain HTTP, and waits — the live
stream flips it to "<name> is connected", listing the programs that
machine offers, with a shortcut to create an agent on it. The old inline
enrollment and remote-connect forms are gone; connecting an existing
serve by address and token is the dialog's second tab. The client no
longer builds the long enrollment command itself.

Removes web code the PR audit confirmed dead: ThreadConversations.tsx,
summariseWorkspaceWork, ThreadGroup.label, the unreachable create-method
chooser in AgentCreatePage, the initialCreateTask/api/runtime/loading
props, unused CSS module classes and a duplicated prop spread.

* refactor(agents): remove dead workspace-agent code confirmed by audit

Drops the scripts/audit harness (not wired into CI), request capture,
the Codex turn-result adapter, the notification pipeline nothing could
trigger, unused store/dispatcher/lease/A2A exports and legacy
background-agent plumbing. Recover only reports work when a parent
report is actually pending.

* fix(web-shell): hide agentCollaboration from the settings message

The experimental flag has no alias in the settings dialog map, which the
alias-drift test rejects.

* feat(agents): bind agents to a runtime program and share them over A2A

An agent on a runtime can pick the program to run (Qwen Code or Codex)
when that runtime offers both; the host runs each assignment with the
program its agent is bound to and falls back to its default otherwise.

Agents can be shared with callers outside the workspace: each share is
its own A2A grant with a 7-day expiry, the token is shown once with a
ready-to-run SendMessage curl, and any share can be revoked.

A failed run's retry row now hides while that agent has a live run and
once the conversation is closed.

* fix(agents): push runtime liveness changes, not every heartbeat

Heartbeats rewrite hosts.json every five seconds, and each write made
every open tab refetch the whole roster and thread list. A runtime that
died wrote nothing, so it never showed as offline without a reload.

The event stream now compares what a person can see (runtimes, their
programs, online or offline) on each hosts.json write and on a
five-second timer, and only announces a change. Add runtime also
recognises a machine that rejoins under its saved identity.

* feat(web-shell): polish Agent page, New agent and the @ picker

- New agent needs only a name; it can start from a .qwen/agents role,
  preselects the runtime it was opened for, and shows both programs
  with the reason one is unavailable.
- Agent rows read "Program · Runtime", offer "@ Mention" and fold
  configure/share/pause/retire into a menu; moving an agent to another
  runtime keeps its program.
- The @ picker shows each agent's program and runtime and ends with
  "New agent…"; at-items can now be actions via onSelect.
- The collaboration composer says who a reply reaches and that other
  members are not interrupted; skip reasons and leftover strings are
  localised.

* feat(agents): show a running agent's tool calls as steps

A running member now lists its latest tool calls under its status line,
one per line with running, done or failed, like a CI job's steps. The
turn stream reports updates as an object so steps travel with stage and
text; remote runtimes still report stage and text only.

Also: subtasks in the team panel name their assignee, join links last
15 minutes as the spec says, and a share created from a localhost page
warns that its address only works on this computer.

* fix(agents): skip a liveness check when the store is busy

A failed hosts.json read looked like every runtime leaving, which
announced two spurious changes.

* feat(agents): let a person's post pass the token budget

The tree-wide token cap now binds only agent-authored posts, like the
turn budget: agents on an exhausted tree stop waking each other, while
a reply from a person still books work. Every automated path posts as
an agent or the system, and A2A intake always starts a new root thread,
so nothing unattended gains a bypass.

* refactor(agents): move cross-runtime and A2A to a follow-up PR

This PR now carries local collaboration only: agents on this computer,
shared threads, streaming, waiting states, the @ picker and the Agent
page with Agents and Conversations tabs.

Moved out, to return in a stacked PR: agent hosts (join links, the
--join and --agent-host-* flags, enrollment, heartbeat, lease and
pickup routes, the host client and Codex host sessions), per-agent
runtime and program binding, the Runtimes tab and Add runtime dialog,
the A2A server, grants, external intake and agent sharing, and the
@a2a-js/sdk dependency. Records written by builds that had them keep
loading; the extra fields are ignored.

* feat(agents): run agents on other computers and share them over A2A

Brings back everything #11206 moved out, stacked on it: agent hosts
(join links, the --join and --agent-host-* flags, enrollment,
heartbeat, lease and pickup routes, the host client and Codex host
sessions), per-agent runtime and program binding, runtime liveness
events, the Runtimes tab and Add runtime dialog, the A2A server,
grants, external intake, agent sharing and the @a2a-js/sdk dependency.

This reverts commit 024df7e887.

* fix(agents): keep A2A grants on release; --join runs as a worker

Releasing the dispatch claim rewrote workspace.json from three fields
and silently revoked every A2A share. A daemon started with --join
still mounted its own collaboration routes and ran local recovery,
unlike one started with --agent-host-server.

* fix(web-shell): polish found by rendering the collaboration views

- The @ picker read its labels through useI18n() above App's
  I18nProvider, so its category, agent states and "New agent…"
  showed raw keys; App now passes its translator in.
- The team panel called a quiet run "connection lost" after 20 s
  without a snapshot, which a long tool call always triggers. It now
  judges by the agent's own activity, and its copy is localised.
- Context carried over from the originating chat is folded under the
  header instead of rendered as a large user message.
- Starting and resuming runs get their own status line.
- Agent rows keep their columns aligned, and a paused agent reads
  "Paused" rather than "Offline".
- The mesh-streaming spec aborts /events (it had thrown on it), runs
  in a zh-CN locale that survives reloads, and matches current copy.

* fix(web-shell): keep Add runtime and Share dialogs inside the screen

The dialog grid's implicit track sized to its widest child, so the join
commands and the share token widened the dialog past the viewport and
pushed the copy, Done and Revoke buttons out of view.

* feat(web-shell): a typed @name lists matching agents

Typing @re used to fall back to file search and show nothing, so an
agent was only reachable by first picking the Agent category. An @
provider can now claim a typed query that names no category; the agent
provider claims it when the query starts an agent's name, and anything
else (e.g. @src/) still searches files.

* feat(agents): show queue position; cover the live stream in e2e

- A queued run reports how many of its agent's queued runs start
  first (priority, then queue order, as the dispatcher takes them), and
  the status line reads "queued, N ahead" when N > 0.
- The mesh spec gains a case where a reply arrives only over the
  /events stream, so the push path is exercised, not just polling.
  Checked that it fails when the frame targets the wrong run.

* feat(agents): Claude Code on runtimes, program checks, remote steps

- Programs are one table in core (qwen, codex, claude), read by the
  host, the daemon's validation and pickup. Binding an agent to a
  program none of its runtimes offers is refused; at pickup a run is left
  for a runtime that has its program, and failed with
  agent_program_unavailable when none does, instead of waiting forever.
  Runtimes report program ids so the UI no longer guesses from labels.
- A runtime with claude-agent-acp offers Claude Code. It runs each
  assignment through the adapter in plan mode and rejects every
  permission request, so it stays inside the read-only boundary like
  Codex; its reply becomes the run's answer (no thread tools).
- Remote runtimes report tool steps. The daemon validates them (8
  steps, bounded titles, known statuses) before storing, and accepts the
  resuming stage a Codex resume reports.
- Tests for leases (isolation, expiry, stale results, program routing,
  steps), A2A grants (hashing, expiry, scope, revoke), external intake
  (idempotent retries, conflicts, per-caller scope) and the Claude
  session (plan mode, rejected permissions, no-plan refusal).

* fix(agents): type the ACP mode list in the Claude session

* fix(agents): import sanitizeChildEnv from its module

* test(serve): allow the Claude session's sanitized child environment

* perf(web-shell): keep collaboration strings out of the transcript bundle

The /export html transcript embeds the whole dictionary, and the
collaboration strings (about 22 KB in two languages) pushed it past its
1.3 MB budget once the runtime and share copy landed. They move to their
own module that the transcript build stubs, exactly as Live Voice's do;
tests guard the split, the stub's shape and that no transcript surface
looks a collaboration key up.

* fix(web-shell): mention Claude Code in the Add runtime description

* fix(agents): hand-offs that stranded parents or replayed finished turns

- A child thread that answers in plain text now reports to its parent;
  before, a waiting parent never heard back.
- Starting a thread from chat sends the person's message as the
  assignment, so the first run starts from it instead of receiving it
  mid-turn.
- A turn that ended before reading a later message completes, and the
  unread message goes to a successor run instead of replaying the turn.
- A status post earlier in a run no longer hides its final answer.
- The agent input receipt no longer needs chat recording.
- Smaller: charge usage on recovery failure, clear a requeued run's
  progress, reject retired assignees in thread_create, fix a subscribe
  race that could orphan file watchers, and cap the sidebar's live
  streams at two.

* fix(agents): remote runs that hung, leaked across grants, or lost answers

- The result upload takes up to 2 MB like progress, and a host stops
  retrying a result the coordinator refuses: it reports a failure once
  instead of holding the run forever.
- An A2A post books only the granted agent; an @name in the text no
  longer reaches another one.
- A cancel on a remote run whose lease ran out now ends; with the host
  gone it stayed cancelling and kept the agent busy.
- A remote Qwen turn refuses permission prompts nobody can answer, as
  the Codex and Claude paths do.
- One failed lease renewal no longer fails the run; only a lost lease or
  credential, or a lapsed lease, does.
- A2A callers get the agent's latest post as the task's answer artifact.
- Smaller: local prompts list remote peers again, a new lease holder's
  progress is no longer hidden behind the last one's, and Add runtime
  starts over after a join and offers a new link once one expires.

* fix(web-shell): collaboration screens in the reader's language

Rendering both languages showed the collaboration UI mixing them: run
triggers and status reasons arrived from the server in English (the
live-run line in Chinese), the task details view and the new
conversation form hard-coded one language each, and the sidebar load
error was Chinese only. All of it now goes through messages.ts, and the
server's fixed sentences are translated one for one on the client.

Also from the render:
- A streaming answer sorted by the run's start, so it sat above a
  status post the same run made later.
- Durations read "585 秒" in one place and "9 分 45 秒" in another.
- "Open conversation" floated unstyled above the details header; it is
  now a header button, and the conversation context is folded there as
  in the chat.
- Approval buttons use the main app's labels for allow and reject.

* fix(web-shell): agents from a daemon without runtimes no longer break the page

A daemon older than runtimes lists agents without a runtime. The Agents
page read runtime.kind and crashed, and the @ picker threw inside its
search and showed no agents at all. Both now treat a missing runtime as
this computer.

* test(web-shell): render the collaboration screens in the visuals preview

No visuals scenario reached the collaboration UI, so a PR that changed
it got an empty preview, and the one browser spec for it lacks @smoke
and never runs in CI. Six views in both themes now: the conversation,
its team panel and details, the @ picker in an ordinary chat, the
Agents page and the conversation list. One thread carries every run
state the screens tell apart, the server's own status sentences are
used so their translation is what renders, and each scene asserts its
content before capturing, so an empty picker fails instead of
photographing.

The spec is added to the base arm's allowlist so later PRs get a real
before/after instead of a "new scenario" every time.

* test(web-shell): render runtimes, joining, sharing and program choice

Adds the screens this PR introduces to the collaboration visuals spec:
the Runtimes tab, Add a runtime waiting for the machine to connect, the
share dialog with its token, and New agent on a machine that offers only
Qwen Code, so Codex and Claude Code show as unavailable. Times come from
the frozen capture clock and each scene waits for its token or runtime
before capturing.

* docs(mesh): record live collaboration verification

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* docs(agents): add linked English and Chinese service designs

* fix(agents): stop Claude adapters that ignore cancellation

* test(agents): run Claude subprocess checks on supported platforms

* docs(web-shell): clarify current A2A read-only permissions

* test(web-shell): add DAEMON_APPROVAL_MODES to sidebar delete-test mock

The mesh sidebar changes pull ThreadsRoute -> AgentCreatePage into this
test's module graph; AgentCreatePage reads DAEMON_APPROVAL_MODES from the
mocked daemon-react-sdk at module scope, so the suite failed at collect
time. Match the sibling sidebar mocks.

* fix(agents): preserve replay delivery and stop removed workspace pickup

* fix(agents): close review-found wedge and boundary holes in workspace agents

- Disabling an agent now settles its queued runs as cancelled in the
  roster transaction: a queued run of a non-addressable agent could never
  start yet pinned its thread in in_progress and blocked retirement, with
  nothing in-band explaining why.
- thread_wait admission no longer counts finishing runs: a finishing run
  already executed its close tool, so a wait admitted against it could
  never be discharged and stranded the thread as blocked.
- claimRun re-checks thread terminal status under the lock; candidate
  selection reads the thread list outside it.
- Agent store files are written 0600 with 0700 directories; transcripts
  hold full conversations and previously landed at the umask default.
- Web Shell: Retry is offered only for roster-addressable agents;
  hand-typed @lead detection applies the picker's enabled/retired
  predicate; the sidebar session merge keeps the daemon catalog order
  the organized bucketing relies on.

* fix(web-shell): add zh-CN entries for experimental.agentCollaboration

Merging main brought in workspace-settings-i18n.test.ts, which requires every
served setting to have settings.label.<key> and settings.description.<key> in
SETTINGS_MESSAGES_ZH. This branch added experimental.agentCollaboration to the
settings schema before that gate existed, so the merge surfaced exactly two
missing entries and failed Test (ubuntu-latest, Node 22.x) with
"expected [ ...(2) ] to deeply equal []".

Add the two zh entries next to the sibling experimental.agentTeam pair, in
schema order. English needs no entry: SETTINGS_MESSAGES_EN carries no
experimental.* labels and the Web Shell falls back to the schema's English
label/description.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-conflict/jmuiw1qih83

* feat(agents): split A2A sharing from remote runtimes

* refactor(agents): move A2A sharing into a separate PR

* feat(agents): add durable collaboration state

* refactor(agents): split web shell from execution

* fix(serve): revoke stale agent event streams

* feat(web-shell): add workspace agent collaboration UI

* chore(web-shell): drop historical mesh test log

* fix(web-shell): gate restored agent threads

* fix(web-shell): isolate agent collaboration scopes

* fix(agents): harden A2A intake boundaries

* fix(agents): harden remote runtime lifecycle

* fix(agents): bound host transport lifecycle

* fix(agents): isolate host preauth budgets

* docs(agents): drop superseded runtime plans

* fix(agents): stop a cancelled child from pinning a waiting parent

hasLiveDescendant counted any non-'open' child as live, so a cancelled
sub-thread with nothing left to deliver kept its parent's waiting run
forever — the no-live-dependency guard could never fire and the thread
stayed stuck in progress. A terminal child (done or cancelled) now
counts as live only while it still owes a pending parent_report; the
done-with-pending-report case keeps waking the parent as before.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(agents): preserve recoverable workspace state

* test(settings, tools): restore agentCollaboration registry coverage

The split refactor (8366c8d9) dropped the zh-CN entries while the setting
stayed served, which the i18n coverage gate rejects; the settings panel row
also lacked a published alias, which the alias-drift gate and the
settings-exclusions E2E both require. The six thread tools join the
tool-surface budget's not-budgeted list: they are registered only inside
agent-collaboration runs and never reach the general request surface.

* fix(agents): close second-round review findings in collaboration runtime

Core:
- Parent reports neutralize @ in child-controlled text so report content
  cannot suppress the assignee wake or wake the wrong agent.
- First delivery into a long thread discloses the retained pre-window
  prefix instead of committing the watermark past it silently.
- The DELTA section is bounded to the recent window with an explicit
  omission line; the rest stays reachable through thread_read.
- An unreported terminal run no longer suppresses child_blocked, while a
  reported one no longer double-reports; and a person-done thread never
  stacks child_cancelled on top of child_done.
- A stranded run is its own blocking obligation, human-released only,
  instead of an auto-releasable ordinary failure.
- The dead-end rule reads the most recent human admission, so a later
  system report cannot flip a genuinely blocked thread back to
  in_progress.
- Unknown @mentions now require a near-miss of a roster name, so prose
  like @media or @param no longer carries routing authority.
- Disabling an agent settles queued runs before the roster write, and the
  settle re-runs on retry, so a failed scan cannot half-apply the change.

Daemon/CLI:
- A rejected cosmetic session title can no longer turn a successful spawn
  into a terminal launch failure.
- The recovery sweep tests roster addressability (matching the DELETE
  teardown) and tears down workspaces where no agent can take work,
  draining pending parent reports first.
- Teardown awaits the owner's in-flight tick before releasing the host
  session, closing the orphan-spawn race.
- Session catalogs exclude both agent-host and agent body sessions via a
  plural excludeSourceTypes list, so --continue, pickers, and listings no
  longer surface machine-owned sessions; branch creation no longer
  conflicts with a resident mesh agent; restoring a finished agent's
  session strips the agent source so the transcript stays readable.

* fix(agents): keep status reasons English, seed unreadable thread

`ThreadStatusResolution.reason` is documented as "why, in a form a UI can
show beside the status", and every reason `resolveThreadStatus` returns is
English except the two `in_progress` ones, which were Chinese — a consumer
would render mixed languages to the user. Count them in English instead
("1 Agent is running", "2 Agents are running, 1 queued") and pin the
plural/mixed branch, which nothing covered before.

`store.test.ts` seeded the unreadable-thread case with `{}`. A record with
no `schemaVersion` makes `assertKnownVersion` raise `AgentSchemaVersionError`,
which `listThreadsUnlocked` deliberately rethrows instead of deferring, so the
test asserted the deferred "thread records are unreadable" error while the
code raised the fail-closed schema one. That is the red `Test (ubuntu-latest,
Node 22.x)` lane on 45a9668a8f (job 108654095052). Seed a versioned but
unparseable record, which is what "unreadable" means on this path; the
fail-closed schema behaviour keeps its own coverage in the version tests.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmukdzlxfat

* fix(serve): narrow the agent config patch on `apply` in the update route

`npm run build` failed on this branch with:

    packages/cli build: src/serve/routes/workspace-agents.ts(1607,38):
    error TS2722: Cannot invoke an object which is possibly 'undefined'.

which reddened both the OpenTUI no-flicker gate and the TUI parity snapshots
jobs (run 36331418407) at their shared "Build cli" step.

readAgentConfigPatch returns a union whose failure branch types `error` as
`string`, so the `if (config.error)` guard never discriminated it: an empty
string is falsy, the failure branch survived, and `apply` stayed optional.
The create handler already solved this by narrowing on `apply` itself; do the
same here. Gating on `config.touched` instead would have been wrong — the
managed-host persona check needs `nextConfig` to carry the stored agent even
when this request changed no config field.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-conflict/jmukizndjb2

* fix(serve): resolve the collaboration opt-in per workspace

The daemon mounted the agent routes from a boot snapshot that skipped
workspace scope, while every hosted session resolved the flag from the
full merge where workspace scope wins — the two could disagree about the
same workspace. The route gate and the recovery sweep now consult the
per-workspace merge (env var still overrides process-wide), so a flipped
workspace takes effect without a daemon restart: its routes answer 404
agent_collaboration_disabled, the sweep strands its leftover live runs
and tears the owner down. The routes are only mounted at boot when some
registered workspace has the flag on, and first-time enablement on an
otherwise-unconfigured daemon still requires a restart, which the doc
row and the setting's requiresRestart marker now say honestly.

* fix(agents): harden the workspace lock and validate run progress

- Give the workspace lock an onCompromised handler like every other
  proper-lockfile site, and tolerate the ERELEASED release that follows a
  compromise so the transaction's result still stands.
- Drop the per-workspace Mutex once nobody holds or waits on it, so a
  long-lived daemon does not keep one per workspace it ever touched.
- Validate the persisted `progress` snapshot: its outputText decides whether
  a completed run closes as unclosed, so a malformed value must not load.
- Replace citations of an external project and of review rounds with the
  reasoning itself; fix the stranded-runs copyright header.

* fix(serve): keep opted-out workspaces untouched and agent sessions owned

- Recovery skips an opted-out workspace that has no collaboration store
  instead of tearing it down, which read (and so created) the store every
  five seconds once any other workspace had opted in.
- A run whose task session is resident without its agent source (a person
  restored the transcript) closes it before reloading it as the agent's;
  attaching would run the turn without the persona, tool ceiling, or thread
  tools.
- `agent_collaboration_v1` is advertised only while the routes are mounted,
  so a workspace opted in after boot no longer gets a capability whose
  routes 404 until restart.
- A settings file that fails to parse keeps the last collaboration answer
  for that workspace rather than reading as "off" and stranding its live
  runs.

* fix(web-shell): follow the per-workspace collaboration opt-in

- A workspace that answers `agent_collaboration_disabled` is skipped by
  the sidebar: no load-failed notice, no event stream retrying a 404, no
  five-second poll. Streams open after the first read so none starts
  against such a workspace.
- An @ message whose roster cannot be read (collaboration off here, or the
  daemon unreachable) is sent as ordinary chat instead of failing, so
  `@README.md` in a non-collaboration workspace still sends.
- Status reasons are matched in the English wording the server now uses,
  including the stranded-run reason, which had no translation.
- A non-JSON error response no longer surfaces as a JSON parse error.
- The workspace session list is only re-sorted when collaboration rows are
  merged in, and a missing timestamp sorts last instead of yielding NaN.
- Move the remaining hardcoded collaboration strings into messages.ts.

* fix(agents): keep A2A work inside its grant and give it an end state

- An agent or system post in a tree opened over A2A books only the granted
  agent, and thread_create refuses to hand such a tree to another agent. A
  caller could otherwise ask the granted agent to relay "@other ..." and
  read the other agent's answer through GetTask. A local person can still
  bring anyone in, and the task's answer is the granted agent's post only.
- An external task reaches a terminal state: once no run is live, a failed,
  cancelled or stranded run is FAILED and anything else is COMPLETED. It
  used to stay WORKING, or INPUT_REQUIRED with continuation refused, so a
  client polling for a terminal state waited forever.
- Errors that are not A2A errors are logged and answered as a generic
  internal error instead of passing store messages (absolute paths, other
  threads' ids) to the caller; a malformed task id is TaskNotFound.

* fix(serve): gate A2A on the per-workspace collaboration opt-in

The merge of the per-workspace opt-in left the A2A registration reading
the removed boot-time `agentCollaborationEnabled`. Register the A2A routes
when any workspace has collaboration on, like the rest of the surface, and
treat a request for a workspace that has opted out as unauthenticated, so
grants left on disk stop working with the rest of its collaboration routes.

* fix(agents): let a Host be removed and harden the Host transport

- `DELETE /workspaces/:ws/agent/hosts/:hostId` removes a Host, which
  revokes its secret. Agents bound to it lose it (one left with no Host
  falls back to local), and runs it holds end as `agent_host_removed`
  instead of waiting on a machine that is gone.
- Host credentials are checked without the workspace lock, and before the
  2 MB progress/result bodies are parsed, so a wrong secret can neither
  queue on the lock nor get a large body read on its behalf.
- A result's summary is capped like the progress text and its error at
  4 KB; enrollment failures no longer echo store messages (file paths)
  to an unauthenticated caller; a pickup whose Host hung up stops polling
  instead of claiming a run for nobody.
- A Windows Host no longer offers Codex or Claude Code, which it cannot
  start; a re-picked held lease no longer reads as a longer lease term.
- Remove internal hostnames, ids, PIDs and a build version from the
  cross-machine acceptance note in the Host entry design doc.

* fix(serve): accept the agent-host bridge's closeSession in the dispatch port

1d3173a53e picked closeSession from AcpSessionBridge, whose Promise<void>
return the agent-host bridge's Promise<unknown> does not satisfy, so the
cli build failed. The port only awaits it, so declare it loosely.

* fix(agents): close the stranding paths the lifecycle review found

- Only a near-miss of a roster name is an unknown mention; `@Override`,
  `@media`, a long npm scope no longer suppress the assignee. A shorter
  ASCII name no longer matches into a longer Latin word (`@maría`).
- `hasLiveDescendant` requires evidence of pending work (a live run, an
  outstanding obligation, or a report owed), not a non-`open` label, so a
  wait cannot rest on a quiet child.
- A close discharges peers' waits by stamping their runs, including a
  waiter still `finishing` that the same close just woke.
- A blocked child whose failure was never reported now tells its parent
  (child_blocked), and a done thread does not stack child_cancelled.
- `strandLocalRuns` settles the thread status, ends `cancelling` runs as
  cancelled, and only treats ENOENT as "never used"; stranded runs get
  their own obligation kind and reason.
- `updateWorkspaceAgents` always writes, the outbox default filter owns
  parent reports only, a `system` author defaults to `authorKind: system`,
  and the live-run status set is shared.
- Split the progress validator into small predicates (Prettier gate).
- Tests: stranded-runs, finishing waiter, quiet child, blocked-child report,
  prose mentions, cancelled thread, retired agent, re-enable, tree-scoped
  budget; retention bounds derive from the constants.

* style(agents): fit the a2a-server store import on one line

* style(serve): fit the agent-hosts express import on one line

* style(agents): drop a stray blank line in the workspace store

* fix(serve): consult only trusted workspaces for the collaboration opt-in

anyAgentCollaborationEnabled read the settings of every registered
workspace, including untrusted ones, whenever capabilities or route
registration were evaluated. An untrusted workspace cannot use
collaboration, and loading its settings writes debug logs during what must
be an untrusted-safe read (multi-workspace-sessions.test).

* fix(web-shell): read the workspace only in the agent activity tab; show the collaboration setting

ArtifactPanel called useWorkspace() on every render, so hosts rendering it
without a workspace provider (SourcePreview.test.tsx on main) threw. The
agent activity tab now reads it itself.

The settings panel hid experimental.agentCollaboration while settings.ts
publishes an alias for it, which the settings alias drift test rejects.
The setting is per-workspace and carries requiresRestart, so it is shown.

* fix(agents): make the thread retention bounds bind

trimThread kept every run that had spent tokens, so MAX_THREAD_RUNS never
dropped a real run, the messages those runs referenced kept
MAX_THREAD_MESSAGES from binding too, and the outbox was never trimmed at
all: a long-lived thread file grew without limit and was rewritten whole
under the workspace lock on every write.

- A run past the bound is dropped whether or not it spent tokens; its spend
  is folded into the thread's new optional `trimmedTokens`, and
  `threadTokens()` (runs plus trimmed) feeds both `tokensUsed` and the
  tree budget at admission, so the budget does not reset.
- Acknowledged outbox events beyond MAX_ACKNOWLEDGED_OUTBOX (50) are
  dropped; pending ones are always kept. An acknowledged event is never
  replayed, and replay idempotency rests on the message's originEventId.

* fix(agents): hold running work to the tree token budget

The budget was checked only at admission, so a run already going kept
spending until it ended (a crash-replayed run closed at 666,749 against a
200,000 budget). Each dispatch pass now records every running run's spend
so far — written only when it moved — and moves the running runs of a tree
at or over DEFAULT_THREAD_TOKEN_BUDGET to `cancelling` with error
`token_budget_exhausted`; the ordinary cancel path then stops them. The
overshoot is at most what a run spent since the previous pass (the host
ticks every second). A run a person triggered is left running, matching the
admission rule that a person's post passes the budget.

* docs(verification): hand off the workspace agent token budget acceptance matrix

* docs(verification): align the acceptance matrix table

* docs(verification): end the budget brief with a newline

* fix(serve): keep the collaboration settings probe out of session debug logs

isAgentCollaborationEnabledFor runs at boot and per request through
loadSettingsCached, which logs a cache hit/miss line on every call; those
lines landed in whichever debug log session was ambient and broke the
untrusted-read isolation test in multi-workspace-sessions.test.ts. The probe
now runs under runWithoutDebugLogSession.

Also fix the new budget tests: port() drops unknown overrides, so
totalTokens has to be spread on after it.

* fix(agents): classify structured memory tools

* refactor(agents): keep execution opt-in out of UI

* feat(web-shell): expose agent collaboration setting

* fix(web-shell): add display names for the thread_* wire tools

The six new thread tools landed without display-name entries, tripping
the TOOL_DISPLAY_NAMES drift guard (Test lane). Pin all six.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* docs(agents): align collaboration PR stack

* fix(web-shell): deduplicate agent tool labels

* fix(agents): isolate disabled collaboration paths

* perf(web-shell): lazy-load collaboration workspace

* fix(agents): close A2A acceptance gaps

* fix(serve): gate agent hosts per workspace

* test(web-shell): inspect split output chunks

* test(web-shell): stabilize collaboration completion capture

* fix(web-shell): expose workspace agent sharing

* fix(web-shell): expose remote runtime removal

* feat(web-shell): render agent collaboration with the chat's own components

A collaboration conversation had two renderings: the chat, and a separate
"Task details" page with its own look (numbered posts, a plain textarea and
Send button, a native select, monospaced names). The chat itself also
hand-rolled pieces the transcript already has. This makes the conversation
one ordinary Web Shell chat and moves everything else into the Team panel.

- Agent replies and thoughts carry an `author` (name + agent color) that the
  message list renders as a sender row, instead of a bold name in Markdown.
- Live work sits in the transcript's "Parallel agents" group (its classes are
  reused), headed by the server's status sentence.
- Approvals are the main chat's ToolApproval panel.
- The composer toolbar says who a draft will reach.
- The details page is removed. The Team panel lists members once, each
  expanding to its run detail, with "Make lead", subtasks, history and the
  budget. Conversation rows on the Agents page open the chat.
- The Agents page uses the role templates page's frame: title, actions,
  a toggle group, and cards with the same avatar.

* fix(web-shell): keep agent collaboration out of the Plugins page

The Plugins page embeds the Agents manager without a chat to open a
conversation in, so a conversation opened there had no Team panel. Show
collaboration only where the host can open its chat (the sidebar's Agents
entry); the Plugins tab keeps managing role definitions.

* fix(web-shell): preserve collaboration chat controls

* fix(serve): make the agent host owner lifecycle race-free

Two overlapping dispatch() calls could each build an owner: the
check-then-create window awaited the replaced owner's stop() before
publishing to the map. The loser never reached `owners`, so neither
teardown path could ever see it and its keepalive timer ran for the life
of the process. Build and publish synchronously, then drain the replaced
owner.

AgentHostSessionOwner.stop() was idempotent but not single-flight. A
boolean flag let every caller after the first resolve at once, without
awaiting the [inFlight, dispatching, ensuring] drain that its own comment
says the callers' teardown order depends on — so a DELETE racing the
recovery tick could release and close the host session while an ensure()
was still inside spawnOrAttach. Memoize the drain so every caller awaits
it; clearInterval still runs synchronously before the first await.

Also narrow two comments that claimed more than the code does: the
mid-edit settings fallback covers the user and system scopes only,
because the loader recovers a workspace-scope parse error into a valid
`{}` instead of rethrowing, and `agentCollaborationEnabled` is resolved
per response rather than once at boot.

R4-3, R4-6 fixed; R4-4 narrowed to what the code actually covers;
capabilities half of R4-1.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(web-shell): correct collaboration UI state, mention routing and stream handling

Mention interception dropped core's second boundary guard, so a longer
Latin word resolved to a shorter agent name ("@maría" to `mar`) and an
ordinary chat message was silently converted into work assigned to an
agent nobody addressed — one the server-side parser says was never
mentioned. Add the guard, matching `agentForToken`.

`describeRun` returned the failure copy before consulting `closeKind`,
which left its own `stranded` case unreachable and labelled every
stranded run "failed at collaboration-disabled": an internal constant
interpolated into a localized sentence, asserting an agent failure where
nothing failed. Check `closeKind` first, the order core's `obligationFor`
already uses.

`teamMembers` ranked a member's runs by `startedAt ?? 0`, so a run
cancelled or failed while still queued always lost to an older run that
did start, contradicting the field's own "otherwise the latest" contract.
Use `endedAt ?? startedAt`, the recency key `lastActivity` already uses.

The member status tone was a single-class Tailwind utility tied on
specificity with `.memberStatus`'s own `color`, so the later-injected CSS
module sheet won and every status line rendered muted grey. Move the
tones into the module, where every sibling status colour already lives.

The agent event stream was declared open on `response.ok` alone, so a 200
that is not an event stream reset `retryMs` on every cycle: the backoff
never grew and the open/closed flap kept recreating both consumers' poll
intervals before they could elapse. Require the content type.

`onOpenCollaboration` was the only sidebar navigation handler that did
not close the mobile drawer, leaving the drawer and its pointer-capturing
backdrop over the thread it had just opened.

Give the ArtifactPanel collaboration-gate test the flush its positive half
already had. Without it `<Suspense fallback={null}>` satisfied `toBeNull()`
whether or not the gate existed, so the negative half could not fail.

R4-10, R4-11, R4-12, R4-13, R4-15, R4-16, R4-17.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* docs(agents): state the real capabilities contract and name the built daemon

`agent_collaboration_v1` is recomputed per request rather than snapshotted
at boot, so "absent means no workspace had it on at startup" was false in
both directions: the tag also goes absent once every workspace has opted
out, and the pre-runtime bootstrap envelope omits it while reading no
workspace settings at all. Say what absence actually encodes.

The token-budget verification doc told the witness to start `qwen serve`,
which runs whichever installed release PATH resolves to rather than the
artifact step 1 just built — a daemon without this PR, whose every matrix
row then 404s and reads as a budget-contract failure. Name
`node dist/cli.js serve`, matching the sibling verification docs, and ask
the witness to confirm the tag before starting.

R4-2, and the protocol-doc half of R4-1.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(serve): close inactive agent sessions safely

* fix(agents): close the four open R4 review findings

R4-4: the collaboration opt-in predicate now loads settings with a new
`preserveInvalidWorkspaceSettings` option. A half-written workspace
settings file used to be "recovered" by rewriting it to `{}`, which the
predicate read as an opt-out — the recovery tick then stranded every live
run, and the rewrite made the opt-out permanent. The strict load throws
instead, so the predicate keeps its last answer and the file is left alone.
`loadSettingsCached` keeps strict entries apart from default ones.

R4-5: session listings and counts hide `agent` sessions on the live side as
they already did on the persisted side (organized view, metadata filter,
catalog live merge, `/sessions` live path, content search, session-info
`live`), through one `isHiddenCatalogSource` predicate.

R4-7: a skill loaded in a workspace-agent session applies none of its side
effects (hooks, allowedTools grants, review workflow). Its body still
reaches the model. A skill hook spawns a command and PreToolUse fires
before the invocation guard, so it was a way out of the read-only
boundary. `Config.isWorkspaceAgentSession()` is now the single gate used by
the tool registry, the invocation guard and this check.

R4-8: `agent` joins `agent-host` as a reserved source on REST
create/load/resume and ACP-HTTP `session/new`, and ACP-HTTP `session/load`
strips a persisted `agent` source as REST restore already does. A client
could otherwise restore a session under an agent's deterministic session id
with `sourceType: 'agent'` and have the dispatcher send a real run into it.
Only the in-process dispatcher creates agent sessions.

* fix(agents): close remaining collaboration review gaps

* fix(agents): deny skill command fallback

* fix(web-shell): restore transcript size gate

* fix(agents): deliver a reused sub-thread title's new hand-off details

The retry-dedupe predicate keys only on (parent, lowercased title, assignee,
non-terminal), so a second, genuinely different delegation that happens to
reuse a title was swallowed: its body and acceptance criteria were written
nowhere, no message reached the reused thread, and the tool still returned
ok() — so the parent's later summary reported work as delegated that no one
was ever told about.

Dedupe still has to hold (a retry must not duplicate a live hand-off), so the
reuse branch now posts this call's task and acceptance criteria onto the
existing thread through the same admission path as the create branch, and
stays silent for a bare retry that carries nothing new. The reply says the
details were posted rather than implying nothing happened.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(serve): report the tree token total admission actually enforces

`budget.tokensUsed` was computed by an inline sum over `run.usageByRound`
only, duplicated at two sites, so it omitted `trimmedTokens` — the spend of
runs retention dropped. Admission charges the tree with
`threadTokens() = (trimmedTokens ?? 0) + sumRunTokens(runs)`, so once a thread
passed MAX_THREAD_RUNS the route reported a smaller number than the one the
server enforced: a nearly-empty budget bar next to a run closed for
`token_budget_exhausted`.

Both sites now call the store's exported `threadTokens()` helper instead of
re-deriving the total, so there is a single implementation left to drift.

This also fixes the token-budget verification recipe, which named that field as
its only observation surface and so read row 6's retention invariant as
violated, and whose step order edited `DEFAULT_THREAD_TOKEN_BUDGET` after
building — esbuild inlines the constant, so the edit never reached the running
daemon and the whole matrix executed against 1_000_000 rather than 20_000.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(web-shell): order the live transcript by the daemon's clock

`applyProgress` stamped the browser's clock into `progress.receivedAt`, a field
the daemon writes with its own clock. `ThreadChat` uses it as the transcript
sort key against daemon-written `post.at` and `run.startedAt`, so with a remote
daemon — an explicitly supported shape — a browser clock two minutes behind
folded every streamed frame to `serverNow - 120s` and sorted the agent's live
output above the posts it was replying to.

The progress event already carries `activityAt`, which the daemon refreshes on
exactly this frame, so it is the same instant on the same clock.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(agents): close remaining review gaps

* perf(agents): list threads once per store transaction

Admission, closing and disabling each call listThreads() several times
inside one workspace lock hold, re-reading and re-validating every thread
file each time; with many threads the hold outlasted another process's
lockfile retries (10 tries, 5-100 ms). The transaction now reads the
directory once and keeps the listing current through its own writes,
handing callers copies so an unwritten in-place edit cannot leak.

* fix(agents): charge the spend of runs on remote Agent Hosts

A run executed on a remote Host was never charged: the Host's progress and
result carried no usage, and the coordinator's dispatch port cannot read a
session on another machine. So neither admission nor the running budget
check counted remote spend, and a tree could spend past its budget there.

- A Host running Qwen Code measures its attempt's spend (session total now
  minus the total when the turn started) and sends it as `tokens` with each
  progress post and with its result. Codex and Claude report none.
- The progress and result routes accept an optional whole `tokens`
  (0..1e9, otherwise 400), and the coordinator records it on the run's
  usage ledger, monotonically, so out-of-order reports cannot lower it.
- With the spend on the ledger, the dispatcher's running check stops a
  remote run like a local one: the run goes `cancelling`, and the Host's
  next lease renewal is refused.

* fix(web-shell): rank the retry candidate by the run's latest activity

retryable picked each agent's latest run by startedAt alone, so a run that
failed while still queued (no startedAt) lost to an older run that did
start and no retry was offered. Use the same runRecency key as teamMembers
(R4-12).

* fix(agents): close the round-1 A2A review findings

- Withdrawing an external task cancels every thread in its tree, not just
  the root; a split-off sub-thread kept running while the caller was told
  the work stopped (R1-1).
- The transport checks workspace trust before the opt-in probe, so an
  unauthenticated request cannot make the daemon read an untrusted
  workspace's settings (R1-2). A failing grant-store read is logged and
  answered 500 instead of reaching Express's default error page.
- `toExternalA2ATaskState` decides a stranded wait from the obligations: a
  wait beside a question or a review is an answer (COMPLETED); only a wait
  left alone is FAILED (R1-3). It reuses thread-status's LIVE_RUN_STATUSES
  (R1-7).
- Reading, listing and cancelling tasks need a valid grant and an existing
  agent, not an addressable one: retiring or disabling an agent no longer
  hides its callers' tasks or leaves them uncancelable (R1-4).
- Resolve the caller's workspace over `list()`, excluding internal
  runtimes (R1-5); validate grant ids and expiry the way the store reads
  them back (R1-12); stamp TaskStatus with the thread's last activity, not
  the read time (R1-14); show the share's real expiry (R1-15); drop the
  unused grant mutators from the core barrel (R1-20); put Thread's JSDoc
  back on Thread (R1-22); let an agent split work to itself in an external
  tree (R1-24).

* fix(agents): bound the Host's result summary and state the env override

- The Host truncates its review summary to the coordinator's 256 KiB bound
  before sending. An oversized one was refused with a 400, which the client
  treats as permanent, so the answer was lost.
- Say at the connection-map delete why the old connection is not stopped
  there: its generation guard is already closed, which ends its loop.
- The collaboration design doc now states the implemented precedence: the
  setting resolves per workspace, and QWEN_CODE_ENABLE_AGENT_COLLABORATION=1
  is a process-wide override for every trusted registered workspace.

* docs(agents): state that an A2A share follows the agent's later configuration

Decided contract for what an issued share authorizes: it is checked against
the agent's live definition on every request, so changing the agent after
sharing applies to shares already issued. The share dialog says so, the A2A
contract doc (English and Chinese) records the decision and how to give a
caller less (revoke, or share a separate agent), and a test pins it.

* test(agents): hoist the caller in the live-policy share test

* style(web-shell): keep the share notice on one line

* fix(agents): preserve external task results and workspace ownership

* fix(agents): isolate remote connections and validate roster updates together

* refactor(agents): remove unused execution setter

* fix(agents): reject invalid grant creation timestamps

* fix(agents): allow a lease window for result uploads

* fix(agents): isolate remote Qwen runtimes

* docs(agents): clarify live runtime sharing

* fix(web-shell): keep collaboration gating working without a feature list

isAgentCollaborationEnabledForWorkspace dropped the optional chain that
App.tsx used before the helper existed, so a workspace capability payload
without `features` threw a TypeError during render and took 989 web-shell
tests down with it.

Also map the agent-host symlink escape inside config.test.ts: the file-wide
node:fs mock resolves realpathSync to the identity, so the guard never saw
the symlink target and the confinement assertion could not fail closed.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-conflict/jmuo5qft7hs

* fix(cli): back off idle agent-host pickup polls

The pickup long-poll re-scanned the agent store at a fixed 250ms
cadence; each scan walks every thread record under the store
transaction, so each idle Host hammered that lock about four times a
second doing nothing. Empty polls now double their interval up to a 2s
cap, resetting per request so a Host that just received work re-polls
hot. Measured by a route test: a 3s idle wait issues at most 5 pickup
calls where the fixed cadence issued twelve (reverting the backoff
turns it red).

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(agents): drop duplicated host auth on the progress and result routes

Both handlers repeated the authenticated middleware trust, collaboration-setting and credential checks verbatim, so every progress flush and result upload paid a second registry read and a second credential compare, and the rules had a second place to drift. The middleware is now the only enforcement point for those routes; the handlers resolve the workspace only to read its cwd.

The inline 262144/65536 progress caps now reuse the named limits, so they cannot diverge from the result summary and error bounds, and the disabled-workspace 404 body is asserted to stay byte-identical to the unknown-workspace answer.

* fix(cli): keep agent-host store errors off the pickup/result wire

The catch blocks on the pickup long-poll and /result returned the raw
store error, whose messages embed coordinator-side absolute paths and
ids, and whose wording changes whenever the store's messages change.
Answer with a fixed message instead, matching enroll and heartbeat, and
log the detail server-side via debugLogger.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmuodld0708

* fix(cli): harden agent-host transport against coordinator restarts

From wenshao's real two-host verification (round 3):

- F1: a bare 401 is no longer read as revocation. The Host deletes its
  credential only on the route's pinned 'Invalid Agent Host credential.'
  body; a coordinator mid-restart (bearer-gate 401) or collaboration
  turned off now puts the Host into retry instead of stranding it until
  a manual re-join. Server side, /agent-hosts/* is exempt from the
  deferred bearer gate so those requests wait for the runtime like the
  warm path.
- F2: a busy agent store (ELOCKED) now answers 503 on pickup and
  result, not 409 — 409 read as permanent to the client, rewriting a
  finished answer as failed or silently re-running the run.
- F4: restore the one-shot strand sweep on the collaboration-off boot
  path, so an in-flight run left by the switch is marked stranded for
  review instead of silently re-dispatching when re-enabled.
- F7: a provider failure arriving as the JSON-RPC error object no
  longer surfaces as '[object Object]'.
- F9: the Host's progress details go on the wire in English like the
  rest of the surface.

isRevocation is exported with a predicate table test; both 503 mappings
are pinned beside the existing fixed-message cases; the restored sweep
is the pre-rescope block verbatim (strandLocalRuns early-returns before
opening the store, so a workspace that never used collaboration still
gets nothing written).

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(agents): preserve Host recovery and read-only execution

* fix(agents): retain provider details from prompt rejection

* fix(cli): warn on the coordinator before sending an enrollment token over HTTP

remote-connect hands a fresh enrollment token to the remote over the
remote leg, and the remote then calls back over the callback leg. With
allowHttp either leg can be non-loopback http, yet only the Host side
warned. Emit the same stderr warning on the coordinator before the token
is issued. allowHttp keeps its current breadth as an explicit demo-mode
tradeoff.

Refs #13123

* revert(agents): move the agent-host guard-before-permission ordering out of this PR

Review scope correction (Nakagawa-master on 8e5cd79cdd): this PR keeps
only the tool-filter half of F3, declared tools = runnable read-only
tools. The Session change in 8d7faadd32 that ran the confinement guard
ahead of the permission flow for agent-host sessions, and its
ordering-specific regression, go to a separate follow-up.

Both files are restored to their 8d7faadd32^ content; neither changed
after that commit. Until the follow-up lands, an out-of-workspace call
on a Host still reaches the auto-rejected permission prompt and ends the
run; that is documented as a known limitation in the PR body.

* refactor(agents): share the agent-host tool set and keep connection errors English

- AGENT_HOST_TOOL_NAMES in tool-names.ts is now the single list that
  config.ts declares as coreTools and ToolRegistry.isToolDisabled lets
  through, so the declared and runnable sets cannot drift apart.
- The agent-host connection route's eleven error strings were Chinese
  and reached clients verbatim via { error: error.message }. They are
  now English, matching the convention #12854 set for wire reasons
  (review-bot residual 1).

* style(cli): format the agent-host connection route

Prettier --check output for 6e1ba96b18; the first --write pass over the
translated strings left a broken-chain form that the CI gate rejects.

* fix(cli): recover read-only Host permission refusals

Return automatic Host refusals as recoverable execution denials while preserving permission and guard ordering. Keep ordinary user cancellation and signal abort terminal.

* fix(cli): recheck Host credentials during pickup polls

Authenticate each pickup scan and recheck credentials before returning work. Preserve selected-workspace checks across authentication waits so removed or disabled runtimes cannot claim work.

* fix(agents): pin the host credential string, stop mutating runs in place

Two review findings on 41aa0ce667.

`isRevocation` matched the literal 'Invalid Agent Host credential.' that
`routes/agent-hosts.ts` answers with in six places, so rewording the server
would silently strand a Host: the client would retry forever and never clear
the stale credential file. Both halves now read one exported constant, placed
next to `AGENT_PROGRAM_LABELS`, which is already the shared host/coordinator
wire vocabulary. It did not go in the route module — the Host join path reaches
`agent-host-client.ts` through a lazy import, and the route module pulls in
express.

`reportHostRunProgress` assigned `run.progress` and `run.usageByRound` on the
object `find` returned, so it reached the store only because `writeThread` was
handed that same reference. Rewritten through `withRun`, like every other write
in the module.

Both are behavior-preserving; the wire body and the persisted progress shape
are unchanged.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(agents): settle abandoned Host runs after lease recovery grace

* fix(agents): requeue unread follow-ups after Host expiry

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: yiliang114 <jinjing.zzj@gmail.com>
Co-authored-by: yiliang114 <yiliang114@users.noreply.github.com>
2026-10-02 15:53:31 +00:00
jinye
b906f937ec
fix(serve): upload session attachments in 512 KiB chunks (#12923)
Some checks are pending
Qwen Code CI / Classify PR (push) Waiting to run
Qwen Code CI / Test (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Lint & Static (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / web-shell E2E Smoke (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (macos-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (windows-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Integration Tests (no-AK, No Sandbox) (push) Blocked by required conditions
Qwen Code CI / Integration Tests (CLI, No Sandbox) (push) Blocked by required conditions
Qwen Code CI / Desktop Shell (ubuntu-22.04) (push) Blocked by required conditions
Qwen Code CI / Desktop Shell (windows-2022) (push) Blocked by required conditions
E2E Tests / Build for E2E (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 1/1 (push) Blocked by required conditions
E2E Tests / E2E Test (Linux) - sandbox:none - shard 1/1 (push) Blocked by required conditions
E2E Tests / E2E Test - macOS - shard 1/2 (push) Blocked by required conditions
E2E Tests / E2E Test - macOS - shard 2/2 (push) Blocked by required conditions
E2E Tests / E2E Interactive - OpenTUI renderer (bun) (push) Blocked by required conditions
E2E Tests / channel-plugin E2E (nightly) (push) Blocked by required conditions
E2E Tests / cron-interactive E2E (nightly) (push) Blocked by required conditions
E2E Tests / web-shell Browser Regression (push) Waiting to run
pnpm Worktree Smoke / Install (macos-latest) (push) Waiting to run
pnpm Worktree Smoke / Install (ubuntu-latest) (push) Waiting to run
pnpm Worktree Smoke / Install (windows-latest) (push) Waiting to run
SDK Java / ubuntu-latest / Java 11 (push) Waiting to run
SDK Java / ubuntu-latest / Java 17 (push) Waiting to run
SDK Java / macos-latest / Java 21 (push) Waiting to run
SDK Java / ubuntu-latest / Java 21 (push) Waiting to run
SDK Java / windows-latest / Java 21 (push) Waiting to run
SDK Java / Runtime Broker and Managed Agent MariaDB / Java 21 (push) Waiting to run
SDK Java / Hosted process fault gates / MySQL 8.4 / Java 21 (push) Waiting to run
SDK Java / Real daemon E2E / Java 11 (push) Waiting to run
* fix(serve): upload session attachments in chunks

* codex: address PR review feedback (#12923)

* codex: address PR review feedback (#12923)

* codex: address PR review feedback (#12923)

---------

Co-authored-by: jinye.djy <3007091+doudouOUC@users.noreply.github.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
2026-09-29 18:24:55 +00:00
易良
8c914ebe03
feat(agents): add local workspace-agent collaboration (#11206)
* test(agents): kill a writer mid-transaction, and say what that does not prove

A daemon can die holding the workspace lock. Two things must be true
afterwards and now are, from a real SIGKILL rather than an argument: the store
stays readable with nothing written before the crash lost, and writes come
back without anyone clearing the lock — a lock held by a dead process that
nothing reclaims is a workspace wedged forever.

How long recovery takes is reported rather than asserted, and that is a
correction. An early probe measured a 10.5-second outage and I wrote that into
both the script and a code comment as a measured fact. Four repeat runs
recover in about 25ms and the outlier never reproduced, so the assertion that
a write in the gap is refused was dropped: it depends on where in the
staleness cycle the crash lands, and stating it as an invariant was wrong.
Both comments now say what I can actually support.

The route maps that refusal to 503 with a Retry-After instead of a 500
quoting a lock file at whoever clicked. It is a wait, not a fault, whether it
lasts 25ms or ten seconds.

The script also records what it does not cover, because I checked: replacing
atomicWriteJSON with a plain truncating write leaves this and the concurrency
script green. The file lock means two writers never touch one file at once, so
atomicity only matters for a crash *during* a write, which killing an idle
holder never reaches. store.test.ts covers that with fault injection. A script
that passes under a mutation which should break it does not cover that
property, and saying so is the difference between coverage and the appearance
of it.

* test(agents): fuzz the store against its invariants

The other scripts check scenarios someone thought of. This one draws
operations at random — create, post as a person or an agent, claim, finish,
toggle, retire — and after every step asserts what must hold regardless of
order: every thread readable, message sequences unique and increasing with the
watermark ahead, run ids unique, queueSequence unique across the workspace,
parent and root ids resolving, terminal runs carrying an end time, no agent
running more than its maxConcurrentRuns, and no run booked for an agent after
it retired. The status resolver is called on every thread, since it has to
answer for any shape the store can reach.

A refusal is a legal outcome and is recorded as one — the store simply has to
stay consistent through it. The seed is printed and accepted back, so a
failure is reproducible instead of a story about a run nobody can repeat.

Clean over seven seeds, up to 800 steps. What makes that worth anything is
what it does when the invariants are broken. Removing the concurrency check
from claimRun is caught by step 42 on one seed and step 20 on another, named
as "ag_1: 2 running over a limit of 1". Removing the retirement guard from
decideDispatch — the exact bug fixed in 521b212cd0 — is caught at step 6 on
seed 42, named as "1 run(s) booked after retirement".

That second one is the argument for this whole script. The bug it replays sat
through eight ticks of orphan sweeps, type checks and re-reading, and was
found only because I happened to notice `isAgentEnabled` where
`isAgentAddressable` belonged. The fuzzer finds it in seconds without anyone
suspecting anything.

* fix(audit): drop an unused binding the fuzzer left behind

The loop destructured a live-run count it never read; it iterates keys now.
Committed with lint red, which the previous commit should have caught before
pushing.

* test(agents): widen the fuzzer, after finding it was barely exploring

The alphabet now includes the thread tools under real run frames, sub-thread
creation, assignment, and hiring, and two invariants come with them: a
`finishing` run must carry a close kind, and every run must be booked for an
agent still on the roster. A child's root thread must equal its parent's, so
splitting work cannot mint a second budget.

Two defects in the harness itself, both of which made a green result mean less
than it looked.

The draw degenerated. Retiring was as likely as anything else, so within a
hundred steps every agent was retired and most of the alphabet became no-ops:
400 steps executed exactly one thread tool. The weights now favour claiming,
keep retiring rare, and balance it with hiring. An earlier weights array was
also shorter than `ops` and silently never drew the last two entries.

Worse, `finishRunInTransaction` was never exported to the bundle, so every
finish threw "is not a function" — and the catch-all that treats a refusal as
a legal outcome swallowed it as one. Runs never reached a terminal state for a
whole session behind a passing result. The entry exports run-lifecycle now,
and a TypeError is reported as a harness bug and exits 2 rather than being
counted as the store refusing something.

With that fixed the draw is healthy: 400 steps give 41 finishes and 34 tool
executions across all five tools against 22 no-ops. Clean over six seeds at
300 steps. Both mutations still bite — removing the retirement guard is caught
on seed 42 at step 130, and dropping closeKind from a close is caught at step
12 — and the docstring says plainly that seed 1 does not catch the first one
at all, because a fuzzer that is sold as a proof is worse than none.

* test(agents): check a sub-thread cannot buy a fresh budget

Found by auditing my own coverage rather than the code: I walked the design
doc's twenty-two decisions and asked which had an executable check. Decision
17 — a child inherits the parent's turn count and charges tokens to the root —
had none, and it is a budget-evasion guard. Without inheritance an agent could
reset the loop breaker by delegating, and a tree could run forever a
sub-thread at a time while every individual thread looked well behaved.

It is implemented and now checked: a child split from a parent at seven
unattended turns starts at seven, not zero, and shares the parent's root.
Verified to bite — starting the child at zero turns the assertion red.

Two decisions still have no executable check and are recorded rather than
quietly left: an agent may not create agents (12), and agents are scoped to
one workspace (4). Neither has an obvious evasion story the way 17 did, but
neither is tested here either.

* docs(agents): record how this branch was verified, and what that misses

Fifteen thousand random operations across 25 seeds held every invariant, so
the plan now carries the verification table rather than leaving it in commit
messages nobody will assemble.

The table says what each check covers and the doc says plainly what none of
them do: the vitest suites are larger and still need CI, partial-write
recovery is store.test.ts's job because the file lock means two writers never
touch one file and killing an idle holder never interrupts a write, and two
of the twenty-two decisions have no executable check here.

Every entry was calibrated by breaking what it checks. That is the only
reason the green is worth writing down.

* test(agents): close the last two unchecked decisions, one of them properly

Decision 12 — an agent may post, mention and delegate but may not make more
agents — is checked at both layers: no agent-creating tool is in the config an
agent runs with, it is named in disallowedTools rather than merely absent, and
the invocation guard refuses it even if something asks anyway. Flipping it to
`allow` turns three assertions red.

Decision 4 — agents are scoped to one workspace — took two attempts, and the
first one is the interesting part. It reused a run frame from earlier in the
script and asserted that changing its workspaceId made a post fail. It did
fail, and the assertion passed, and it proved nothing: by that point the run
had already been closed, so the refusal came from the closed-run check and the
workspace comparison was never reached. Removing that comparison from
requireLiveRunInTransaction left the suite green.

It runs on a fresh live run now, with the frame first proven to work as
itself, so the only thing wrong in the second call is the workspace. Removing
the comparison turns both assertions red.

That near miss is the same failure this whole review keeps finding: a green
assertion that passes for a reason other than the one it claims. Mutating the
thing under test is the only way I have found to tell the difference.

* test(agents): cover what a mutation campaign proved was untested

Disabling each of 86 single-line guards in the subsystem one at a time and
re-running the harness: 8 caught, 72 survived, 6 would not build. That number
is the honest measure of what 146 assertions were worth, and it is much lower
than the count suggested.

Most survivors are fair. Turning off `if (!thread) throw` changes nothing when
every test supplies a thread, and store.ts's forty-odd validators guard
malformed records this script never writes — store.test.ts covers those with
fixtures I cannot easily reproduce here. A survivor means "this script never
builds the case that guard exists for", not "nothing checks it".

Some were not fair, and are covered now:

- `classifyAgentTool` fails closed for a name that is not in the table at all.
  Section 17c tested tools that *are* in it, so the allow-list could have
  become a fall-through and nothing would have noticed. This is the boundary
  I claimed was enforced two commits ago.
- The capability guard does not override an upstream refusal.
- An empty sub-thread title is refused rather than creating something nameless.
- Close obligations: a live run owes nothing, a blocked close does, a
  completed run with no close kind owes nothing — `unclosed` is a kind an
  agent records, not the absence of one, and my first assertion conflated
  them — a run that ended without a hand-off does owe, and a failed run
  outranks whatever it recorded first.
- A `finishing` run owes nothing until it is terminal. This one exists purely
  because the liveness guard survived at first: every live fixture also
  lacked a close kind, so disabling liveness was neutralised by the next
  guard. Only a run that is both live and carrying a close kind tells them
  apart.

Re-running the campaign on those guards: five of five now caught. A vacuous
`ok(..., true)` I wrote as a placeholder was deleted rather than left to pad
the count.

* test(agents): charge tokens for real, which nothing here had ever done

The mutation campaign showed the whole charging path unreached: the fake port
in the crash-recovery section has no `totalTokens`, so `chargeRunUsage`
returned at its first line every time. Commit f663f779a1 moved accounting onto
the session's own counter and claimed the per-tree gate still worked; nothing
in this script had executed a single line of it.

Now it does. A session reporting 1,750 spent against a run whose baseline is
1,000 charges the run 750 — the difference, not the whole session — and the
tree total reflects it. A run whose baseline already equals the session total
records nothing. A runtime that cannot report usage charges nothing rather
than guessing, because charging a number nobody reported would spend a
person's budget on a hunch.

The fixture failed first for a reason worth keeping: without `consumedOnStart`
the trigger message counts as undrained, so the dispatcher requeues instead of
closing and charging never happens. That is the code being right and the test
being wrong, again.

All three guards in the path are now caught when disabled, including
`spent === 0` and the unreadable-meter branch, and replacing the baseline
subtraction with the raw total turns two assertions red with 1750 where 750
belongs.

* test(agents): cover every way an assignment can be refused

A focused campaign over thread-actions and dispatcher caught 3 of 24 guards.
The assign path accounted for five of the survivors, and two of those are the
retirement work from 521b212cd0 — that commit fixed admission and the assign
path in one go, and only the admission half had ever been exercised. The guard
I wrote to stop a thread being handed to a retired agent had never run.

All six are covered now and all six are caught when disabled: a thread that
does not exist, a finished thread, a name nobody has, a disabled agent, a
retired agent refused as retired rather than as disabled, and claimRun
noticing an agent that stopped being addressable after its run was booked.

That last fixture is worth keeping for what it taught. Retiring cannot produce
that state at all: 8d6e199cc4 refuses to retire an agent with queued work, so
my first attempt retired nothing and the claim succeeded — the test failed
because two of my own guards interact, not because either is wrong. Disabling
has no such restriction, reaches the state, and the assertion now says so and
pins the retirement refusal beside it.

* test(agents): cover the quiet no-ops, and say which survivors are not gaps

Re-running the full campaign after the last two commits: 8 caught out of 86 at
the start of this, 23 now. This commit takes it further and, more usefully,
draws a line under what is left.

Covered here, each because disabling its guard changed nothing before: a
notification pass with no sender and with no destination sends nothing; once a
destination exists a blocker queues a notification, a real sender delivers
every pending one, and a second pass sends nothing; whitespace-only
instructions leave the definition prompt exactly as it was; clearing an
assignee nobody set is accepted and changes nothing; and acknowledging
discharges only what a selector matches, so a scoped discharge is not a
blanket clear.

The missing-sender guard needed a destination to be observable at all — with
none configured the loop never reaches it — which is the difference between
"unobservable" and "I never built the state". It is caught now.

Three survivors are staying, and not because I gave up. Disabling
`pending.length === 0` makes the code walk an empty list to the same end;
disabling the unset-assignee check re-assigns undefined over undefined. Their
removal is indistinguishable from their presence, so no assertion can catch
them and writing one would be theatre. Most of store.ts's remaining survivors
are per-field validators: store.test.ts covers fail-closed at the record
level, which I checked rather than assumed, but a single field check can be
disabled with both suites still green. That is a real if minor gap, stated
rather than papered over.

Two of my own assertions were wrong on the way. "Delivers it exactly once"
assumed a clean slate when the script had already queued 27 notifications, and
a minimal dispatch port lacked `start`. Both were mine; the idempotency claim
that survives is the one that was worth making.

* test(agents): finish the mutation triage, and record what the score means

28 of 86 guards now caught, from 8 when the campaign started. Four files are
clean: capability, dispatch-policy, thread-status and persona.

The last two additions came from taking the remaining survivors seriously
rather than declaring them acceptable. A malformed parent-report payload is
reachable — the payload is a record of unknown fields and nothing validates
it — so the delivery pass is now shown to survive one, and the guard is caught
when removed.

The orphaned-parent branch beside it turned out to be unreachable, and finding
that out was the useful part: `deleteThread` refuses to delete a thread that
has sub-threads, so no supported operation can produce a report whose parent
is gone. The refusal is what gets asserted. Faking the state to make the guard
look covered would have been the wrong answer.

The plan now records the score and, more importantly, the three kinds of
survivor: unobservable, where removing the guard changes nothing anyone can
see; unreachable, where the store forbids the state; and somebody else's,
where store.test.ts covers the behaviour at record level while individual
field validators can each be disabled with every suite green. Counting
survivors without that split would make the number look worse than it is, and
counting them as covered would make it look better.

* fix(agents): release task dispatch before model completion

* fix(agents): connect collaboration tools and task panel routes

* fix(agents): start and resume real collaboration sessions

* fix(agents): enforce capability policy in agent sessions

* fix(agents): apply model selection when sessions initialize

* feat(web-shell): Show persisted task routing outcomes

* fix(agents): Preserve unread inputs across run close

* feat(agents): Deliver live input through ACP with consumption receipts

* fix(web-shell): Describe peer waits without assuming child threads

* fix(agents): Confirm runtime stop before completing cancellation

* docs(agents): Record human unblock acceptance and current delivery workflow

* fix(agents): Recover durable work after runtime readiness

* fix(agents): Persist budget gate notifications on admission

* fix(agents): Confirm initial input after transcript flush

* feat(agents): expose persistent agent runtime state

* feat(agents): reuse the existing agent builder

* fix(agents): avoid spurious collaboration wakes

* feat(agents): show agent sessions in the sidebar

* fix(agents): isolate task sessions by thread

* feat(agents): create persistent identities directly

* fix(agents): bind identities to the local runtime

* feat(agents): separate roster tasks and runtime views

* fix(agents): apply persistent persona to task sessions

* feat(agents): connect identities tasks and sessions

* feat(agents): expose the local runtime host

* docs(agents): audit the original collaboration goal

* docs(agents): define the registered host boundary

* docs(agents): stage the remote host protocol

* feat(agents): register remote hosts

* fix(agents): repair two compile errors the new host work introduced

Running the checked-in gates against the 70 commits that landed since
192bb98cda found the cli side no longer typechecks.

`AgentHostBridge` narrowed `resumeSession` to `Promise<unknown>` and lacked
`enqueueMidTurnMessage`, which the port now requires. The same object is
handed to the dispatch port as an `AgentSessionBridge`, so both make it
unassignable there. This is the third time that type has drifted from the port
it feeds — after `getSessionStatsStatus` and `spawnOrAttach` — and the fix is
the same each time: name the real bridge's member instead of restating a
looser shape.

`config.apply` was reported possibly undefined despite an early return on
`config.error` above it. The guard reads correctly and proves nothing: the
success branch types `error` as an optional `undefined`, which never
discriminated the union, so the compiler was right and the code had been
relying on a narrowing that did not exist. It narrows on `apply` itself now,
which also survives into the transaction callback where it is used.

Also updates one assertion and one baseline for deliberate changes rather than
regressions. The persona now carries a standing identity contract in front of
the definition's prompt, so pinning the old literal pinned a shape rather than
a rule; the assertion compares an identity with blank instructions against one
with none, which is the invariant it always meant. `getAgentHostsFilePath` is
used inside store.ts, and the sweep does not count a defining file as a
caller, so it joins the other path helpers in the baseline.

Gates after: 183 assertions, both typechecks, the four sweeps, ten-process
concurrency, crash recovery, and fuzz over three seeds — all green on the
current head.

* fix(web-shell): remove unsupported agent task fields

* fix(serve): guard missing search sessions

* docs(agents): define service collaboration handoff and opt-in boundaries

* docs(agents): close the review gaps in the service-collaboration design

Verified the four §7 gaps against the current code first; all four hold. Then
folded seven findings from the review into the architecture and plan:

- §7's inventory grows from four sites to about twelve. Several were added by
  the earlier per-process work — persona at session creation, the run frame
  from `_meta`, the bridge strip/re-inject, the dispatch port's injection, the
  session title, the web-shell panel — and P0 has to gate all of them, not the
  four it knew about.
- §4 names the mechanism that actually enforces "do not trust a caller's
  runId": strip on every caller, re-inject only from daemon context, and a
  negative check on originatorClientId for the mid-turn queue. That last one
  was never written down and is fragile; P1 must give external tasks a channel
  that does not pass through `_meta`.
- §6 decides re-enable semantics. The existing crash recovery requeues a
  `running` run whose body is absent, which is exactly the re-dispatch §6
  forbids and cannot tell a crash from a gate closing. Stranded runs go to a
  distinct terminal state for a person to resume or cancel. An agent session
  resumed with the gate off is refused rather than downgraded.
- §2 notes that the caller-side token gate says nothing about remote spend,
  so P1's contract freeze must settle whether usage is reported.
- P0 gains a prerequisite in plain terms: a way to see the model's actual
  final instruction and tool list, so the off-contract can be diffed rather
  than read. Three options listed, (a) test-only capture recommended; this is
  the one item still awaiting a decision.
- scripts/audit is settled: keep the orphan sweep, the two tsconfigs and the
  rule harness, which caught two compile errors within minutes of the last
  parallel push; retire fuzz, concurrency and crash, whose one-time answers
  are already recorded.

* chore(audit): retire the one-time probes, keep the regression net

Per the successor plan §1. fuzz, cross-process concurrency and crash recovery
answered questions that do not need re-asking on every push — the file lock
holds across ten processes, a SIGKILLed holder is recovered from, 15,000
random operations held every invariant — and those answers stay recorded in
the 2026-09-08 plan. The orphan sweep, the two narrow tsconfigs and the rule
harness stay: they cost minutes and caught two compile errors and a stale
assertion within one run of the last parallel push landing.

* docs(agents): settle the P0 capture method on a test-only hook

Option (a): a capture hook registered only in test builds that records each
request's final systemInstruction, tool declarations and the originating
session sourceType, so the off-contract can be diffed by session type rather
than read. Nothing from this review remains awaiting a decision.

* docs(agents): ground §5, §8, P1 and P4 in what the code actually has

A second pass over the sections the first review skipped, each claim checked
against the branch.

§5: the local rule is already enforced — a run that stops without a closing
tool is recorded as unclosed, never as implicit success. Codex has no closing
tools, so every Codex turn ends that way; without a defined signal for 'a real
result', every Codex task would sit at unclosed forever. A mapping is proposed
for P3 to confirm.

§8: the §9.1/§9.6 re-analysis has not been done. deleteThread removes the
thread record and nothing else; what happens to that task's session
transcript is undefined, and the retire-keeps-transcripts rule needs a second
look under per-task isolation.

P1: no caller-scoped idempotency key exists anywhere — the outbox dedupes on
event id. It is one of the fields §2 defers to the first external path, and it
has to be persisted before a task is acknowledged.

P4: the host transport is a single enroll route. The take-work channel, the
lease/attempt handshake and result push-back are all new; 'reuse existing'
covers enrollment only.

* docs(agents): name the missing server-binding check, and pin the gate's resolution point

§6 says sourceType alone is not authorization and that 'server binding'
decides the privileged path. Checked against the code, that condition has no
enforcement site: routes/session.ts rejects a client-supplied agent-host type
but passes 'agent' straight through, and acpAgent.ts applies a persona after
confirming only that the roster has the agent — never that the daemon
dispatched this session. Any client with daemon access can therefore claim
sourceType: agent and receive an agent's persona and read-only tool surface
with no run booked. The thread tools still refuse for want of a run frame, but
the persona and tool surface are already handed out. Defined concretely: look
the session up as the sessionId of a claimed run, and refuse otherwise. Added
to §7's inventory as a check that must hold with the gate on as well as off,
and to P0's forge observation, which cannot be satisfied by the gate alone.

Also pins 'resolved at startup' to daemon startup. agentTeamEnabled is read
per session at Config construction; if the new gate copied that, routes and
sessions could resolve different values after a settings change. Sessions
read the daemon's resolved value. Per-workspace narrowing is scoped out of P0.

* docs(agents): make the two documents agree with each other

A read-through of both final drafts found three inconsistencies I had
introduced while folding findings in. The plan's handoff summary claimed
nothing remained awaiting a decision, while §5 of the architecture had since
gained a Codex result-mapping recommendation marked for confirmation before
P3; it now names that one item and separates it from the §9.1/§9.6
transcript re-analysis, which is undone work rather than an open decision.
§7's inventory said 'about twelve' and holds fourteen rows. And §7's header
pinned every row to 6a69c0b5bb when the last eight were checked at a later
head — one that differs from it only by documentation commits, so the
claims hold at both, and the header now says so instead of overstating the
pin.

* feat(core): capture what the model was actually asked, for the P0 off-contract

P0's whole claim is a negative — with collaboration off, nothing
collaboration-shaped reaches the model — and the successor architecture says
plainly that reading the source cannot establish it. Nothing could observe it
either: the request was assembled and sent with no point in between where its
final shape could be read. This is that point, and it comes first because
without it the gate would land with no way to show it works.

A decorator on ContentGenerator writes one JSON line per request: the final
system instruction flattened to text, the declared tool names sorted, the
session's source type and id, and the method. It forwards every call
untouched. It installs at the existing LoggingContentGenerator wrapping site,
outside it, so it sees the request as the model receives it after every other
decorator has had its say.

Test-only by construction rather than by convention: it is built only when
QWEN_CODE_CAPTURE_REQUESTS names a file, so a production run constructs
nothing and pays one env lookup. A capture that throws is swallowed —
observation must not break the thing being observed.

scripts/audit/check-request-capture.mjs exercises it against a fake
generator: no wrapping without the env var, the request reaches the inner
generator unchanged, instruction and tool names recorded, structured
instructions flattened, streaming captured too, countTokens forwarded rather
than eaten by the decorator, and a throwing capture leaving the turn intact.
15 assertions.

Calibrated, not just green: dropping tool-name collection turns the tool
assertion red, and wrapping regardless of the env var turns the no-op
assertion red. Restoring each returns 15/15.

Not run: build, repo typecheck, vitest. Lint is clean and the subsystem
typecheck reports nothing for either changed file.

* feat(agents): gate collaboration behind an opt-in and bind agent sessions to runs

P0 of docs/plans/2026-09-09-agent-service-collaboration-plan.md. Two separate
things, because the plan is explicit that they are separate: an experiment
opt-in that decides whether the feature exists, and a server-side binding check
that decides who may speak as an agent. The opt-in alone is not authorization.

The opt-in — `experimental.agentCollaboration`, default false, also settable
via QWEN_CODE_ENABLE_AGENT_COLLABORATION=1:

- `Config.isAgentCollaborationEnabled()` is the single truth source. Two core
  sites read it: thread-tool registration and the agent tool-invocation guard.
- In the daemon it is resolved once at startup, not per session, because the
  surface includes work no session owns (a recovery sweep, a 5s dispatch timer,
  the Host transport routes). Off means `registerWorkspaceAgentRoutes` and
  `registerAgentHostTransportRoutes` are never called, so the scanner is never
  built and the routes 404 — rather than handlers that refuse after the timer
  is already reading collaboration storage.
- Clients learn about it from a new conditional capability tag,
  `agent_collaboration_v1`, so a UI can drop the entry point instead of
  rendering it and letting the calls fail.
- Creating a `sourceType: agent` session with the opt-in off is refused, not
  downgraded (architecture §6): a downgrade hands back a session the client
  believes is an agent's.

Observed rather than read, with a six-combination probe over the real registry:
off, all of {ordinary session, ordinary subagent, agent session, agent's
subagent} expose zero `thread_*` tools; on, only the agent rows do. That probe
found the gate was one clause too wide. `forSubAgent` is now gone from it — a
subagent runs on `deriveConfig`, which is `Object.create(parent)`, so an agent's
own subagent reads `sourceType === 'agent'` off the prototype chain anyway;
all the clause added was the six tools on subagents of ordinary conversations,
which have no run frame and would have thrown on first use. Dropping it takes
that row to zero and leaves the agent-subagent row at six.

The binding — `findAgentSessionBinding`, checked on agent session creation and
deliberately NOT gated on the opt-in, since with collaboration on is exactly
when it has to hold. `sourceType`/`sourceId` arrive from the client, so on their
own they are a claim; what makes the claim true is that this workspace's store
holds a live run for that agent naming this session.

That check could not hold as the dispatcher stood: `bindRunSession` writes
`run.sessionId` only after `port.start()` returns, and `start` is where the
session is created — so the first turn of every thread would have refused
itself. The port now answers `plannedSessionId` up front and the dispatcher
reserves it on the claimed run before starting. `queued` is not a live status:
only a claimed run can name a session.

Verified by execution, not by reading. scripts/audit/run-workspace-agents.mjs
gains a section asserting the ordering from inside `start()` itself: 193 passed,
0 failed. Mutation-checked — dropping the reservation, the agentId check, or the
run-status check each turns assertions red (2, 2 and 1 respectively). Narrow-scope
tsc over the touched cli files is clean, and that filter was itself checked by
planting a type error.

Refs #11206

* feat(agents): gate the run frame and the shared-threads entry on the opt-in

Two more rows of the architecture §7 checklist.

Session.ts no longer reads a run frame off a prompt when collaboration is off.
The frame can only arrive on the trusted daemon channel and an opted-out daemon
never mounts the routes that dispatch, so in practice none is sent — this is
defence in depth, and it is cheap because every downstream consumer (mid-turn
agent input, the six thread tools) requires the frame this one seam
establishes. Gating it here shuts all of them without a second truth source.

The Web Shell reads the new `agent_collaboration_v1` capability instead of
rendering "Shared threads" unconditionally. The page previously opened straight
onto that view by default, so on a daemon without the opt-in every request
behind it 404s. Definition CRUD on the same page is untouched: it is
`workspace_agents`, a different and unconditional feature — a distinction worth
stating because the two live in the same component and read alike.

Verified the capability's producing end by execution: the tag is absent with no
toggles and with the toggle false, present with it true, is registered
conditional rather than baseline, and turning it on adds exactly that one tag
and removes none. The consuming end is a three-line React guard with no test
covering it; that half is reviewed, not verified.

Refs #11206

* feat(agents): close runs the collaboration switch strands, so recovery cannot revive them

Architecture §6 names a conflict this branch had not resolved. The dispatcher's
recovery sweep asks of every `running` run whether its runtime body is still
there; a missing body with attempts left means a crash, so it requeues and
starts again. A run the operator switched collaboration off underneath looks
exactly the same — the daemon restarted, the body is gone — so opting back in
would silently re-dispatch work nobody asked to resume. Recovery cannot tell
the two apart, and no "state unknown" label stops it.

So the difference is drawn at the one moment it is knowable, a daemon starting
with the opt-in off, and recorded in the store where recovery later finds a
terminal run rather than a live one. `strandLocalRuns` closes every `running` /
`finishing` local run as failed with `closeKind: 'stranded'` and a reason, and
the Web Shell labels it as stranded and counts it outstanding — a person decides
whether the work is re-raised or dropped; the system does neither. `queued` runs
are left alone: nothing started them, so there is no orphan and no ambiguity.

It is a one-shot, not a scanner — no timer, no routes, untrusted workspaces
skipped, and the store's existence checked before the transaction rather than
inside it, since opening one creates the directory and lock file. A workspace
that never used collaboration comes out of an opted-out startup with nothing
written into it.

Implemented as a new `RunCloseKind`, not a new `ThreadRunStatus` member. The
status union has 21 literal consumers across core, routes and the view with no
central terminal set, and adding to it would mean 21 unaudited branches;
`closeKind` is already the field that says how a run ended, and its two
validators and the UI's label switch are the whole blast radius. Worth flagging
as a reading of "独立的搁浅终态" rather than a literal one — the run is
distinguishable and terminal, but via the close kind rather than the status.

scripts/audit/run-workspace-agents.mjs: 203 passed, 0 failed, including that a
subsequent dispatch tick does not restart a stranded run, that a second sweep
writes nothing, and that a collaboration-free workspace is left without a store.
Mutation-checked: dropping the existence guard or the close kind each turns one
red.

Refs #11206

* test(agents): make the collaboration gate observation reproducible

The six-combination probe that found the gate's `forSubAgent` clause was a
one-off in a scratch directory. P0 asks for this observation as its pass
condition, so it belongs where it can be re-run: 21 assertions over real
`Config` objects and real tool registries, no daemon, no bridge, no model,
seconds to run.

Covers what the plan lists: with the switch off none of the four session kinds
sees a `thread_*` tool; with it on only the two agent kinds do; the two
experiment switches are independent in all four combinations and Agent Team
alone exposes nothing; the env override reaches collaboration and not Agent
Team; and the capability tag appears if and only if the toggle is set.

Mutation-checked: dropping `isAgentCollaborationEnabled()` from the gate turns
three assertions red.

Refs #11206

* feat(agents): freeze the A2A contract, with the mapping as code rather than prose

P1. A2A is a rolling document, so "A2A-compatible" only means something against
a pinned version — which is why the plan asks for one before any transport
exists. Pinned: protocol `1.0`, the `JSONRPC` binding, `@a2a-js/sdk@1.1.0`
(Apache-2.0, node>=20, and it ships `./server/express`, which is the shape the
daemon already is), the card at `.well-known/agent-card.json`. gRPC was the
alternative and would have added two runtime peers for no capability we need.

Every constant was read from the specification and from the SDK's own type
declarations rather than inferred, and the parts that can be wrong silently
live in `a2a-contract.ts` with assertions on them, not only in the document.
scripts/audit/run-workspace-agents.mjs is 227 passed, 0 failed.

Three findings the spec forced, each of which changes what P2 may assume:

- A2A 1.0 has no usage or token field anywhere in its data model, so remote
  usage cannot be required of a third-party agent. Ours rides in
  `Task.metadata` under a declared extension URI, and a missing figure must
  read as unknown — reporting it as 0 would make an agent that declines to
  report free to call. Mutation-checked.
- Deduplication is `MAY`, on a client-minted `Message.messageId`, with no
  scoping — two callers can present the same id. So the key is the server's:
  `externalRequestKey` scopes it by authenticated caller and target agent, and
  length-prefixes the parts, because ids are opaque strings from outside and a
  caller who can put the separator inside one could otherwise forge another
  caller's key. Also mutation-checked.
- `TASK_STATE_REJECTED`, `TASK_STATE_AUTH_REQUIRED` and thread-level
  cancellation have no local counterpart — `ThreadStatus` has no cancelled
  member, only runs do. So an inbound `cancelTask` cannot be represented today
  and P2 must add it before claiming cancellation works.

The load-bearing decision: an A2A `Task` is one local `Thread`, not one
`ThreadRun` — a Task survives `INPUT_REQUIRED` and further input, which is a
thread being answered and worked again, whereas a run is a single turn with no
protocol counterpart. `contextId` is then `rootThreadId`. `in_review` maps to
`INPUT_REQUIRED`, which loses the "asked a question" / "submitted for review"
distinction across the boundary; it is kept in the extension metadata.

Interop acceptance client: `a2a-sdk` (Python, PyPI 1.1.2) — a different
language and codebase from our server SDK, so it is not the two-ends-of-our-own
-client self-test architecture §6 rules out.

No transport, no Agent Card, no auth, no intake storage, and the SDK is not yet
a dependency. P1's threshold is the recorded mapping and unsupported list;
making it talk is P2.

Refs #11206

* feat(agents): accept external work idempotently and scope it to its caller

P2's first half — the part that is wrong or right without a network, so it does
not wait for one. Two properties, both of which the plan lists as acceptance
conditions and neither of which needs a remote peer to check:

A retry must not produce a second piece of work. The lookup, the intake record
and the thread are one transaction: split across two, a retry arriving between
them would be looked up, not found, and accepted again — which is the exact
failure the key exists to prevent, so writing the key afterwards is writing it
too late. A repeat returns the original thread and books no second run; it does
not re-post the message either, because a retry means the caller did not hear
the answer, not that it wants the work done twice.

Reusing a key for different content is refused, loudly and with its own error
class — the transport has to answer that case differently from every other
failure, since a caller that cannot tell "your request failed" from "you already
used this id for something else" will retry forever.

And one caller must not reach another's work. B serves several authorized
clients over one queue, so reads are scoped by authenticated caller rather than
merely filtered for display: `getExternalThreadForCaller` returns undefined for
"no such thread" and "not yours" alike, because a caller able to distinguish
them can enumerate another client's work.

The intake record lives on the thread, not in an index of its own. An index
would be a second write, and a second write can disagree with the first about
whether work was accepted; lookup by key is a scan, which costs what the store's
other scans cost and cannot go stale. Present-but-malformed is rejected rather
than ignored — dropping the field would hand the thread to whoever asked next.

scripts/audit/run-workspace-agents.mjs: 245 passed, 0 failed. Mutation-checked —
dropping the content-hash comparison, the list scoping, or the by-id ownership
check each turns assertions red (2, 1 and 2).

Still absent, and not claimed: the A2A transport itself, the Agent Card,
authentication, and anything about network reachability. Those need the
deployment decisions the plan holds for a human.

Refs #11206

* feat(agents): give a thread a cancelled state, closing the gap P1 recorded

P1's contract listed thread-level cancellation as a local gap that blocked
`cancelTask` — A2A makes it a required operation, and `ThreadStatus` had no
member for it, only runs did. So there was nothing for an inbound cancel to
become, and a server could not honestly claim A2A without this.

Seven places asked `thread.status === 'done'`, and every one of them meant
"this thread is over" rather than "someone pressed done": dispatch admission,
candidate selection, recovery, close handling, assignment and status
resolution. They now ask `isThreadTerminal`. Adding a second literal at each
site would have been seven chances to miss one, and the one missed would have
kept dispatching work for a task its caller had already withdrawn.

Cancelling retires the thread's runs the way the existing "mark done" route
does — a queued run no selection will ever pick still reads as pending work,
and a live one is asked to stop rather than quietly relabelled. `cancelling` is
a request, not a report, so the number of still-live runs comes back separately:
the receipt can say "nothing further will start" without claiming the body has
stopped, which the plan asks to be reported apart. Cancelling an already-done
task leaves it done rather than rewriting how the work ended.

scripts/audit/run-workspace-agents.mjs: 259 passed, 0 failed.

Two of the three mutations initially SURVIVED, and the reason is worth
recording: the cancelled thread shared an agent that already had a live run, so
candidate selection skipped it for being busy and never consulted the thread's
status — the assertion passed without testing anything. And the late-post
assertion could not reach admission at all, because a thread still holding a
queued run coalesces a new message into it and short-circuits first. Both are
fixed: cancellation gets an idle agent of its own, the test first asserts the
run is queued and its agent is free, and the late-post case drains its run
before cancelling. All three mutations now turn assertions red.

Web Shell groups a cancelled thread with done rather than idle — an idle group
is things still waiting on someone, which a withdrawn task is not.

Refs #11206

* feat(agents): serve the five required A2A operations, with grants that actually gate

P2's server side, transport-free. What a JSON-RPC layer adds is framing and
status codes; what can be got wrong — who may call, what a retry does, which
task a caller may see, what state it is told — is all here, so all of it is
exercised without a socket.

Grants are per caller and per agent, never per daemon. The plan is explicit
that the daemon's management token is never handed to an external
collaborator, so authenticating as a caller must not be a route to it: a grant
names one agent, and opening agent A says nothing about agent B. Secrets are
returned once at issue and stored only as digests, compared in constant time.
`analysis` is read-only and is what the plan opens first; `full` covers it,
never the reverse, and the two are spelled out rather than ordered by index so
adding a scope cannot silently widen an existing one.

Every authorisation failure reaches the caller as one undifferentiated refusal.
An unauthorised caller that can tell "no such agent" from "wrong secret" from
"revoked" can enumerate this daemon's agents; the distinctions stay on our side
for the log. Same reasoning gives `getTask` one answer for "no such task" and
"not yours". The extended Agent Card is built per caller for the same reason —
it lists the agents that caller may address, which is exactly its grants.

Streaming and push notifications are advertised as false because they are not
implemented. The spec gates its optional operations on those flags, so
advertising one we do not serve turns a client's correct behaviour into a
failed call.

scripts/audit/run-workspace-agents.mjs: 294 passed, 0 failed, covering every
authorisation boundary the plan lists — wrong secret, ungranted agent, unknown
agent answering identically, a valid secret presented by the wrong caller, a
second client sharing the queue but not the work, scope, expiry, revocation
(which also closes off already-submitted work), re-issue replacing rather than
accumulating, and a retired agent admitting nobody. Mutation-checked on four
guards; each turns 1-3 assertions red.

One assertion failed on first run and the failure was mine, not the code's:
retiring an agent that still has live work is refused, so the busy agent I
reused was never actually retired and the test asserted a refusal in a world
where nothing had happened. It now retires an idle agent and asserts the
retirement took before asserting its consequence.

Still absent: the JSON-RPC transport, the public discovery card, and any claim
about network reachability.

Refs #11206

* feat(agents): decide what a Codex turn ending is evidence of

P3's blocking decision, implemented. Codex has no equivalent of the six
`thread_*` tools, so it never says how its work ended. Applied literally, the
local rule — a run reaching `completed` without a closing tool is `unclosed`,
never implicit success — would leave every Codex turn `unclosed` forever with a
person marking each by hand.

Architecture §5's recommended rule: a turn ending plus a structured result item
is a result; a turn merely ending is `unclosed`. Implemented blind to content —
it sees `turn/completed` statuses and `item/completed` *types*, never item text.
"Done!" is not evidence, and a rule that read it would be guessing success from
natural language, which §5 forbids and which the local path already refuses.

`fileChange` and `exitedReviewMode` count: an edit with a path and a diff exists
whether or not anyone describes it, and the second is Codex's own explicit
completion event. `agentMessage` deliberately does not, and that absence is the
point — it is where "I've finished the analysis" would appear.

**A consequence you should weigh before P3 runs:** a read-only analysis, which
is exactly the first task the plan opens to an external caller, produces an
`agentMessage` and nothing else. Under this rule it lands `unclosed` and waits
for a person — every time. That is the conservative answer the rule was chosen
for rather than an oversight, but it means the common case is not hands-off. If
that is too strict, the fix is to give the adapter a deliverable to emit, not to
start reading the prose. Flagging because §5 marks this decision as needing
confirmation before P3, and this is what confirming it costs.

A delivered result closes as `review`, not silently: the work came from another
runtime under someone else's control, so a person accepts it rather than the
system accepting on their behalf. An interrupted or failed turn claims no close
kind at all — recording one would say the agent decided something.

Method, event and item names are the published Codex App Server protocol's, not
invented; `CodexItemType` is enumerated rather than `string` so a new item type
is a compile error where someone must decide whether it is a deliverable.

scripts/audit/run-workspace-agents.mjs: 308 passed, 0 failed. Mutation-checked:
counting `agentMessage` as a result, or not treating a failed turn as failed,
each turns three assertions red.

No Codex is contacted, installed or required. Which signals count is the part
worth getting right, and it can be argued with before anyone has a server.

Refs #11206

* feat(agents): lease runs to outbound Hosts, so a ghost cannot overwrite its successor

P4's core, without the network. A managed Host reaches out and nothing reaches
in, so the daemon cannot tell a Host that is thinking from one whose network
died — work it holds has to become available again on its own. The danger in
doing that is the obvious one, and it is the one the plan names: the first Host
comes back and writes its result over work a second Host has since done.

A lease is what makes reclaiming safe. Re-acquiring mints a new `leaseId`, never
reuses one, and every write is checked against both that id and the run attempt.
The id catches a worker whose hold was taken over; the attempt catches the
subtler case where the run was requeued and restarted — possibly by the very
same Host — so an id from the previous attempt would otherwise still look
current. Both refusals are distinct, because they mean different things to
whoever is debugging.

Renewal is a heartbeat, not a claim: a lapsed lease is refused rather than
revived, since a Host unreachable for longer than the window is exactly the case
the window exists to notice. A Host can hand its own lease back early but cannot
free another's, and terminal runs are not leasable at all — a Host handed one
would do work nothing will accept.

A malformed lease fails the run record rather than being dropped. A dropped
lease reads as "nobody holds this run", which is the single answer that lets two
Hosts execute the same work.

scripts/audit/run-workspace-agents.mjs: 326 passed, 0 failed. Mutation-checked:
skipping the attempt check, reusing the lease id on re-acquisition, or not
checking whether another Host holds it each turn 1-3 assertions red.

The pickup long-poll, the lease transport and result return are not here. They
are the parts that need two machines, and they are worth nothing until this is
right — which it now demonstrably is.

Refs #11206

* feat(web-shell): answer P5's three questions in one pass

"Who is queued, who needs an answer, whose is the result" — the three things
P5 says a person must be able to answer at a glance, computed together rather
than as three passes. Separate passes are how one thread ends up counted as
both waiting for a person and still running, which reads as two pieces of work
where there is one; the assertion that every thread lands in exactly one place
is there to keep it that way.

Needing a person outranks a live run deliberately. A blocked thread can still
show a run mid-teardown, and telling someone it is running makes them wait for
something that will not happen. Mutation-checked by swapping the order.

A workspace now holds locally raised work and work raised by an external A2A
caller side by side in one list, so finished items carry an owner — without it
"whose result is this" has no answer on the row. Sub-threads fold into their
parent: a split is how an agent organises its own work, and surfacing each one
turns one task into a list nobody asked for.

scripts/audit/run-workspace-agents.mjs: 335 passed, 0 failed.

This is the view logic, not the page. The remaining P5 work — one entry point
that starts local-plus-remote collaboration without first making a Team or
opening a task tab — needs P2/P3 to be reachable before it has anything to
show.

Refs #11206

* docs(agents): record what each phase actually delivered, and what it did not

Per the plan's own rule — report observations, not percentages, and write
"not run" where nothing ran. Each phase gets what landed, what is still
absent, and the human decisions still blocking it.

Refs #11206

* docs(agents): hand the two transport tasks off with their constraints attached

A2A JSON-RPC and the Host outbound pickup are both transport-only now: the
semantics, storage and authorisation behind each are implemented and covered
by assertions. The brief says which functions to call rather than what to
build, so neither gets reimplemented alongside what already passes.

The constraints carry their reasons, because several are load-bearing in ways
that read as style otherwise: A2A's four authorisation failures must stay one
undifferentiated refusal at the transport or the caller can enumerate agents;
the public Agent Card is a different document from the per-caller one and must
not list agents; streaming and pushNotifications stay false because the spec
gates its optional operations on them; and every Host write goes through
checkRunLease rather than a second reconciliation, since that check is the
only thing stopping a returning worker overwriting its successor.

Acceptance for each is an observation, not a build: A2A against the Python
a2a-sdk chosen in P1 as the independent interop client, and the Host path
against two Hosts where the first one's late write is refused as stale_lease.

Refs #11206

* feat(cli): expose workspace agents over A2A

* feat(cli): add managed host run pickup

* feat(web-shell): choose agent execution hosts

* fix(agents): end workspace turns at handoff

* feat(agents): execute managed host assignments

* feat(agents): run managed work with codex

* fix(agents): complete delegated work handoff and resume waiting agents

Keep each ACP attempt active until its own terminal result, isolate managed Hosts from coordinator scheduling, and route peer results back to waiting agents.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(agents): make Host pickup refuse terminal threads, and revive the audit suite

Two findings from reviewing the transport work.

**The audit harness had stopped running.** `thread_create` now requires
`assignee` — a defensible narrowing, since an unassigned sub-thread is work
nobody owns — but the harness still called it without one, so it threw on
startup and all 335 assertions silently stopped covering anything. Every one of
the seven transport commits landed with the suite dead. Harness updated; it is
green again, and the one assertion that then failed was its own fault, not the
code's: pinning `autoTurnsUsed === 7` pinned the booking behaviour too, when
what it is for is that a child inherits the parent's count rather than resetting.

**`pickupRunForHost` handed out work the dispatcher refuses.** It is a second
selection path beside `selectCandidates`, and it had no terminal-thread check —
so a Host could be given a queued run on a thread whose caller had cancelled it
or whose owner had marked it done, while the dispatcher correctly ignored the
same run. This is exactly the failure `isThreadTerminal` was introduced to
prevent when the seven existing sites were unified, and it is an eighth site
added afterwards without it.

Observed rather than argued: the harness gains a section that first asserts a
Host *can* pick up live work — without that the negative case passes for the
wrong reason, which it did on the first attempt — then marks the thread
terminal and asserts both paths agree. 339 passed, 0 failed; mutation-checked by
removing the guard again.

The held-lease branch gets the same check. A thread that goes terminal while a
Host holds it should not have that hold extended — the work is over.

Refs #11206

* test(agents): cover the Host pickup and result paths that had none

`pickupRunForHost` decides what work leaves this daemon for another machine and
`applyHostRunResult` is where a stale worker would do damage. Both landed with
no assertions; neither needed a network to be checked.

Pickup: a Host takes only work for an agent placed on it, never another Host's
and never a locally-executed agent — and the local dispatcher leaves
managed-host work alone, which is the other half of that separation, since
without it both sides run the same task. A reconnecting Host resumes the lease
it already holds rather than being handed a second copy of its own work under a
new id. Concurrency is enforced across Hosts.

Results: a lease id nobody holds, a different Host under the right lease id, a
previous attempt, and an unknown run are each refused — and the refusals are
asserted before the legitimate write, so none of them passes merely because the
run was already closed. The holder's own result closes the run with the kind it
reported rather than an implicit success, moves the thread to review, and
resending it is recognised as a replay instead of closing twice. A failure
reports as a failure with the Host's reason and no close kind invented for it.

Mutation-checked, all four bite: dropping the hostId check on results (3 red),
not recognising a replay (1), not distinguishing which Host is asking (6), and
skipping the concurrency limit (1). 369 passed, 0 failed.

One assertion was wrong on the first attempt and the API was right: testing
the concurrency limit against a single Host asserted nothing, because a Host
asking again resumes the assignment it already holds and the resume branch
answers before the limit is ever consulted. The limit only decides anything
when a second Host is placed on the same agent, which is what it now tests.

Refs #11206

* test(agents): drive the A2A transport over real HTTP

`routes/a2a.ts` mounts routes on an Express app, so the honest way to check it
is to mount them and make requests — testing its internals would test a shape
no client speaks. A fake workspace registry points at a temp project root; the
store beneath is the real one, and there is no daemon and no model. It skips
with exit 0 when the SDK is not installed rather than failing for the wrong
reason.

What it pins is the part a JSON-RPC layer can quietly get wrong. The public
card lists no agents and names none, so anyone may fetch it. Six different
authorisation failures — no credentials, wrong secret, ungranted agent, unknown
agent, another caller's identity, unknown workspace — all answer with the same
code and the same message, and a task that is not yours is byte-for-byte
indistinguishable from one that does not exist. Streaming is refused, matching
the capability the card advertises. Idempotency and caller scoping survive the
transport: a resent message id yields the same task and the store holds one
piece of work, a reused id with different content is a distinct error naming
the existing task, and a second client gets its own task and cannot read,
cancel or list the first's.

Three assertions were wrong before they were right, each for a reason worth
keeping:

- Every request was missing `A2A-Version`, so the SDK assumed the legacy 0.3
  wire version and refused all of them with one code — which turned "all
  refusals look alike" green without testing anything. The header is now sent,
  and the version negotiation itself is asserted.
- The unauthenticated case sent empty params, so it failed parameter validation
  before reaching auth and reported a leak that was not there. Every refusal now
  sends a well-formed request, so the only difference between them is
  authorisation.
- The public card's `skills` is absent rather than `[]`, because protobuf
  `toJSON` omits empty repeated fields.

34 passed, 0 failed. Mutation-checked: leaking why a refusal happened (2 red),
listing agents on the public card (2), and answering differently for "not
yours" than for "no such task" (1). A fourth mutation, swapping the not-found
error type, correctly survives — it changes both cases alike and leaks nothing.

Refs #11206

* test(agents): pin the asymmetry in who must name a thread's owner

`thread_create` requires an assignee; `createThread` does not. That reads like
an inconsistency and is not one: an agent splitting work must say who does it,
or it has created work nothing will ever dispatch, while a person may raise a
thread and decide later. Both halves are now asserted, along with the fact that
the refusal names the missing field and leaves no orphan thread behind — it is
rejected by the tool's own schema, so the model never reaches an execution it
could misread as partial success.

Also records the four audit scripts and what each covers in the plan, since
"335 passed" in a commit message ages badly and the counts had already moved.

373 passed, 0 failed.

Refs #11206

* chore: sync pnpm-lock.yaml with @a2a-js/sdk dependency

* fix(agents): refuse an executor a workspace Agent cannot honour, and clear the CI gates

Three findings from reviewing the branch after main was merged in.

**A definition's external executor was silently dropped.** Subagent definitions
gained an `executor` block (#11003): the turn runs on an external agent over
ACP. Workspace Agents borrow a definition's prompt, model and tool config
through `agentType`, but they never go through `createAgentHeadless`, which is
the only thing that honours an executor — they are their own top-level session.
So attaching such a definition took its instructions and ran the turn locally as
Qwen: the operator asked for one runtime and got another wearing the first one's
prompt. It now refuses, on exactly the reasoning the neighbouring rendered-prompt
case already gives, and the message points at `execution.mode: "managed-host"`,
which is how a workspace Agent actually says it runs elsewhere. Confirmed by
execution before and after: the assertion failed with "resolved" first.

**Seventeen files across the PR failed the Prettier gate**, mine and the
transport work's alike. CI runs Prettier with `--experimental-cli`, which
rejects files the classic CLI passes, so neither of us saw it. All formatted;
the whole changed set is clean.

**`routes/a2a.ts` had never been linted** and carried six errors. The failure
mapping now has an exhaustiveness default, so adding an `A2AFailure` member is
a compile error at the one place that decides what a caller is told rather than
a silent fall-through; the unused registry parameter is marked; and the two
streaming stubs are declared as methods returning `AsyncGenerator` instead of
generators that never yield — which also means an unsupported operation fails
when it is called rather than when a client first iterates a stream that will
never arrive. The transport audit still reports streaming refused.

Also checked and found sound, so recorded rather than changed: unknown tools
still classify as `deny`, so an upstream tool landing without a classification
fails closed; both lockfiles carry `@a2a-js/sdk` and its transitive `jose`; and
the new upstream `SessionSourceService` is about attached content sources, not
`sourceType: agent`, so it does not touch the server-binding check.

376 / 34 / 21 / 15 across the four audits, all green after the merge.

Refs #11206

* test(agents): a waiting agent is woken once, not on every later close

e0f75795cc made a close @-mention peers whose runs ended `waiting`, so they
resume. The risk in that is repeat wake-ups: if the wait were not acknowledged
when answered, every later close on the thread would wake the same agent again
and spend its turn budget on nothing new. Now asserted end to end: both agents
mid-turn on one thread (a wait is only legal while something can still wake
it), the waiter closes `waiting`, the closer's review wakes it with one new run
and acknowledges the wait, the woken run is drained, and a second close wakes
nothing.

Every step is read from the store rather than a tool's return shape. The first
version of this scenario had the waiter call `thread_wait` alone on the thread;
the tool refused ("waiting would strand it"), the refusal sat in `llmContent`
rather than `.error`, and the scenario went on asserting against a run that had
closed `unclosed`. Draining the woken run before the second close is also
deliberate — left queued, a second wake-up would coalesce into it and the count
would stay flat for the wrong reason.

Mutation-checked. Dropping the @-mention turns both wake assertions red.
Dropping the acknowledgement in `closeRunInTransaction` turns the "acknowledged"
assertion red but not the end-to-end one — because a person's post also
acknowledges outstanding waits (`thread-actions.ts`), and the second closer turn
here is triggered by one. So the property is defended at two sites, and the two
assertions pin one each. Recorded rather than engineered around.

383 passed, 0 failed.

Refs #11206

* fix(agents): restore the core build, which every CI job on this branch was failing on

`thread_create` was narrowed to require `assignee`, and `thread-tools.test.ts`
was not updated with it. That is a type error, `tsc --build` fails on it, and
`npm run build --workspace=packages/core` fails with the build — so **all four
failing CI jobs on this PR fail on the same two lines**: Lint & Static, Test,
Integration Tests (no-AK) and web-shell E2E Smoke each build before they do
anything else. The main CI has been red on this branch since 2026-09-08 and has
never been green.

Two call sites, both in a test about where a sub-thread lands rather than about
assignment, so they take the same `bob` the neighbouring cases use.

Also adds `scripts/audit/tsconfig.workspace-agents-tests.json`, because this
should not have needed CI to find. The existing narrow configs exclude
`*.test.ts` — they were written to check production sources — but CI compiles
test files too, so a break confined to a test was invisible locally while being
fatal remotely. The new config covers the workspace-agents tests and
`thread-tools.test.ts`, and was verified by reverting the fix: it reports both
TS2345s, then none.

383 passed, 0 failed on the behavioural audit; the four A2A/gate/capture suites
are unaffected.

Refs #11206

* fix(cli): fold the agent-run type import into Session.ts's existing type block

`Lint & Static` was still red after the build fix, on two warnings — and CI runs
`eslint . --max-warnings 0`, so a warning is a failure.

`Session.ts` already had one `import type { … }` block and one value block from
`@qwen-code/qwen-code-core`. This branch added `type AgentRunContext` inline in
the value block, which gives the module two type-import surfaces and trips
`import/no-duplicates` on both. Moved into the existing type block, which is the
arrangement main has and lints clean.

Confirmed it was ours rather than pre-existing by linting main's copy of the
file in place: clean there, two warnings here.

This is also a mistake in how I had been checking. I ran eslint with
`| grep -v "no-duplicates"` throughout this work and read the result as clean —
filtering out the one warning class that CI treats as fatal. `npm run lint:ci`,
the exact CI command, now passes repo-wide with no output; that is what I should
have been running all along.

383 passed on the behavioural audit; Prettier clean.

Refs #11206

* fix: regenerate the settings schema, and guard an optional Config method

Two more CI failures on 8d3e506a3, one mine and one from the session-naming work.

**`Lint & Static`: "settings.schema.json is out of date."** I added
`experimental.agentCollaboration` to `settingsSchema.ts` and never regenerated
the JSON schema the gate diffs against. The generator cannot run in this
sandbox — it resolves `@qwen-code/qwen-code-core/envVarResolver`, and the
installed copy here predates that subpath export — so the entry is written by
hand from its sibling `agentTeam`'s exact shape: same key order
(`description`, `type`, `default`), same position between `agentTeam` and
`artifact` as in the source. The description is asserted equal to the one in
`settingsSchema.ts` verbatim, and the file is Prettier-clean. CI's regenerate-
and-diff step is what confirms it; I could not.

**`Test (ubuntu)`: `config.getWorkspaceAgentName is not a function`** — every
failure in `acpAgent.test.ts` traces to one unguarded call in
`createAndStoreSession`. The method exists on a real `Config`, but this layer
is handed Config-shaped objects that are not always complete, which is why
`getWarnings`, `getSessionId` and `getFailedMcpServerNames` in the same file are
all called through a `typeof … === 'function'` guard. This one now is too, so a
missing method costs the session its agent title instead of failing session
creation outright.

That test file cannot run here (it needs the workspace packages built, which
OOMs in this sandbox), so the second fix is reviewed rather than executed — CI
is the check. The alternative, teaching the test double the method, was
rejected as the less conventional of the two: the guard follows three
precedents in the same file.

383 passed on the behavioural audit; `npm run lint:ci` clean repo-wide.

Refs #11206

* fix: guard every optional Config read, and refresh NOTICES.txt

**954 of the CLI shard's 557 test failures were one line of mine.**
`Session.ts` called `this.config.isAgentCollaborationEnabled()` unguarded, and
that layer is handed Config-shaped objects that are not always a full Config.
This is the identical bug I diagnosed in `getWorkspaceAgentName` one commit ago
and fixed there without looking at my own two call sites — the general lesson
was in hand and I did not apply it.

Both sites now go through one `#collaborationEnabled()` helper, so a third
cannot forget the guard, and the third unguarded read in `acpAgent.ts`'s agent
session branch is guarded too. Absent means off everywhere, which is the safe
reading in both places: no run frame is established, and an agent session on a
Config that cannot say whether the operator opted in is refused rather than
granted. Swept the repo for other unguarded calls to either method; there are
none.

**NOTICES.txt** was the remaining `Lint & Static` failure (the settings-schema
entry from the previous commit worked). The regenerated file is one line:
`jose@6.1.3` → `jose@6.2.12`, the transitive dependency `@a2a-js/sdk` pulls in.

Worth recording how that was produced, because the first attempt was wrong: run
in this worktree as it stood, the generator saw 655 dependencies against an
incomplete `node_modules` and rewrote the file with 589 insertions and 7481
deletions. CI's own diff was `2 +-`. A real `npm ci --ignore-scripts` first, then
regenerate, reproduces CI's one-line diff exactly. A generated licence file is
not something to hand-write or approximate.

Audits: 383 / 34 / 21. `npm run lint:ci` clean repo-wide. The CLI test shard
cannot run here (it needs the workspace packages built), so the guard is
verified by the failure signature it removes rather than by a local green run.

Refs #11206

* fix: bring the drifted tests and registries up to what the code now does

CI's Test job is down to 24 failures across three shards; this clears all of
them. Local runs became possible partway through — `tsc --build packages/core`
with a 12GB heap succeeds in this sandbox, contrary to what I had recorded —
so every fix below was run rather than reasoned about, except the three
`server.test.ts` capability cases, which are blocked locally by an unrelated
`web-templates` generator gap and were verified by comparing my expected order
against `getRegisteredServeFeatures()` directly (index 47, between
`workspace_agent_generate` and `workspace_env`, absent without the toggle).

Registries that had to learn about what we added: `agent_collaboration_v1` is
now documented in the serve protocol's conditional-features table, present in
the registered-feature list in declaration order, has its predicate branch in
the drift-insurance test, and `QWEN_CODE_ENABLE_AGENT_COLLABORATION` is declared
in the process.env allowlist with its reason. The six `thread_*` tools gained
Web Shell display names and zh translations. The four `--agent-host-*` serve
flags are registered as fast-path fallbacks — a managed Host is a different
startup mode, so handing off to the full CLI is right.

Tests that encoded behaviour we deliberately changed: thread status reasons are
prose for a person now, with the run id in `outstanding`; the GAP label dropped
"earlier"; the persona states "not a subagent" rather than avoiding the word;
`thread_create` reports work queued rather than woken; a close @-mentions
waiters, so a review leaves the thread in progress rather than in review; a
failed run posts a system message, so acknowledgement sequences shift; an
`open` sub-thread with nobody on it cannot satisfy a wait; and the resumed
workspace-Agent ceiling denies shell while always adding the thread tools.
`ResidentAgentContinuationResult` is a string union, not a boolean.

Two were worse than stale. `dispatcher.test.ts` asserted the delivery watermark
moves at start, which `954c1ffa29` deliberately stopped doing — the local port
reports `consumedOnStart: false` and the input is confirmed after the transcript
flush. And `config.test.ts`'s collaboration-tools case asserted against
`getAllToolNames()`, which this file stubs to `[]` at module scope: its positive
half could never pass and its negative half could never fail. It now reads the
recorded `registerFactory` calls, clearing the shared prototype mock between the
two registries so the ordinary session does not inherit the agent's
registrations.

Local: 1171 core tests and 111 cli tests that were failing now pass; audits
383 / 34 / 21; `npm run lint:ci` clean repo-wide.

Refs #11206

* fix: complete the i18n keys and record that terminateTurn is no longer Goal-only

CI is down to two failures from twenty-four; this clears both.

**i18n.** Adding the six `thread_*` display names to zh alone was half the job:
`check-i18n` requires every key to exist in `en.js` first and to be translated
in both Chinese locales, so the run failed with "Extra key in zh.js (not in
en.js)". English base keys and zh-TW translations added; `check-i18n` now passes
all checks. The Web Shell keeps its own `toolName.*` table, which also needed
the six — that is what its own drift test caught.

**`terminateTurn` outside a Goal turn.** The test asserted an ordinary turn
keeps its tool loop, and its comment explained why: "nothing but the Goal tool
sets it today". That is no longer true — the workspace-Agent closing tools set
it too, and `#endTurnAfterToolRun` (renamed from `#endGoalTurnAfterToolRun`)
deliberately dropped the Goal-turn guard, because a hand-off makes later work in
the same physical turn stale for the same reason a checkpoint does. The case now
asserts the flag is honoured wherever it is set; tools that do not set it keep
the ordinary loop, which the neighbouring cases already cover.

`npm run lint:ci` clean, `check-i18n` green, and the two test files pass
locally (`toolFormatting.test.ts` 51/51; the Session case is verified against
CI's own received value, since that file needs a `web-templates` generator this
sandbox cannot complete).

Refs #11206

* fix(scripts): do not crash the dev launcher on a manifest without an exports map

The last two test failures cleared; `Lint & Static` went green for the first
time. What surfaced behind them was five failures in `scripts/tests/dev.test.js`,
all from one line this branch added.

The acp-bridge source remapping reads that package's `exports` and iterates it
directly. With no `exports` field — which is what the launcher sees when the
manifest read is stubbed, and what it would see against any manifest that omits
one — `Object.entries(undefined)` throws at module load, so the launcher dies
for every caller rather than skipping a remapping it has no input for. Absent
now means nothing to remap.

Verified by running the suite: 6/6 after, and removing the `?? {}` again
reproduces `TypeError: Cannot convert undefined or null to object` across the
file.

Refs #11206

* fix(mesh): authenticate A2A requests before task lookup

* fix(mesh): renew exact Host leases without claiming more work

* fix(mesh): quote task frames and record review regression checks

* feat(mesh): stream live agent replies into project conversations

* test(mesh): add repeatable Playwright streaming acceptance

* fix(mesh): stream provider thoughts and clarify queued agent state

* fix(mesh): request Codex reasoning summaries for streamed runs

* fix(mesh): show startup loading and toggle activity panel

* fix(mesh): resume persistent Codex host conversations

* fix(mesh): retain Codex processes and deliver in-flight follow-ups

* fix(web-shell): clarify pending agent execution confirmation

* fix(web-shell): move agent activity into right panel and bridge loading states

* fix(web-shell): retain native chat header for collaboration

* docs(design): record cross-machine agent-host acceptance

* refactor(web-shell): drop the Agents session-source tab

An agent session holds one run's transcript, not a conversation, and the run
row inside its own conversation already opens it. Listing runs beside
conversations made the sidebar's Sessions group read as if they were
conversations, so the source switch is Tasks and Channels again.

* fix(core): keep the merged agent-session fixtures as separate transcript entries

* fix(cli): import core symbols from their defining modules

* style: format the merged files with Prettier

* fix(cli): cover --agent-host-allow-http in the serve fast-path option map

* fix(web-shell): keep the collaboration entry from crashing or churning

The collaboration entry dereferenced `collaborationThread.id` after a
comparison that is true when both sides are undefined, and read
`capabilities?.features.includes(...)` where only the first hop was
optional. Both threw against the partial capability snapshots and mocked
workspaces the suites use.

The entry also returned fresh `providers` and `submit` values on every
render, and App.tsx handed the composer a freshly built `atProviders`
array, so ChatEditor re-rendered on unrelated renders and its props never
compared equal. Memoize the hook's return, the two collaboration
callbacks, and the merged provider list.

The sidebar suites load AgentCreatePage through the threads route, so
their daemon-react-sdk mocks need DAEMON_APPROVAL_MODES.

* test(scripts): provide readdirSync in the dev launcher's fs mock

scripts/dev.js scans packages/channels with readdirSync at import time,
but the node:fs mock in dev.test.js never exported it, so five tests
failed with 'No "readdirSync" export is defined on the "node:fs" mock'
before their assertions ran. Return an empty directory listing: the scan
only keeps a shared worktree install from mixing another checkout's
channel build, and no test here depends on a registered channel.

* fix(core): classify the omni media tools for workspace agents

* fix(cli): restore the goal-turn argument on the end-turn path

Renaming the goal-turn helper to #endTurnAfterToolRun during the merge
dropped main's goalTurn parameter, which left packages/cli failing to
compile (TS2304). Restore the parameter, pass the enclosing goal turn at
both call sites, and guard the ending-tool-call bookkeeping so a turn
without a goal still ends normally.

* feat(serve): stream agent collaboration events over SSE

Adds GET /workspaces/:ws/agent/events. It pushes `changed` when anything
under the workspace's agent store is written (found with fs.watch, so
routes, the dispatch loop, host routes and thread tools inside agent
sessions are all covered) and `progress` frames with a running agent's
reply, thinking, current tool and pending approval, published straight
from the dispatch port and coalesced to one frame per 100 ms.

The on-disk progress snapshot drops from every 500 ms to every 3 s and
only when it changed; it stays so a reloaded page can show a reply that
is still being written.

A run that shows no activity for 15 minutes is cancelled and fails with
`agent_run_stalled`, except while it waits for a person to answer a tool
approval. Approval requests and resolutions for the run's turn are now
reported (resolution clears only the request on screen).

Stage and detail are codes/raw text instead of hard-coded Chinese UI
copy; the client localizes them.

* feat(web-shell): live agent replies, waiting states and approvals

The collaboration views subscribe to the workspace event stream instead
of polling every second (the sidebar every five): `changed` triggers one
coalesced refetch and `progress` frames fold into the open thread so a
reply streams as it is written. Subscribers of the same workspace share
one connection. When the stream is down or unsupported the views fall
back to the old polling.

Every live run now shows one line saying what it is doing: queued,
runtime offline, starting, thinking, replying or running a named tool
with elapsed time, and after five quiet minutes that it may be stuck,
with Stop. A run waiting on a tool approval shows an approval card whose
options answer through the existing session permission route.

New copy lives in i18n (en and zh-CN); the mesh-streaming e2e spec
follows the renamed strings.

* feat(web-shell): turn run details into a team panel

The side panel opened from a collaboration thread is now the team: every
agent that has been handed or worked on the thread, lead first, each with
a one-line state (queued, runtime offline, thinking, running a named
tool, waiting for your approval, may be stuck, stopped after 15 minutes
without progress, done). Clicking a member opens its session. Live run
details, subtasks, the parent task and past runs follow.

Panel, tab and header labels move to i18n; the mesh-streaming e2e spec
follows the new names.

* feat(web-shell): mention an agent from any conversation

`@agent` now works mid-conversation, not only in an empty chat. The new
collaboration thread carries the last few messages as context, and the
first agent addressed becomes its lead (assignee), so a follow-up without
an @ reaches it and a sub-thread report wakes it instead of leaving the
thread blocked with nobody to act.

The mention picker reads the roster once per five seconds instead of on
every keystroke, and shows each agent's program, runtime and state.

* fix(core): make agent replies and mentions work in a conversation

Four defects that broke the basic "mention an agent, let it bring in
others" loop, all confirmed by the PR audit:

- An agent that answered in plain text (the normal way a chat model
  replies) left its answer only in the run's progress snapshot: peers,
  thread_read and parent reports never saw it, and the thread went
  `blocked` as "ended without a hand-off". The answer is now posted as
  the agent's message through normal admission (so an @mention in it
  wakes that agent), idempotently per run attempt, and such a run closes
  without an `unclosed` obligation.
- Only a person's post that reached nobody now marks the thread blocked;
  an agent reply that wakes no one is the normal end of its turn.
- `@` written straight after Chinese text was not a mention, and a name
  ran into the next word ("请@迁移助手看一下"). The lookbehind now rejects
  only ASCII word characters and dots (emails stay excluded) and a token
  resolves to the longest agent name it starts with, unless more ASCII
  name follows ("@alice2" is not alice). The Web Shell entry uses the
  same rule to pick the lead.
- thread_create reused any same-titled child, including a finished,
  cancelled or reassigned one, so the new hand-off was silently never
  made. It now reuses only a live child with the same assignee.

The web chat hides a run's live preview once the run's answer exists as
a post, so it is not shown twice.

* fix(core): classify the deferred tool bridge for workspace agents

main added the tool_call bridge after this branch forked, so the
exhaustive classification record no longer compiled and every CI build
failed. It can invoke any registered tool, so it is denied like exec.

* feat(web-shell): keep waiting, retry and approval errors

A quiet run's stall warning now offers Keep waiting (hides it for five
minutes) next to Stop. An agent whose latest run failed or timed out
gets a line with Retry, which asks it to continue where it stopped. A
failed approval shows the daemon's reason in the error banner and
reopens the card instead of silently reverting.

* fix(serve): type fixes for the agent event watcher and stall test

* feat(serve): join a coordinator as a runtime with one link

`qwen serve --join <link>` takes the single link the coordinator shows
under Agent → Runtime → Add runtime (<coordinator>/join/<workspace>/<token>)
instead of --agent-host-server, --agent-host-workspace-id and the
QWEN_AGENT_HOST_ENROLLMENT_TOKEN variable typed separately. The token is
single-use and short-lived; the host saves its own credential after
enrolling, as before.

A host now advertises every program it can run (Qwen Code always, Codex
when its CLI is on PATH or was asked for) so one machine appears as one
runtime offering several programs.

* feat(web-shell): Agent page tabs and a one-line Add runtime dialog

The Agent page now always shows its three tabs (Agents, Conversations,
Runtimes); they were hidden whenever the page was opened from the
sidebar, which instead carried three sub-links. The sidebar keeps a
single Agent entry.

Add runtime is a dialog: it issues a single-use join token, shows
`qwen serve ... --join '<link>'` (and an npx variant) with copy buttons,
warns when the address is localhost or plain HTTP, and waits — the live
stream flips it to "<name> is connected", listing the programs that
machine offers, with a shortcut to create an agent on it. The old inline
enrollment and remote-connect forms are gone; connecting an existing
serve by address and token is the dialog's second tab. The client no
longer builds the long enrollment command itself.

Removes web code the PR audit confirmed dead: ThreadConversations.tsx,
summariseWorkspaceWork, ThreadGroup.label, the unreachable create-method
chooser in AgentCreatePage, the initialCreateTask/api/runtime/loading
props, unused CSS module classes and a duplicated prop spread.

* refactor(agents): remove dead workspace-agent code confirmed by audit

Drops the scripts/audit harness (not wired into CI), request capture,
the Codex turn-result adapter, the notification pipeline nothing could
trigger, unused store/dispatcher/lease/A2A exports and legacy
background-agent plumbing. Recover only reports work when a parent
report is actually pending.

* fix(web-shell): hide agentCollaboration from the settings message

The experimental flag has no alias in the settings dialog map, which the
alias-drift test rejects.

* feat(agents): bind agents to a runtime program and share them over A2A

An agent on a runtime can pick the program to run (Qwen Code or Codex)
when that runtime offers both; the host runs each assignment with the
program its agent is bound to and falls back to its default otherwise.

Agents can be shared with callers outside the workspace: each share is
its own A2A grant with a 7-day expiry, the token is shown once with a
ready-to-run SendMessage curl, and any share can be revoked.

A failed run's retry row now hides while that agent has a live run and
once the conversation is closed.

* fix(agents): push runtime liveness changes, not every heartbeat

Heartbeats rewrite hosts.json every five seconds, and each write made
every open tab refetch the whole roster and thread list. A runtime that
died wrote nothing, so it never showed as offline without a reload.

The event stream now compares what a person can see (runtimes, their
programs, online or offline) on each hosts.json write and on a
five-second timer, and only announces a change. Add runtime also
recognises a machine that rejoins under its saved identity.

* feat(web-shell): polish Agent page, New agent and the @ picker

- New agent needs only a name; it can start from a .qwen/agents role,
  preselects the runtime it was opened for, and shows both programs
  with the reason one is unavailable.
- Agent rows read "Program · Runtime", offer "@ Mention" and fold
  configure/share/pause/retire into a menu; moving an agent to another
  runtime keeps its program.
- The @ picker shows each agent's program and runtime and ends with
  "New agent…"; at-items can now be actions via onSelect.
- The collaboration composer says who a reply reaches and that other
  members are not interrupted; skip reasons and leftover strings are
  localised.

* feat(agents): show a running agent's tool calls as steps

A running member now lists its latest tool calls under its status line,
one per line with running, done or failed, like a CI job's steps. The
turn stream reports updates as an object so steps travel with stage and
text; remote runtimes still report stage and text only.

Also: subtasks in the team panel name their assignee, join links last
15 minutes as the spec says, and a share created from a localhost page
warns that its address only works on this computer.

* fix(agents): skip a liveness check when the store is busy

A failed hosts.json read looked like every runtime leaving, which
announced two spurious changes.

* feat(agents): let a person's post pass the token budget

The tree-wide token cap now binds only agent-authored posts, like the
turn budget: agents on an exhausted tree stop waking each other, while
a reply from a person still books work. Every automated path posts as
an agent or the system, and A2A intake always starts a new root thread,
so nothing unattended gains a bypass.

* refactor(agents): move cross-runtime and A2A to a follow-up PR

This PR now carries local collaboration only: agents on this computer,
shared threads, streaming, waiting states, the @ picker and the Agent
page with Agents and Conversations tabs.

Moved out, to return in a stacked PR: agent hosts (join links, the
--join and --agent-host-* flags, enrollment, heartbeat, lease and
pickup routes, the host client and Codex host sessions), per-agent
runtime and program binding, the Runtimes tab and Add runtime dialog,
the A2A server, grants, external intake and agent sharing, and the
@a2a-js/sdk dependency. Records written by builds that had them keep
loading; the extra fields are ignored.

* fix(web-shell): polish found by rendering the collaboration views

- The @ picker read its labels through useI18n() above App's
  I18nProvider, so its category, agent states and "New agent…"
  showed raw keys; App now passes its translator in.
- The team panel called a quiet run "connection lost" after 20 s
  without a snapshot, which a long tool call always triggers. It now
  judges by the agent's own activity, and its copy is localised.
- Context carried over from the originating chat is folded under the
  header instead of rendered as a large user message.
- Starting and resuming runs get their own status line.
- Agent rows keep their columns aligned, and a paused agent reads
  "Paused" rather than "Offline".
- The mesh-streaming spec aborts /events (it had thrown on it), runs
  in a zh-CN locale that survives reloads, and matches current copy.

* feat(web-shell): a typed @name lists matching agents

Typing @re used to fall back to file search and show nothing, so an
agent was only reachable by first picking the Agent category. An @
provider can now claim a typed query that names no category; the agent
provider claims it when the query starts an agent's name, and anything
else (e.g. @src/) still searches files.

* feat(agents): show queue position; cover the live stream in e2e

- A queued run reports how many of its agent's queued runs start
  first (priority, then queue order, as the dispatcher takes them), and
  the status line reads "queued, N ahead" when N > 0.
- The mesh spec gains a case where a reply arrives only over the
  /events stream, so the push path is exercised, not just polling.
  Checked that it fails when the frame targets the wrong run.

* perf(web-shell): keep collaboration strings out of the transcript bundle

The /export html transcript embeds the whole dictionary, and the
collaboration strings (about 22 KB in two languages) pushed it past its
1.3 MB budget once the runtime and share copy landed. They move to their
own module that the transcript build stubs, exactly as Live Voice's do;
tests guard the split, the stub's shape and that no transcript surface
looks a collaboration key up.

* fix(agents): hand-offs that stranded parents or replayed finished turns

- A child thread that answers in plain text now reports to its parent;
  before, a waiting parent never heard back.
- Starting a thread from chat sends the person's message as the
  assignment, so the first run starts from it instead of receiving it
  mid-turn.
- A turn that ended before reading a later message completes, and the
  unread message goes to a successor run instead of replaying the turn.
- A status post earlier in a run no longer hides its final answer.
- The agent input receipt no longer needs chat recording.
- Smaller: charge usage on recovery failure, clear a requeued run's
  progress, reject retired assignees in thread_create, fix a subscribe
  race that could orphan file watchers, and cap the sidebar's live
  streams at two.

* fix(web-shell): collaboration screens in the reader's language

Rendering both languages showed the collaboration UI mixing them: run
triggers and status reasons arrived from the server in English (the
live-run line in Chinese), the task details view and the new
conversation form hard-coded one language each, and the sidebar load
error was Chinese only. All of it now goes through messages.ts, and the
server's fixed sentences are translated one for one on the client.

Also from the render:
- A streaming answer sorted by the run's start, so it sat above a
  status post the same run made later.
- Durations read "585 秒" in one place and "9 分 45 秒" in another.
- "Open conversation" floated unstyled above the details header; it is
  now a header button, and the conversation context is folded there as
  in the chat.
- Approval buttons use the main app's labels for allow and reject.

* test(web-shell): render the collaboration screens in the visuals preview

No visuals scenario reached the collaboration UI, so a PR that changed
it got an empty preview, and the one browser spec for it lacks @smoke
and never runs in CI. Six views in both themes now: the conversation,
its team panel and details, the @ picker in an ordinary chat, the
Agents page and the conversation list. One thread carries every run
state the screens tell apart, the server's own status sentences are
used so their translation is what renders, and each scene asserts its
content before capturing, so an empty picker fails instead of
photographing.

The spec is added to the base arm's allowlist so later PRs get a real
before/after instead of a "new scenario" every time.

* docs(mesh): record live collaboration verification

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(web-shell): add DAEMON_APPROVAL_MODES to sidebar delete-test mock

The mesh sidebar changes pull ThreadsRoute -> AgentCreatePage into this
test's module graph; AgentCreatePage reads DAEMON_APPROVAL_MODES from the
mocked daemon-react-sdk at module scope, so the suite failed at collect
time. Match the sibling sidebar mocks.

* fix(agents): close review-found wedge and boundary holes in workspace agents

- Disabling an agent now settles its queued runs as cancelled in the
  roster transaction: a queued run of a non-addressable agent could never
  start yet pinned its thread in in_progress and blocked retirement, with
  nothing in-band explaining why.
- thread_wait admission no longer counts finishing runs: a finishing run
  already executed its close tool, so a wait admitted against it could
  never be discharged and stranded the thread as blocked.
- claimRun re-checks thread terminal status under the lock; candidate
  selection reads the thread list outside it.
- Agent store files are written 0600 with 0700 directories; transcripts
  hold full conversations and previously landed at the umask default.
- Web Shell: Retry is offered only for roster-addressable agents;
  hand-typed @lead detection applies the picker's enabled/retired
  predicate; the sidebar session merge keeps the daemon catalog order
  the organized bucketing relies on.

* fix(web-shell): add zh-CN entries for experimental.agentCollaboration

Merging main brought in workspace-settings-i18n.test.ts, which requires every
served setting to have settings.label.<key> and settings.description.<key> in
SETTINGS_MESSAGES_ZH. This branch added experimental.agentCollaboration to the
settings schema before that gate existed, so the merge surfaced exactly two
missing entries and failed Test (ubuntu-latest, Node 22.x) with
"expected [ ...(2) ] to deeply equal []".

Add the two zh entries next to the sibling experimental.agentTeam pair, in
schema order. English needs no entry: SETTINGS_MESSAGES_EN carries no
experimental.* labels and the Web Shell falls back to the schema's English
label/description.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-conflict/jmuiw1qih83

* feat(agents): add durable collaboration state

* refactor(agents): split web shell from execution

* fix(serve): revoke stale agent event streams

* feat(web-shell): add workspace agent collaboration UI

* chore(web-shell): drop historical mesh test log

* fix(web-shell): gate restored agent threads

* fix(web-shell): isolate agent collaboration scopes

* fix(agents): stop a cancelled child from pinning a waiting parent

hasLiveDescendant counted any non-'open' child as live, so a cancelled
sub-thread with nothing left to deliver kept its parent's waiting run
forever — the no-live-dependency guard could never fire and the thread
stayed stuck in progress. A terminal child (done or cancelled) now
counts as live only while it still owes a pending parent_report; the
done-with-pending-report case keeps waking the parent as before.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(agents): preserve recoverable workspace state

* test(settings, tools): restore agentCollaboration registry coverage

The split refactor (8366c8d9) dropped the zh-CN entries while the setting
stayed served, which the i18n coverage gate rejects; the settings panel row
also lacked a published alias, which the alias-drift gate and the
settings-exclusions E2E both require. The six thread tools join the
tool-surface budget's not-budgeted list: they are registered only inside
agent-collaboration runs and never reach the general request surface.

* fix(agents): close second-round review findings in collaboration runtime

Core:
- Parent reports neutralize @ in child-controlled text so report content
  cannot suppress the assignee wake or wake the wrong agent.
- First delivery into a long thread discloses the retained pre-window
  prefix instead of committing the watermark past it silently.
- The DELTA section is bounded to the recent window with an explicit
  omission line; the rest stays reachable through thread_read.
- An unreported terminal run no longer suppresses child_blocked, while a
  reported one no longer double-reports; and a person-done thread never
  stacks child_cancelled on top of child_done.
- A stranded run is its own blocking obligation, human-released only,
  instead of an auto-releasable ordinary failure.
- The dead-end rule reads the most recent human admission, so a later
  system report cannot flip a genuinely blocked thread back to
  in_progress.
- Unknown @mentions now require a near-miss of a roster name, so prose
  like @media or @param no longer carries routing authority.
- Disabling an agent settles queued runs before the roster write, and the
  settle re-runs on retry, so a failed scan cannot half-apply the change.

Daemon/CLI:
- A rejected cosmetic session title can no longer turn a successful spawn
  into a terminal launch failure.
- The recovery sweep tests roster addressability (matching the DELETE
  teardown) and tears down workspaces where no agent can take work,
  draining pending parent reports first.
- Teardown awaits the owner's in-flight tick before releasing the host
  session, closing the orphan-spawn race.
- Session catalogs exclude both agent-host and agent body sessions via a
  plural excludeSourceTypes list, so --continue, pickers, and listings no
  longer surface machine-owned sessions; branch creation no longer
  conflicts with a resident mesh agent; restoring a finished agent's
  session strips the agent source so the transcript stays readable.

* fix(agents): keep status reasons English, seed unreadable thread

`ThreadStatusResolution.reason` is documented as "why, in a form a UI can
show beside the status", and every reason `resolveThreadStatus` returns is
English except the two `in_progress` ones, which were Chinese — a consumer
would render mixed languages to the user. Count them in English instead
("1 Agent is running", "2 Agents are running, 1 queued") and pin the
plural/mixed branch, which nothing covered before.

`store.test.ts` seeded the unreadable-thread case with `{}`. A record with
no `schemaVersion` makes `assertKnownVersion` raise `AgentSchemaVersionError`,
which `listThreadsUnlocked` deliberately rethrows instead of deferring, so the
test asserted the deferred "thread records are unreadable" error while the
code raised the fail-closed schema one. That is the red `Test (ubuntu-latest,
Node 22.x)` lane on 45a9668a8f (job 108654095052). Seed a versioned but
unparseable record, which is what "unreadable" means on this path; the
fail-closed schema behaviour keeps its own coverage in the version tests.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmukdzlxfat

* fix(serve): resolve the collaboration opt-in per workspace

The daemon mounted the agent routes from a boot snapshot that skipped
workspace scope, while every hosted session resolved the flag from the
full merge where workspace scope wins — the two could disagree about the
same workspace. The route gate and the recovery sweep now consult the
per-workspace merge (env var still overrides process-wide), so a flipped
workspace takes effect without a daemon restart: its routes answer 404
agent_collaboration_disabled, the sweep strands its leftover live runs
and tears the owner down. The routes are only mounted at boot when some
registered workspace has the flag on, and first-time enablement on an
otherwise-unconfigured daemon still requires a restart, which the doc
row and the setting's requiresRestart marker now say honestly.

* fix(agents): harden the workspace lock and validate run progress

- Give the workspace lock an onCompromised handler like every other
  proper-lockfile site, and tolerate the ERELEASED release that follows a
  compromise so the transaction's result still stands.
- Drop the per-workspace Mutex once nobody holds or waits on it, so a
  long-lived daemon does not keep one per workspace it ever touched.
- Validate the persisted `progress` snapshot: its outputText decides whether
  a completed run closes as unclosed, so a malformed value must not load.
- Replace citations of an external project and of review rounds with the
  reasoning itself; fix the stranded-runs copyright header.

* fix(serve): keep opted-out workspaces untouched and agent sessions owned

- Recovery skips an opted-out workspace that has no collaboration store
  instead of tearing it down, which read (and so created) the store every
  five seconds once any other workspace had opted in.
- A run whose task session is resident without its agent source (a person
  restored the transcript) closes it before reloading it as the agent's;
  attaching would run the turn without the persona, tool ceiling, or thread
  tools.
- `agent_collaboration_v1` is advertised only while the routes are mounted,
  so a workspace opted in after boot no longer gets a capability whose
  routes 404 until restart.
- A settings file that fails to parse keeps the last collaboration answer
  for that workspace rather than reading as "off" and stranding its live
  runs.

* fix(web-shell): follow the per-workspace collaboration opt-in

- A workspace that answers `agent_collaboration_disabled` is skipped by
  the sidebar: no load-failed notice, no event stream retrying a 404, no
  five-second poll. Streams open after the first read so none starts
  against such a workspace.
- An @ message whose roster cannot be read (collaboration off here, or the
  daemon unreachable) is sent as ordinary chat instead of failing, so
  `@README.md` in a non-collaboration workspace still sends.
- Status reasons are matched in the English wording the server now uses,
  including the stranded-run reason, which had no translation.
- A non-JSON error response no longer surfaces as a JSON parse error.
- The workspace session list is only re-sorted when collaboration rows are
  merged in, and a missing timestamp sorts last instead of yielding NaN.
- Move the remaining hardcoded collaboration strings into messages.ts.

* fix(serve): accept the agent-host bridge's closeSession in the dispatch port

1d3173a53e picked closeSession from AcpSessionBridge, whose Promise<void>
return the agent-host bridge's Promise<unknown> does not satisfy, so the
cli build failed. The port only awaits it, so declare it loosely.

* fix(agents): close the stranding paths the lifecycle review found

- Only a near-miss of a roster name is an unknown mention; `@Override`,
  `@media`, a long npm scope no longer suppress the assignee. A shorter
  ASCII name no longer matches into a longer Latin word (`@maría`).
- `hasLiveDescendant` requires evidence of pending work (a live run, an
  outstanding obligation, or a report owed), not a non-`open` label, so a
  wait cannot rest on a quiet child.
- A close discharges peers' waits by stamping their runs, including a
  waiter still `finishing` that the same close just woke.
- A blocked child whose failure was never reported now tells its parent
  (child_blocked), and a done thread does not stack child_cancelled.
- `strandLocalRuns` settles the thread status, ends `cancelling` runs as
  cancelled, and only treats ENOENT as "never used"; stranded runs get
  their own obligation kind and reason.
- `updateWorkspaceAgents` always writes, the outbox default filter owns
  parent reports only, a `system` author defaults to `authorKind: system`,
  and the live-run status set is shared.
- Split the progress validator into small predicates (Prettier gate).
- Tests: stranded-runs, finishing waiter, quiet child, blocked-child report,
  prose mentions, cancelled thread, retired agent, re-enable, tree-scoped
  budget; retention bounds derive from the constants.

* style(agents): drop a stray blank line in the workspace store

* fix(serve): consult only trusted workspaces for the collaboration opt-in

anyAgentCollaborationEnabled read the settings of every registered
workspace, including untrusted ones, whenever capabilities or route
registration were evaluated. An untrusted workspace cannot use
collaboration, and loading its settings writes debug logs during what must
be an untrusted-safe read (multi-workspace-sessions.test).

* fix(web-shell): read the workspace only in the agent activity tab; show the collaboration setting

ArtifactPanel called useWorkspace() on every render, so hosts rendering it
without a workspace provider (SourcePreview.test.tsx on main) threw. The
agent activity tab now reads it itself.

The settings panel hid experimental.agentCollaboration while settings.ts
publishes an alias for it, which the settings alias drift test rejects.
The setting is per-workspace and carries requiresRestart, so it is shown.

* fix(agents): make the thread retention bounds bind

trimThread kept every run that had spent tokens, so MAX_THREAD_RUNS never
dropped a real run, the messages those runs referenced kept
MAX_THREAD_MESSAGES from binding too, and the outbox was never trimmed at
all: a long-lived thread file grew without limit and was rewritten whole
under the workspace lock on every write.

- A run past the bound is dropped whether or not it spent tokens; its spend
  is folded into the thread's new optional `trimmedTokens`, and
  `threadTokens()` (runs plus trimmed) feeds both `tokensUsed` and the
  tree budget at admission, so the budget does not reset.
- Acknowledged outbox events beyond MAX_ACKNOWLEDGED_OUTBOX (50) are
  dropped; pending ones are always kept. An acknowledged event is never
  replayed, and replay idempotency rests on the message's originEventId.

* fix(agents): hold running work to the tree token budget

The budget was checked only at admission, so a run already going kept
spending until it ended (a crash-replayed run closed at 666,749 against a
200,000 budget). Each dispatch pass now records every running run's spend
so far — written only when it moved — and moves the running runs of a tree
at or over DEFAULT_THREAD_TOKEN_BUDGET to `cancelling` with error
`token_budget_exhausted`; the ordinary cancel path then stops them. The
overshoot is at most what a run spent since the previous pass (the host
ticks every second). A run a person triggered is left running, matching the
admission rule that a person's post passes the budget.

* docs(verification): hand off the workspace agent token budget acceptance matrix

* docs(verification): align the acceptance matrix table

* docs(verification): end the budget brief with a newline

* fix(serve): keep the collaboration settings probe out of session debug logs

isAgentCollaborationEnabledFor runs at boot and per request through
loadSettingsCached, which logs a cache hit/miss line on every call; those
lines landed in whichever debug log session was ambient and broke the
untrusted-read isolation test in multi-workspace-sessions.test.ts. The probe
now runs under runWithoutDebugLogSession.

Also fix the new budget tests: port() drops unknown overrides, so
totalTokens has to be spread on after it.

* fix(agents): classify structured memory tools

* refactor(agents): keep execution opt-in out of UI

* feat(web-shell): expose agent collaboration setting

* fix(web-shell): add display names for the thread_* wire tools

The six new thread tools landed without display-name entries, tripping
the TOOL_DISPLAY_NAMES drift guard (Test lane). Pin all six.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* docs(agents): align collaboration PR stack

* fix(web-shell): deduplicate agent tool labels

* fix(agents): isolate disabled collaboration paths

* perf(web-shell): lazy-load collaboration workspace

* test(web-shell): inspect split output chunks

* test(web-shell): stabilize collaboration completion capture

* feat(web-shell): render agent collaboration with the chat's own components

A collaboration conversation had two renderings: the chat, and a separate
"Task details" page with its own look (numbered posts, a plain textarea and
Send button, a native select, monospaced names). The chat itself also
hand-rolled pieces the transcript already has. This makes the conversation
one ordinary Web Shell chat and moves everything else into the Team panel.

- Agent replies and thoughts carry an `author` (name + agent color) that the
  message list renders as a sender row, instead of a bold name in Markdown.
- Live work sits in the transcript's "Parallel agents" group (its classes are
  reused), headed by the server's status sentence.
- Approvals are the main chat's ToolApproval panel.
- The composer toolbar says who a draft will reach.
- The details page is removed. The Team panel lists members once, each
  expanding to its run detail, with "Make lead", subtasks, history and the
  budget. Conversation rows on the Agents page open the chat.
- The Agents page uses the role templates page's frame: title, actions,
  a toggle group, and cards with the same avatar.

* fix(web-shell): keep agent collaboration out of the Plugins page

The Plugins page embeds the Agents manager without a chat to open a
conversation in, so a conversation opened there had no Team panel. Show
collaboration only where the host can open its chat (the sidebar's Agents
entry); the Plugins tab keeps managing role definitions.

* fix(web-shell): preserve collaboration chat controls

* fix(serve): make the agent host owner lifecycle race-free

Two overlapping dispatch() calls could each build an owner: the
check-then-create window awaited the replaced owner's stop() before
publishing to the map. The loser never reached `owners`, so neither
teardown path could ever see it and its keepalive timer ran for the life
of the process. Build and publish synchronously, then drain the replaced
owner.

AgentHostSessionOwner.stop() was idempotent but not single-flight. A
boolean flag let every caller after the first resolve at once, without
awaiting the [inFlight, dispatching, ensuring] drain that its own comment
says the callers' teardown order depends on — so a DELETE racing the
recovery tick could release and close the host session while an ensure()
was still inside spawnOrAttach. Memoize the drain so every caller awaits
it; clearInterval still runs synchronously before the first await.

Also narrow two comments that claimed more than the code does: the
mid-edit settings fallback covers the user and system scopes only,
because the loader recovers a workspace-scope parse error into a valid
`{}` instead of rethrowing, and `agentCollaborationEnabled` is resolved
per response rather than once at boot.

R4-3, R4-6 fixed; R4-4 narrowed to what the code actually covers;
capabilities half of R4-1.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(web-shell): correct collaboration UI state, mention routing and stream handling

Mention interception dropped core's second boundary guard, so a longer
Latin word resolved to a shorter agent name ("@maría" to `mar`) and an
ordinary chat message was silently converted into work assigned to an
agent nobody addressed — one the server-side parser says was never
mentioned. Add the guard, matching `agentForToken`.

`describeRun` returned the failure copy before consulting `closeKind`,
which left its own `stranded` case unreachable and labelled every
stranded run "failed at collaboration-disabled": an internal constant
interpolated into a localized sentence, asserting an agent failure where
nothing failed. Check `closeKind` first, the order core's `obligationFor`
already uses.

`teamMembers` ranked a member's runs by `startedAt ?? 0`, so a run
cancelled or failed while still queued always lost to an older run that
did start, contradicting the field's own "otherwise the latest" contract.
Use `endedAt ?? startedAt`, the recency key `lastActivity` already uses.

The member status tone was a single-class Tailwind utility tied on
specificity with `.memberStatus`'s own `color`, so the later-injected CSS
module sheet won and every status line rendered muted grey. Move the
tones into the module, where every sibling status colour already lives.

The agent event stream was declared open on `response.ok` alone, so a 200
that is not an event stream reset `retryMs` on every cycle: the backoff
never grew and the open/closed flap kept recreating both consumers' poll
intervals before they could elapse. Require the content type.

`onOpenCollaboration` was the only sidebar navigation handler that did
not close the mobile drawer, leaving the drawer and its pointer-capturing
backdrop over the thread it had just opened.

Give the ArtifactPanel collaboration-gate test the flush its positive half
already had. Without it `<Suspense fallback={null}>` satisfied `toBeNull()`
whether or not the gate existed, so the negative half could not fail.

R4-10, R4-11, R4-12, R4-13, R4-15, R4-16, R4-17.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* docs(agents): state the real capabilities contract and name the built daemon

`agent_collaboration_v1` is recomputed per request rather than snapshotted
at boot, so "absent means no workspace had it on at startup" was false in
both directions: the tag also goes absent once every workspace has opted
out, and the pre-runtime bootstrap envelope omits it while reading no
workspace settings at all. Say what absence actually encodes.

The token-budget verification doc told the witness to start `qwen serve`,
which runs whichever installed release PATH resolves to rather than the
artifact step 1 just built — a daemon without this PR, whose every matrix
row then 404s and reads as a budget-contract failure. Name
`node dist/cli.js serve`, matching the sibling verification docs, and ask
the witness to confirm the tag before starting.

R4-2, and the protocol-doc half of R4-1.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(serve): close inactive agent sessions safely

* fix(agents): close the four open R4 review findings

R4-4: the collaboration opt-in predicate now loads settings with a new
`preserveInvalidWorkspaceSettings` option. A half-written workspace
settings file used to be "recovered" by rewriting it to `{}`, which the
predicate read as an opt-out — the recovery tick then stranded every live
run, and the rewrite made the opt-out permanent. The strict load throws
instead, so the predicate keeps its last answer and the file is left alone.
`loadSettingsCached` keeps strict entries apart from default ones.

R4-5: session listings and counts hide `agent` sessions on the live side as
they already did on the persisted side (organized view, metadata filter,
catalog live merge, `/sessions` live path, content search, session-info
`live`), through one `isHiddenCatalogSource` predicate.

R4-7: a skill loaded in a workspace-agent session applies none of its side
effects (hooks, allowedTools grants, review workflow). Its body still
reaches the model. A skill hook spawns a command and PreToolUse fires
before the invocation guard, so it was a way out of the read-only
boundary. `Config.isWorkspaceAgentSession()` is now the single gate used by
the tool registry, the invocation guard and this check.

R4-8: `agent` joins `agent-host` as a reserved source on REST
create/load/resume and ACP-HTTP `session/new`, and ACP-HTTP `session/load`
strips a persisted `agent` source as REST restore already does. A client
could otherwise restore a session under an agent's deterministic session id
with `sourceType: 'agent'` and have the dispatcher send a real run into it.
Only the in-process dispatcher creates agent sessions.

* fix(agents): close remaining collaboration review gaps

* fix(agents): deny skill command fallback

* fix(web-shell): restore transcript size gate

* fix(agents): deliver a reused sub-thread title's new hand-off details

The retry-dedupe predicate keys only on (parent, lowercased title, assignee,
non-terminal), so a second, genuinely different delegation that happens to
reuse a title was swallowed: its body and acceptance criteria were written
nowhere, no message reached the reused thread, and the tool still returned
ok() — so the parent's later summary reported work as delegated that no one
was ever told about.

Dedupe still has to hold (a retry must not duplicate a live hand-off), so the
reuse branch now posts this call's task and acceptance criteria onto the
existing thread through the same admission path as the create branch, and
stays silent for a bare retry that carries nothing new. The reply says the
details were posted rather than implying nothing happened.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(serve): report the tree token total admission actually enforces

`budget.tokensUsed` was computed by an inline sum over `run.usageByRound`
only, duplicated at two sites, so it omitted `trimmedTokens` — the spend of
runs retention dropped. Admission charges the tree with
`threadTokens() = (trimmedTokens ?? 0) + sumRunTokens(runs)`, so once a thread
passed MAX_THREAD_RUNS the route reported a smaller number than the one the
server enforced: a nearly-empty budget bar next to a run closed for
`token_budget_exhausted`.

Both sites now call the store's exported `threadTokens()` helper instead of
re-deriving the total, so there is a single implementation left to drift.

This also fixes the token-budget verification recipe, which named that field as
its only observation surface and so read row 6's retention invariant as
violated, and whose step order edited `DEFAULT_THREAD_TOKEN_BUDGET` after
building — esbuild inlines the constant, so the edit never reached the running
daemon and the whole matrix executed against 1_000_000 rather than 20_000.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(web-shell): order the live transcript by the daemon's clock

`applyProgress` stamped the browser's clock into `progress.receivedAt`, a field
the daemon writes with its own clock. `ThreadChat` uses it as the transcript
sort key against daemon-written `post.at` and `run.startedAt`, so with a remote
daemon — an explicitly supported shape — a browser clock two minutes behind
folded every streamed frame to `serverNow - 120s` and sorted the agent's live
output above the posts it was replying to.

The progress event already carries `activityAt`, which the daemon refreshes on
exactly this frame, so it is the same instant on the same clock.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(agents): close remaining review gaps

* perf(agents): list threads once per store transaction

Admission, closing and disabling each call listThreads() several times
inside one workspace lock hold, re-reading and re-validating every thread
file each time; with many threads the hold outlasted another process's
lockfile retries (10 tries, 5-100 ms). The transaction now reads the
directory once and keeps the listing current through its own writes,
handing callers copies so an unwritten in-place edit cannot leak.

* fix(web-shell): rank the retry candidate by the run's latest activity

retryable picked each agent's latest run by startedAt alone, so a run that
failed while still queued (no startedAt) lost to an older run that did
start and no retry was offered. Use the same runRecency key as teamMembers
(R4-12).

---------

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: yiliang114 <jinjing.zzj@gmail.com>
2026-09-29 16:04:14 +00:00
jinye
a765229c0a
fix(daemon): persist session approval mode across cold restore (#12918)
* fix(daemon): persist session approval mode across cold restore

* fix(core): Skip approval notifications for derived configs

Prevent child-local approval mode changes from invoking the root-only listener on derived Config overlays. Clarify when explicit mode changes create a transcript and how recorder failures are retried.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-09-28 15:07:40 +00:00
易良
e39567eb56
fix(cli): honor --bare and --safe-mode in the cross-session messaging gate (#9845)
* feat(core): accept cross-session messages behind an inbound gate

Step two of QwenLM/qwen-code#8724, rebuilt on current main now that the
registry from step one has landed. A session can be reached by another
session on the same machine, and every message that arrives is gated
before the model can act on it. Off by default behind
`agents.crossSessionMessaging`.

Transport is one UNIX domain socket per session, NDJSON over the wire,
one frame per line. The socket directory is 0700 and the socket 0600,
and that is the whole access-control story: Node cannot read SO_PEERCRED
without a native addon, so a frame's claimed `from` is not
authenticated. Everything downstream assumes that.

The gate is why the transport and the policy land together. With an
explicit `agents.crossSessionInbound` the user decides; unset, the
policy follows approval-mode parity — a message auto-delivers only when
acting on it cannot do more than the sender could already have done
itself. Anything unreadable holds. Held messages are settled rather than
stranded: the buffer is bounded, shutdown expires the rest, and every
terminal outcome goes back to the sender as a control frame.

Content reaches the model inside a <cross_session_message> envelope with
the delimiter defanged in the body, so a peer cannot close the envelope
early and forge one attributed to the user. The envelope carries a fixed
notice that a peer holds none of the user's authority, and the auto-mode
classifier gains the matching rule. `/peers` lists and releases held
messages; without it, holding would be indistinguishable from dropping.

Landed only after four independent reviews, whose non-obvious findings
are worth naming because they were all live defects:

- A receiver in AUTO_EDIT auto-accepted peer messages and nothing
  reviewed what they caused. The old rationale — "every consequential
  action still faces its own gate" — is true of AUTO but false of
  AUTO_EDIT, where edit confirmations are approved outright and the
  classifier does not run. A peer could get a file written with no
  prompt, no classifier and no user. Receiver policy now turns on
  whether the mode reviews actions at all, not on whether it is YOLO.
- `server.unref()` does not cover accepted connections, so any peer that
  connected and lingered pinned the session open forever.
- `fs.mkdir(recursive)` and `fs.chmod` both follow a symlink, so another
  user could pre-create the world-writable `/tmp` fallback directory and
  redirect our 0700 chmod onto a directory of ours.
- A full listen backlog surfaces as EAGAIN on Linux, not EBUSY; the busy
  case was being read as dead on the primary platform.
- The envelope was escapable without markup: `escapeAttribute` handled
  `&<>"` but not newlines, so a crafted `fromName` could emit
  free-standing lines inside the opening tag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(i18n): translate the /peers description in strict-parity locales

`Test (ubuntu-latest, Node 22.x)` has failed every run on this branch with

    FAIL src/i18n/mustTranslateKeys.test.ts
      > does not fall back to English for any built-in command description
        in strict-parity locale { code: 'zh-TW' | 'zh' }
      AssertionError: expected [ 'peers' ] to deeply equal []

`peersCommand` set `description` to a literal English string rather than
routing it through `t()`, so both strict-parity Chinese locales fell back
to English and the parity test — which exists to catch exactly this —
reported the `peers` path.

Four merges of current main were attempted against this check. None could
fix it: the untranslated description is introduced by this branch, so no
state of main contains the missing keys.

Use the `get description() { return t(...) }` form every other built-in
command uses, and add the key to `en.js`, `zh.js` and `zh-TW.js`. All
three locale files remain in sync at 1817 keys.

Verified both directions in a clean worktree install: with the change,
`mustTranslateKeys.test.ts` is 20/20; stashing it reproduces the CI
failure exactly, `expected [ 'peers' ] to deeply equal []`, twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VgTjRF91xANQh6SY9YGyCf

* fix(ipc): close Critical review findings in the cross-session inbox

- Dedup held msgIds case-insensitively, matching /peers resolution
- Print unique dash-stripped handles and resolve them in /peers
- Bound outbound sends: in-flight cap plus an absolute deadline
  (socket.setTimeout is an idle timer a dribbling peer resets)
- Defang whitespace-split and quote-glued envelope delimiter tokens
- Seed onHeldChange subscribers with the current held snapshot
- Skip POSIX-only socket-path tests on win32 like the sibling suites

* fix(cli): gate held-message announcements on the held set growing

Once the hold buffer is full, every further peer frame evicts the oldest
entry while arriving under a fresh id; announcing each of those appends a
history item and re-renders per frame — reintroducing one layer up the
unbounded growth the hold buffer's ceiling exists to prevent.

* fix(ipc): close round-6 Critical findings in the cross-session inbox

* fix(ipc): close round-7 Critical findings in the cross-session inbox

- defang envelope delimiters split by render-invisible separators the
  \s class misses, sharing flattenPeerLabel's strip class so the two
  cannot drift (R5-1)
- restore admission-failed queue submissions deferred until idle, the
  same recovery the /btw path uses, so a restored peer envelope cannot
  leak raw into the racing turn's mid-turn steer drain (R7-1)
- cap the accepted-message backlog symmetric to the held cap: the
  pre-wiring buffer and the post-wiring queue refuse once full, with an
  honest 'expired' receipt instead of unbounded socket-speed growth (R7-11)

* fix(ipc): close round-8 Critical findings in the cross-session inbox

- Refuse any msgId canonicalizing to `all` at the wire boundary and fold
  the /peers bulk keyword case, so a peer-chosen id can never alias the
  bulk action or trap a per-message decision (R8-1, R1-11, R1-27).
- Re-hold approved messages whose delivery fails instead of silently
  dropping them: decide() returns 'failed' and parks the entry back at
  its position, reevaluate() re-holds failed releases, and /peers reports
  the failure honestly (R8-2, R1-18).
- Drain accepted peer envelopes on a preprocessing-free path: the queue
  marks them peer, the drain submits them with the Teammate send type
  (which returns before slash/shell/@ handling), renders the one-line
  projection, and restores failed admissions as peer entries (R8-3).
- Close the envelope-defang class structurally: escape every `<` in peer
  content so no forged delimiter survives regardless of wedged invisibles
  or homoglyph spellings; the open-enumeration regex is deleted (R5-1).

Every guard is witnessed by new focused tests and verified with mutation
probes (each probe fails its witnesses when removed, passes restored).

* fix(ipc): close peer messaging lifecycle races

* feat(core): address local peer sessions from agent tools

* fix(core): make peer addresses unambiguous

* fix(core): reserve peer routing namespace

* fix(ipc): complete peer delivery feedback

* fix(ipc): harden peer session routing

* fix(cli): honor effective peer messaging gate

* fix(cli): report effective peer messaging state

* test(cli): teach the cross-session test doubles the new gate accessor

The interactive UI gates the peer inbox on
`config.isCrossSessionMessagingEnabled()` instead of the merged
`agents.crossSessionMessaging` setting, so every config double that
exercises `startInteractiveUI` must expose that accessor.

The shared `sessionRegistryConfigStub` in `llm.test.tsx` builds most of
those doubles, and the four `startInteractiveUI.test.tsx` cases that
assert the inbox binds need the enabled config rather than the default.

Fixes the 37 failing cli tests (`Tests 37 failed | 28548 passed`) in the
`Test (ubuntu-latest, Node 22.x)` lane; both files now pass 101/101.

* refactor(core): drop peer-receipts, superseded by peer-send on main

The send-side receipt correlation this module implemented shipped on main
as packages/core/src/ipc/peer-send.ts (#10158, step 3 of #8724), in a
strictly richer form: it canonicalizes message ids, models seven delivery
statuses with explicit legal-transition sets, bounds tracked sends at
MAX_TRACKED_SENDS, and reports the settled receipt with its age.

This copy had no production import site -- only its own test imported it --
so keeping it could only let it drift from the shipped version. Removing
both files leaves the branch as just the cross-session messaging gate
change it now describes.

No behavior change.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): gate every peer-inbox reader on --bare and --safe-mode

The only reader was isCrossSessionMessagingEnabled(settings.merged), which
cannot see the two session-level suppressions. A session started with --bare
or --safe-mode bound a UDS inbox and published its ipcPath into the
live-session registry, advertising itself as addressable from the modes that
exist to close cross-process surfaces.

On the daemon path it was worse than visible. sendToPeer reports `disabled`
only when the caller's own record lacks an ipcPath, so publishing one is also
what let a suppressed hosted session send -- while inbound was already
refused. An interactive TUI under the same flags bound nothing and sent
nothing; a hosted session did both.

Answer the question once, in the file whose doc comment already claims to be
the one place it is answered: crossSessionMessagingSuppression(config) reads
the two accessors Config already carries, and
isCrossSessionMessagingActive(settings, config) narrows the setting with it.
All four readers now ask that -- the interactive bind, the interactive
failure banner, /peers, and registerHostedSession.

/peers also stops hardcoding "--bare or --safe-mode" and names the cause
that actually fired, with the environment channel that reaches each, so a
session suppressed only by QWEN_CODE_SAFE_MODE recognises why.

Drops the ConfigParameters field and accessor an earlier shape of this branch
added. Config already stores both flags and loadCliConfig already passes them
in, so the parameter duplicated two booleans that were on the object handed
to every read site. Its fail-closed default was also what made the hosted
reader look unsafe to wire, which is why that reader was deferred rather than
fixed; reading the accessors removes both the parameter and the reason.

Each new case was mutation-verified and each mutation reverted: ignoring
config at the ACP gate, at the interactive bind, or at the banner branch
turns the corresponding cases red; deleting the /peers message arm turns both
of its cases red; dropping the getBareMode clause turns the bare-mode cases
red in three suites.

Refs #8724

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* docs: record that --bare and --safe-mode suppress cross-session messaging

Five places stated the settings-only rule as a contract and would be wrong
after the gate change: the agents.crossSessionMessaging row, the two
--safe-mode inventories, the hosted-session registration paragraph, and the
SDK's inbox note. The row now carries the same "Ignored in --bare and
--safe-mode" clause its sibling general.outputStyle already has, and both
inventories list cross-session messaging alongside the other surfaces safe
mode closes.

The dated design record docs/design/2026-09-02-peer-inbox-reachability.md is
left as written -- it describes the state at its own date, and rewriting it
would falsify the record rather than update it.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): name both causes when a flag and the setting both turn messaging off

`/peers` answered with the startup suppression whenever one was in force, so
a session whose own `agents.crossSessionMessaging` was also off was told the
setting cannot matter, and was sent to a restart that leaves messaging off
with the surviving cause still unnamed. The scope remedy now leads and the
flag follows as a second cause when both fire; the single-cause text is
byte-identical to before.

Also qualify the SDK's universal reachability claim for the two flags in the
three copies that carry it (package README, the `endpoint.ts` file JSDoc and
the hand-maintained `docs/developers/sdk-typescript.md` mirror, whose inbox
bullet was still the pre-PR twin of the one this branch amended), and record
at the gate that an *inherited* `QWEN_CODE_SAFE_MODE` — unlike
`QWEN_CODE_SIMPLE`, which `spawnChannel.ts` scrubs from spawned `qwen --acp`
children — suppresses every session a daemon or editor hosts.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmujyzhfka1

---------

Co-authored-by: qqqys <qys177@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: yiliang114 <effortyliang@gmail.com>
2026-09-28 10:48:12 +00:00
theater
7f06a1e082
docs(developers): document CI and release repository variables (#11129)
* docs(developers): document CI and release repository variables

* docs(developers): scope the worker-cap claim and note the retry tradeoff

Review feedback: state which Vitest invocations actually consume
QWEN_CI_VITEST_MAX_WORKERS, soften the retry-masking absolute, and point
at the workflow files as the source of truth for the defaults.

* docs(developers): attribute each CI pin to the test that actually holds it

Review feedback: release-workflow.test.js does not pin the release
worker-cap expressions; package-scripts.test.js asserts the
QWEN_CI_VITEST_MAX_WORKERS / ecs-qwen- expression, while
release-workflow.test.js pins the release retry and timeout expressions.
Name both files with their respective attributions.

* test(ci): keep documented variable defaults aligned with workflows

* test(ci): derive documented variables bidirectionally and fix web-shell scope note

* docs(ci): clarify variable scope and guard documented defaults

* fix(ci): format variable guard and clarify worker scope
2026-09-28 10:36:29 +00:00
易良
5deddb722b
docs(tools): sync shell.md with the shipped run_shell_command contract (#12841)
* docs(tools): sync shell.md with the shipped run_shell_command contract

Four statements in this file contradict the tool as shipped.

is_background was documented as required -- twice in the arguments list and the Usage note, plus a "Required Background Parameter" section whose rationale was backwards. The schema is required: [command] (shell.ts:5762) and the parameter description says "Optional: ... If not specified, defaults to false (foreground execution)" (shell.ts:5743), so foreground is the fallback, not what the parameter exists to avoid.

The foreground bullet list recommended cat and grep as one-time foreground commands, which the same tool description forbids in favour of the dedicated tools. #12532 replaced that list in the shipped description with one line; this mirrors it and states why the file tools are not candidates, so the list does not come back.

"or by explicitly adding & to commands" offered & as a second control for backgrounding. On the managed background path a bare trailing & is stripped as redundant (stripTrailingBackgroundAmp, shell.ts:143, called at :4125). The sentence is now scoped to that path instead of claiming & never works: the example further down uses it with is_background=false, which is the unmanaged path, and is left alone deliberately.

Verified against origin/main at e68822c815. No other docs file carries the "required" claim. Prettier clean. Docs only -- no code or test change.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* docs(tools): fix the fourth is_background mention to optional

The Background execution choices bullet still called the parameter
required, contradicting the rest of the page after this PR.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* docs(tools): state the actual trailing-& contract

The page said a bare trailing & is "stripped as redundant" and that a
command containing & backgrounds the call. Neither holds: validation
rejects is_background:true with a trailing bare & before execution, the
spawn-time strip only covers what validation does not classify, and a
mid-command & never makes the tool return early. Rewrite the section
lead, the background bullets, and the mislabeled example to the shipped
contract.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* docs(tools): describe what a shell-level & actually does

56a0952a stated that a bare trailing `&` is stripped at spawn time and
that `npm run dev &` with `is_background=false` runs in the foreground
and blocks the turn. Neither holds. `stripTrailingBackgroundAmp` is
called only from `executeBackground`, reached only when `is_background`
is true; the foreground path passes the command through verbatim, so the
shell detaches it and the result settles on 'close' or
POST_EXIT_STREAM_DRAIN_MS after exit — ShellExecutionService names
`sleep 10 &` as exactly that case.

The difference that matters is tracking, not foreground versus background:
only `is_background=true` registers a background task that `task_stop`
can reach. Also drop the `Background PIDs` field name, which no code
emits; the managed return carries `id` and `pid`.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-09-27 12:18:21 +00:00
samuelhsin
81c260bb04
fix(mcp): Support larger Apps, scoped tool calls and isolated origins (#12258)
* fix(mcp): Make App resource limits configurable per server

* docs(mcp): Explain App resource limits in pool fingerprints

* fix(mcp): Degrade oversized App HTML per historical page document (#12258)

Address review on the App resource limits change:

- WebShell historical-page admission now drops each MCP App display's
  html whole (never truncated) when a page exceeds its byte budget, so two
  ceiling-size documents degrade to fallbackText instead of failing the
  whole page as non-retryable unavailable; the design doc ceiling rationale
  now names all five budgets a retained document crosses.
- Pool fingerprint hashes App resource limits at their enforced
  (clamped/floored/defaulted) values via a shared module, so configs with
  byte-identical enforced policy share one pool entry.
- Limit warnings name the setting in the source that declares the server
  (extension manifest or .mcp.json) instead of always pointing at
  settings.json, whose same-named entry would shadow rather than merge.
- Timeout warnings credit the key that produced the deadline (timeout when
  no explicit App timeout is configured).
- A non-finite configured limit now logs a debug warning naming the server,
  key, rejected value, and fallback instead of being silently discarded.
- Add a reconnect-replay test pinning that App resource limits survive
  rediscovery, and sync the daemon transport-pool doc with the fingerprint
  contract.

* fix(mcp): Preserve live sessions when App HTML exceeds queue budgets

* fix(mcp): Preserve App limits across SDK and settings configuration

* docs(mcp): Use verified Amplitude resource timeout

* fix(mcp): Degrade App HTML in the replay envelope and window (#12258)

- Restore the live byte cap's first-item rule for degraded App frames so
  a caught-up subscriber is admitted, not evicted, when even the reduced
  frame overflows an empty queue
- Refuse to degrade an App frame whose fallbackText is empty; the byte
  cap eviction keeps the original resumable from the replay ring
- Blank replayed App html oldest-first when a restore page exceeds the
  32 MiB replay envelope so the session loads with fallback text instead
  of failing closed with transcript_page_too_large
- Degrade retained App html oldest-first in the compacted replay window
  before evicting segments, so an oversized App turn is neither kept
  above the window budget nor evicted by the next turn

* fix(cli): Show a notice for omitted HTML-only replay apps

* fix(mcp): Render third-party Apps with isolated origins and scoped tools

* fix(mcp): Align App route telemetry and regression fixtures

* fix(mcp): Preserve historical App fallback and document origin isolation

* fix(mcp): Harden App sandbox and preserve in-flight rendering

* fix(mcp): Preserve vendor origins over remote connections

* fix(web-shell): preserve MCP App tools in historical pages

* fix(mcp): repair App call recovery and approval capacity

* fix(mcp): repair pooled App connections in the calling session

* fix(mcp): keep App approvals responsive and repair guarded connections

* test(cli): update MCP App telemetry route audit

* fix(web-shell): drop the duplicate session id on the pane transcript

The main merge left sourceSessionId twice on TranscriptViewport, which fails the web-shell typecheck and stops every job at install.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): revoke failed apps and clarify resource timeout guidance

---------

Co-authored-by: yungsen.hys <yungsen.hys@alibaba-inc.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
2026-09-27 04:56:31 +00:00
Shaojin Wen
663d98eac5
feat(acp-bridge): deliver workspace changes to every paired engine (#12807)
On a paired Legacy/Managed Bridge, permission rules, the settings reload,
the Session Workflow gate, model providers and Skills now reach every live
engine. After workspace control handles the command, each live channel of
another engine gets a versioned qwen/control/workspace/change notification
and must acknowledge that exact revision; an engine that exits during
delivery counts as settled.

An engine that does not acknowledge is quarantined with the new reason
workspace_change_unacknowledged, through the paired quarantine: no fresh
sessions or new work, settled sessions close, and the channel retires by
the drain deadline. After a change that tightens permissions its running
turns are cancelled at once, its permission requests are refused, and Goal
turns the child reports later are cancelled. A late exact acknowledgement
of every missing change ends the quarantine early. Every quarantine now
also refuses shell commands and continuations, and checks a prompt again
after its attachments resolve.

The command fails as partially applied; the workspace service still
publishes saved permission rules and returns the Legacy reload result.
Propagation is skipped while a workspace stop is in progress, and
single-factory Bridges are unchanged.

Part of #12737 (slice B2b, part two).
2026-09-27 02:48:47 +00:00
Shaojin Wen
a480bc7815
feat(acp-bridge): give each paired channel its own workspace runtime identity (#12776)
Each channel now keeps the epoch allocated when its handshake completed,
so workspace status stamps, the lifecycle epoch, idle candidates and stop
snapshots describe their own channel. Starting a Managed channel no
longer restamps the Legacy runtime, and a Legacy exit makes its MCP and
Skills preparation stale even while Managed stays live.

A paired Bridge also reports the lifecycle of the Legacy channel that
serves workspace control. The workspace runtime coordinator and the
workspace service in all four daemon constructors use it instead of
aggregate liveness, so a live Managed channel cannot make a failed Legacy
preheat look ready. Single-channel Bridges report the same lifecycle as
before.

A workspace runtime stop now covers every live channel. The preview and
receipt list each channel with its own epoch, the top-level epoch is the
newest one so a later channel start stales the confirmation, and the stop
waits for every registry release. Channels that an incomplete stop left
running return to the idle policy.

Part of #12737 (slice B2b, part one).
2026-09-26 16:35:17 +00:00
易良
1060b9e269
fix(serve): stop validating load-only restore fields on resume (#12768)
POST /session/:id/resume parsed and rejected liveReplayMode and
compactedReplayMode even though resume never uses them and the
published OpenAPI resume schema does not declare them, so a resume
carrying an invalid load-only value failed with 400 against its own
contract.

The accepted body fields for load and resume now live in one
runtime-owned list that the restore handler follows (load-only fields
are parsed for load only) and the REST documentation contract test
verifies the OpenAPI schemas against, replacing the second hardcoded
array. The TypeScript SDK splits ResumeSessionRequest out of
RestoreSessionRequest so resumeSession no longer exposes the load-only
replay fields at the type level; the wire builder keeps dropping them
for untyped callers.

Closes #12146
2026-09-26 14:30:24 +00:00
Shaojin Wen
5df67177df
feat(web-shell): prepare updates and restart from sidebar (#12734)
* feat(web-shell): prepare updates and restart from sidebar

* test(serve): include daemon update in capability contracts

* test: complete update contracts and isolate shell output fixtures

* fix(web-shell): preserve task URLs and check update launchers

* fix(serve): support older Node declarations in update checks
2026-09-26 12:15:18 +00:00
jinye
879c311092
feat(serve): Enable private Hosted Harness no-tool text turns (#12713)
* feat(serve): Add private no-tool Hosted Harness turns

* fix(serve): repair hosted turn initialization and history

* codex: address PR review feedback (#12713)

* codex: address PR review feedback (#12713)

* codex: address PR review feedback (#12713)

---------

Co-authored-by: jinye.djy <jinye.djy@U-Y37CF7G3-1943.local>
Co-authored-by: Shaojin Wen <szujobs@gmail.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
2026-09-26 06:16:32 +00:00
callmeYe
be1a0a1fb0
feat(serve): support model and reasoning selection at session creation (#12308)
* feat(serve): support model and reasoning selection at session creation

* fix(serve): allow model-only session startup configuration

* test(serve): include startup capability in registry expectations

* fix(serve): roll back rejected standalone startup

* fix(serve): make definite startup rejections retryable and honest

- Map the ACP internalError wire shape ({code: -32603, data.details})
  produced when switchModel refuses an unregistered model to
  startup_config_rejected on the startup-config call path, so the
  ordinary route answers 422 instead of 500 and the standalone path
  takes its definite rollback branch.
- Roll back the persisted recording of a caller-supplied sessionId on
  POST /session after a definite startup rejection, so a retry of the
  same id is no longer answered with 409 session_id_conflict; uncertain
  failures keep the recording.
- Publish auto in the OpenAPI approvalMode enum and pin it against
  APPROVAL_MODES; narrow the 422/protocol/design cleanup wording to
  match the implemented best-effort semantics; restore the sessionId in
  the new rollback warning.

* fix(serve): keep uncertain startup faults uncertain and roll back ACP recordings (#12308)

- session-startup-config: drop the -32603 arm so child-side internal
  errors (auth, credential, timeout, transport) stay uncertain instead
  of a false definite startup_config_rejected; the ACP child now maps
  only its two caller-caused switchModel refusals (unknown model for the
  auth type, media-only primary) to invalid params, so definite
  rejections ride -32602 alone.
- dispatch: roll back the persisted recording of a caller-supplied
  sessionId on the ACP session/new transports after a definite startup
  rejection, mirroring the REST route; uncertain outcomes keep it.
- routes/session: warn once when the recording rollback is refused or
  fails instead of silently discarding the boolean outcome.
- standalone-session-service: extract the shared rollback sequence into
  one helper used by both the startup-config and model_selection_failed
  branches.
- openapi: qualify the 422 close/rollback as best-effort and replace the
  unconfirmable status-view pointer with the self-certifying retry check.

* fix(serve): name the rejected session in startup rollback and align ACP error codes (#12308)

- SessionStartupConfigError carries an optional sessionId so both
  rollback gates (REST route, ACP dispatcher) roll back the rejected
  session's recording even when the caller never supplied an id or the
  child did not honor it; a daemon-generated id no longer leaves a
  listed, resumable phantom after a definite 422.
- A refused ACP recording rollback now logs the same inconclusive
  warning the REST route emits instead of dropping the cleanup result.
- startup_config_rejected maps to JSON-RPC -32602 (caller input), with
  data.httpStatus keeping the REST-equivalent 422; SDK transports key
  on httpStatus, so client behavior is unchanged.
- SDK throws DaemonStartupConfigError (TypeError with code
  'invalid_startup_config') for pre-transport startup rejections.
- Docs: modelApplied is documented as spawn-scoped again (an attach
  omits it or reports the spawn owner's outcome).
- Tests: daemon-generated-id rollback pins on both transports, a
  refused-rollback log pin, a service-boundary invalid-config guard
  pin, full reasoning-selection acceptance coverage, whitespace trim
  pins, and a real-ModelsConfig classifier coupling test.

* test(sdk): keep the message assertion beside the stable code on startup rejections (#12308)

* fix(serve): fail closed on control-character startup model ids (#12308)

A modelServiceId carrying a newline slipped past parseSessionStartupConfig
and produced a core refusal message the single-line classifier regexes
could not match, so a definite caller-caused rejection degraded to an
internal error and skipped the recording rollback. Reject control
characters at the parse boundary and make isCallerCausedModelRefusal
newline-tolerant; align the SDK and integration-guide modelApplied
presence rule with the spawn-scoped wording the bridge and OpenAPI
already publish.

* docs(sdk): name both transport error-code locations in DaemonStartupConfigError JSDoc (#12308)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
2026-09-25 16:45:37 +00:00
易良
73aa65a4b4
chore(desktop): rename packages/desktop-shell to packages/desktop (#12653)
* chore(desktop): rename packages/desktop-shell to packages/desktop

The Electron-era packages/desktop was removed in #9085 and the OpenWork
downstream has forked, so the short name is free and the shell suffix is
the only thing left of the transition. Rename the package directory and
every load-bearing reference: workspace negations (npm + pnpm), the ci.yml
crate filter, desktop-release.yml, the isolation guard, lint/ignore configs,
scripts tests, CLI workspace-fixture tests, and living docs.

User-facing identity is untouched: the crate stays qwen-code-desktop,
productName stays "Qwen Code Desktop", the bundle identifier, updater
feed, release tag scheme, and the legacy electron-bridge asset names are
all unchanged. check-desktop-isolation now guards both path names so the
old one cannot re-enter a workspace set. Dated design docs under
docs/design/ keep their historical paths deliberately.

Refs #8596

* docs(design): follow the rename in the undated notification design notes

The web-shell-browser-notification-details pair is a living doc without a
date prefix, so it falls under the PR's update rule rather than the
historical-record exception.

* docs(desktop): stop citing paths the rename invalidated

Three prose sites and one CI comment still described the pre-rename world,
or contradicted themselves once `packages/desktop` named both the removed
Electron app and the living Tauri shell.

- ci.yml `desktop_shell`: the comment this branch added credited the
  Cargo.toml guard with keeping "branches from either side of the rename"
  green. It does not. A pre-rename head's files sit under
  `packages/desktop-shell`, which the changed-files filter no longer
  matches, so the job skips before the guard ever runs. Say that, and say
  what the guard does cover (a head the filter matched because ci.yml
  itself changed, with no crate at the new path).
- docs/design/web-shell-user-message-links.md: "desktop-shell routing" /
  "desktop-shell clicks" -> "desktop-host", the wording this branch already
  chose in LinkifiedText.tsx for the same rule.
- docs/design/telemetry-runtime-client-attribution-design.md: repoint the
  `runtime.rs` citation. It backs the live `QWEN_CODE_DESKTOP=1` claim, and
  the other document of record for that invariant -- the comment in
  acp-channel-fallback.ts -- was already repointed by this branch, so the
  two disagreed. Only the path token changes; the doc's dated findings are
  untouched.
- brand-create.mjs / SKILL.md / src-tauri/src/main.rs: name the historical
  referent as historical (the Electron `packages/desktop` removed in PR
  9085) instead of letting it read as a self-reference. In main.rs the
  citation `packages/desktop/packages/shared/src/config/storage.ts` now
  looked like a subpath of the file's own package. Rewritten, not deleted:
  the `QWEN_DEFAULT_WORKSPACE_DIR` clause next to it is the only in-code
  documentation of that override and still resolves.

Deliberately not swept: `scripts/check-desktop-isolation.js`'s
`packages/desktop-shell` tripwire, ci.yml's #8132 history comment,
`docs/design/9152-architecture-invariant-classification.md` (names both
spellings and is accurate), and the date-prefixed
`docs/design/2026-07-31-desktop-web-shell-release.md`.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmug1iw422v

* test(scripts): pin the desktop crate path and the isolation guard's coverage

Both invariants fail green today, and this rename is the second time the
crate directory has moved.

`scripts/check-desktop-isolation.js` gained `'packages/desktop'` in
`nativePrefixes` -- the only production-logic line in this branch -- and
nothing read it. Deleting that line leaves the guard's output and exit code
byte-identical, because `isNativeLocation('packages/desktop')` then matches
no remaining prefix; a later PR that drops `!packages/desktop` from the root
workspace list would re-admit the package with CI green.

The `desktop_shell` job names the crate directory in five places (the
changed-files filter, the Cargo.toml guard and its `::notice::` text, the
rust-cache `workspaces:`, and two `working-directory:` values). Nothing
asserted they agree with each other or with a directory that exists, so the
next drift reports a skipped job as a passed one.

The new suite parses both instead of re-running them, and is superset-shaped
on purpose: `nativePrefixes` carries a fourth entry (the
`packages/desktop-shell` tripwire) that nothing negates, and an exact-match
assertion would invite deleting it. The npm/pnpm mirror of the negation list
is already pinned by package-scripts.test.js, so this reads package.json only.

Verified by mutation, each applied alone and then restored: reverting the
filter alternative or either `working-directory` to `packages/desktop-shell`,
dropping `'packages/desktop'` from `nativePrefixes`, and dropping
`!packages/desktop` from package.json all turn this suite red. Today no other
test does.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmug1iw422v

* test(scripts): pin what the desktop-isolation tests actually claim

Four review findings on scripts/tests/desktop-isolation.test.js, each
verified by mutating the thing it claims to pin, before and after.

- The comment above the local `isNativeLocation` copy claimed a change to
  the script's rule "has to be made here too". Nothing reads the rule back
  from the producer -- `nativePrefixesFromSource` parses the array literal
  only -- and narrowing the script's rule to prefix equality leaves the
  suite green. Describe the copy instead of claiming coupling that does not
  exist.
- `still carries the Cargo.toml existence guard` matched only the guard's
  condition text. Flip the body's `changed=false` to `changed=true`, or move
  it after `fi`, and the suite stayed green at 5/5 while the job runs every
  gated step against a crate that is not there. Assert the assignment sits
  inside the guard's own `if ... fi` body.
- Both on-disk checks keyed on the bare directory, so git-ignored residue
  under `packages/desktop-shell` (`node_modules`, which survives a branch
  switch with an empty `git status`) reddened two tests on a tree with no
  defect and blamed the root workspace set. Key on `package.json`, which is
  what `packages/*` requires to make a directory a workspace member; a real
  re-entry still reddens both.
- The `startsWith(filterAlternative)` assertion compared a path built from
  `crateDir` -- `filterAlternative` with one trailing slash stripped -- back
  against `filterAlternative`, so it could not fail for any input. Dropping
  the trailing slash from the changed-files filter in ci.yml left it green.
  Replace it with the property that makes the alternative a path prefix
  rather than a bare string prefix.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmugedurp3j

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-09-25 05:41:25 +00:00
Juampi
f1361498ad
docs(tools): add Firecrawl to the web search MCP services (#12646)
Some checks are pending
Qwen Code CI / Classify PR (push) Waiting to run
Qwen Code CI / Test (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Lint & Static (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / web-shell E2E Smoke (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (macos-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (windows-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Integration Tests (no-AK, No Sandbox) (push) Blocked by required conditions
Qwen Code CI / Integration Tests (CLI, No Sandbox) (push) Blocked by required conditions
Qwen Code CI / Desktop Shell (ubuntu-22.04) (push) Blocked by required conditions
Qwen Code CI / Desktop Shell (windows-2022) (push) Blocked by required conditions
E2E Tests / Build for E2E (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 1/1 (push) Blocked by required conditions
E2E Tests / E2E Test (Linux) - sandbox:none - shard 1/1 (push) Blocked by required conditions
E2E Tests / E2E Test - macOS - shard 1/2 (push) Blocked by required conditions
E2E Tests / E2E Test - macOS - shard 2/2 (push) Blocked by required conditions
E2E Tests / E2E Interactive - OpenTUI renderer (bun) (push) Blocked by required conditions
E2E Tests / channel-plugin E2E (nightly) (push) Blocked by required conditions
E2E Tests / cron-interactive E2E (nightly) (push) Blocked by required conditions
E2E Tests / web-shell Browser Regression (push) Waiting to run
pnpm Worktree Smoke / Install (macos-latest) (push) Waiting to run
pnpm Worktree Smoke / Install (ubuntu-latest) (push) Waiting to run
pnpm Worktree Smoke / Install (windows-latest) (push) Waiting to run
SDK Java / ubuntu-latest / Java 11 (push) Waiting to run
SDK Java / ubuntu-latest / Java 17 (push) Waiting to run
SDK Java / macos-latest / Java 21 (push) Waiting to run
SDK Java / ubuntu-latest / Java 21 (push) Waiting to run
SDK Java / windows-latest / Java 21 (push) Waiting to run
SDK Java / Runtime Broker MariaDB / Java 21 (push) Waiting to run
SDK Java / Real daemon E2E / Java 11 (push) Waiting to run
Co-authored-by: Juampi <JuampiHernandez@users.noreply.github.com>
2026-09-24 16:00:24 +00:00
qqqys
688f56ed8f
fix(serve): report serve.channels restore failures in channel status (#12542)
* fix(serve): report serve.channels restore failures in channel status

A channels name that fails to restore never joins the committed selection,
so the workspace channel list reported it as stopped and daemon status did
not mention it; the failure lived only in the daemon log.

Record boot skips and late-restore failures per workspace and channel,
redacted the way the log renders them. The management list reports such a
channel as error with the recorded lastError, and daemon status raises one
channel_restore_failed warning per workspace. A record is dropped when the
channel commits, when an operator acts on it or on the whole selection, or
when its workspace leaves.

* fix(serve): pin the PUT clear and the committed-name filter; drop the commit-time clear

Every path that commits a name a restore left down is an operator action
that clears the record itself, so the clear on commit was a redundant layer
no test could pin.

* docs(serve): a restore failure clears on operator action or workspace removal

* fix(serve): retry a channel listed as error with nothing running by starting it

Web Shell offers Retry for an error runtime and calls restart, which refused
a name outside the committed selection with channel_worker_not_enabled. A
failed restore now lists that way, and so did a replacement rolled back
after its reload failed. Restart of such a name starts it instead.

* fix(serve): retire a restore record from current state, not from events

Review round 1 found five ways an event-driven record goes wrong: a trust
reconcile erased a record that was still true, a workspace removed while its
own restore was still on the lane recorded against a workspace that was
gone, a later commit of the same name by another workspace left a connected
channel reported as unrestored, and a DELETE that stopped nothing cleared
everything.

Replace the disposal-time clear and the record's clear-on-commit with one
read-time rule: a record is dropped when the channel is committed now or its
workspace is no longer registered. Reads prune, so nothing leaks. A DELETE
clears only when it stopped something, which is the same condition the line
above it already uses for channelHostingStoppedByOperator.

Also: record a boot worker startup the settings-derived branch swallows, so
the shape a single-workspace daemon fails in is reported rather than logged
only; key a restore's per-channel errors by channel, since the supervisor
stamps them with the owning workspace, not the requesting one; bound the
daemon-status message the way the sibling startup-failure surface bounds the
same data; carry restore warnings on the bootstrap status response, which is
the only one that exists when boot records are written; and drop the record
fields no production reader consumes.

* docs(serve): state the restore reporting the code makes, and name what stays log-only

* test(serve): a swallowed boot startup failure now lists as error, redacted

* test(serve): pin the later-commit retire and the cross-owner adapter error

* fix(serve): filter stale restore records on read, never delete them

The predicate reads live state that can dip: a workspace is briefly absent
from the registry while its runtime is replaced. Deleting on a dip would
destroy a failure that is still true, which is the defect this surface
exists to prevent.

* test(serve): pin the boot startup-failure fan-out to every claimant
2026-09-24 07:35:57 +00:00
qqqys
f8ce07463b
feat(serve): run Qwen Live through the Web Shell, native macOS Host opt-in (#12534)
* feat(serve): run Qwen Live through the Web Shell, native macOS Host opt-in

A macOS daemon advertised realtime_voice, registered /live/host,
downloaded and launched Qwen Live Host when Live was enabled, and let
the native Host supersede a browser endpoint. Keeping two endpoints
means two setup flows and two sets of failure modes while Live is
experimental, so every platform now uses the Web Shell endpoint
(realtime_voice_web, /live/web) by default.

The native Host is opt-in with QWEN_LIVE_NATIVE_HOST=1 in the daemon
environment (still macOS-only). Without it, /live/setup reports
nativeHost: false with a non-retryable install state and never probes
for an installed Host, and /live/setup/install and /live/setup/launch
answer 409 live_native_host_unavailable, so the Host cannot be
installed or started through the API either. The installer and Host
code stay in place.

Refs #12530

* fix(serve): name the native Live Host switch QWEN_SERVE_LIVE_NATIVE_HOST

Triage follow-ups on the web-only Live default:

- The switch is read by qwen serve only, so it takes the QWEN_SERVE_
  prefix the other serve gates use; QWEN_LIVE_* belongs to the
  standalone qwen-live daemon and live-host, which do not read it.
- The environment variable constant is no longer exported.
- A test pins that install/launch report the missing Host even while
  Live is off.
- The qwen serve guide says an already-installed Qwen Live Host cannot
  connect unless the switch is set.
2026-09-23 14:07:56 +00:00
qqqys
69622ba749
feat(channels): separate private and group access policies (#12547)
Some checks are pending
Qwen Code CI / Classify PR (push) Waiting to run
Qwen Code CI / Test (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Lint & Static (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / web-shell E2E Smoke (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (macos-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (windows-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Integration Tests (no-AK, No Sandbox) (push) Blocked by required conditions
Qwen Code CI / Integration Tests (CLI, No Sandbox) (push) Blocked by required conditions
Qwen Code CI / Desktop Shell (ubuntu-22.04) (push) Blocked by required conditions
Qwen Code CI / Desktop Shell (windows-2022) (push) Blocked by required conditions
E2E Tests / Build for E2E (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 1/1 (push) Blocked by required conditions
E2E Tests / E2E Test (Linux) - sandbox:none - shard 1/1 (push) Blocked by required conditions
E2E Tests / E2E Test - macOS - shard 1/2 (push) Blocked by required conditions
E2E Tests / E2E Test - macOS - shard 2/2 (push) Blocked by required conditions
E2E Tests / E2E Interactive - OpenTUI renderer (bun) (push) Blocked by required conditions
E2E Tests / channel-plugin E2E (nightly) (push) Blocked by required conditions
E2E Tests / cron-interactive E2E (nightly) (push) Blocked by required conditions
E2E Tests / web-shell Browser Regression (push) Waiting to run
SDK Java / ubuntu-latest / Java 11 (push) Waiting to run
SDK Java / ubuntu-latest / Java 17 (push) Waiting to run
SDK Java / macos-latest / Java 21 (push) Waiting to run
SDK Java / ubuntu-latest / Java 21 (push) Waiting to run
SDK Java / windows-latest / Java 21 (push) Waiting to run
SDK Java / Runtime Broker MariaDB / Java 21 (push) Waiting to run
SDK Java / Real daemon E2E / Java 11 (push) Waiting to run
Co-authored-by: qqqys <266654365+qqqys@users.noreply.github.com>
2026-09-23 12:44:09 +00:00
Lilian0122
43868d6a69
feat(web-shell): add shared URL navigation for pages and sessions (#12499)
* feat(web-shell): add shared URL navigation for pages and sessions

* test(web-shell): expect settings route after remote connection return

* fix(web-shell): preserve navigation state across workspace flows

---------

Co-authored-by: 铃蓝 <guolinliang.gll@alibaba-inc.com>
2026-09-23 09:43:09 +00:00
qqqys
126eda5327
fix(serve): never hold a workspace registration on the channel-control lane (#12527)
The runtime-added hook awaited `restoreWorkspace()` and `refreshWorkspaces()`
on the channel manager. Both run on the manager's single FIFO lane, and the
hook runs under the daemon-wide runtime-topology gate that every workspace
registration and trust reconcile shares. So a channel worker that never became
ready — a peer that accepts TCP and never completes the upgrade — held the lane
for the whole channel startup budget, and the next registration waited behind
it, holding the gate: a workspace configuring no channels at all took 29.7 s
to register instead of 9 ms, measured on a real daemon in #12396's review.
Since #12396 that is reachable from a daemon that booted with no channels.

The hook now queues both calls and returns; the lane already keeps them in
order. That alone would have reopened a race the awaits were hiding — they were
also what ordered a second registration's late restore behind the first's
commit — so the late restore's delta moves into the lane as well. A new manager
operation, `addChannels`, reads the committed selection inside the lane, skips
names already committed, leaves a committed `all` alone, and evaluates an
optional precondition there too, so an operator's recorded stop binds a
restore queued behind it. The late restore uses it instead of reading the
selection from outside and replacing it, which makes the local chain #12396
added unnecessary.

What changes for a caller: a `201` from `POST /workspaces` no longer means that
workspace's channels are up, or that an already-hosted channel it owns has
finished restarting.
2026-09-23 08:19:58 +00:00
qqqys
b9840886b8
feat(core): record when each tool call started (#12517)
Tool timing frames in paged transcript replay carried only a duration:
the scheduler logs a batch's tool calls in one loop after every call in
it has settled, so a record's event timestamp is the batch's end and no
honest per-call start could be derived from it.

The start was always measured, though; it just was not kept. Record it
as an optional `started_at_ms` on the tool_call event, from the
scheduler's terminal states (which now keep `startTime`) and from both
places the ACP session logs a tool call, and pass it through unchanged:
replay emits it as the tool frame's `startedAt`, the SDK timing reader
keeps it, and the trajectory projection puts it on the tool row.

Nothing derives it. Records written before this carry none and their
frames stay start-less, as before. The trajectory fixture's two tool
starts came from a pre-merge build that did derive them, and are
removed so the fixture matches what a released daemon sends.
2026-09-23 04:45:52 +00:00
qqqys
adb8f5bbd9
feat(serve): restore serve.channels once for a workspace registered after boot (#12396)
* feat(serve): let channel grouping take an ownership hint and skip tolerated names

`resolveChannelWorkspaceGroups` resolves every selected channel name to the
one workspace that owns it, and fails the whole selection when a name has no
owner, more than one owner, or an untrusted owner. Both behaviors block a
daemon from restoring several workspaces' `serve.channels` at boot: a channel
defined in user scope without a `cwd` matches every registered workspace, and
one workspace's stale entry would strand every other workspace's channels.

Add two optional inputs. `preferredOwners` names the workspace that asked for
a channel, and breaks an ambiguous tie in its favor — but only among owners
the ownership predicate already accepted, and the owner's trust check still
runs afterwards, so a hint can neither invent an owner nor bypass folder
trust. `tolerant` marks names whose failure must not fail the selection; those
are dropped and reported in `skipped` while every other name groups as usual.

Callers that pass neither input get the previous behavior and the previous
result shape: `skipped` is omitted when nothing was skipped.

* feat(serve): restore every trusted workspace's own serve.channels at boot

A flagless `qwen serve` boot read `serve.channels` from the primary workspace
alone, so a channel a non-primary workspace was configured to start came back
only if an operator called the runtime API again. The management API writes
that setting per workspace and reports `startsWithServe` from it, so the
toggle in a non-primary workspace promised an automatic start the daemon never
performed, and logged nothing when it did not.

Build the boot selection from every registered workspace instead: each
contributes the list in its own workspace-scope settings, which the fast path
already gates on folder trust. The names form one flat selection, as before,
plus the attribution the resolver needs. The workspace that listed a name owns
it when ownership is otherwise ambiguous, which is what a channel defined in
user scope without a `cwd` always is; that hint also travels to runtime
resolution, so a later `PUT /workspace/channel` resolves the way boot did. A
name a non-primary workspace contributed is dropped, with a log naming it,
when it cannot be resolved, and the committed selection is rewritten to what
the groups actually host so the lease and `/workspace/channel` agree. The
primary workspace's own names keep failing the restore as a whole, which is
what a single-workspace daemon does today.

`all` stays primary-only: one flat selection cannot express "every channel in
A plus two in B", so a primary `all` wins outright and an `all` anywhere else
is reported and ignored. Exceeding the channel-control workspace limit still
fails the restore loudly rather than hosting a silent subset.

* feat(serve): restore serve.channels for a workspace registered after boot

A workspace registered through the API after the daemon started kept its
channels offline: the runtime-added hook only un-drained a workspace that was
already part of the committed selection, and returned immediately when the
daemon had no channel worker manager at all. An operator who registered a
workspace whose channels are configured to start had to enable them by hand,
every time, even though the same settings now bring those channels up when the
workspace is present at boot.

Read that workspace's own `serve.channels` when it registers and enable the
names it asks for, seeding the same ownership attribution the boot restore
uses so a definition shared through user scope resolves to the workspace that
asked for it. A daemon that was hosting nothing loads the channel runtime and
takes the service lease at that point, which is the cost of honoring the
setting at all.

Two operator decisions outrank the setting, and neither could be inferred from
the committed selection: removing a workspace also drops its names, so a
selection diff would read "removed" as "stopped on purpose" and would refuse to
restore exactly the workspace re-registration this is meant to serve. Instead
the management service reports an explicit runtime intent when a channel is
started or stopped, and the daemon-wide `DELETE /workspace/channel` records
that hosting is off until something enables it again. Both live only in this
process: the next boot reads the settings from disk, which is what the setting
means.

* fix(serve): answer a registration before its channels, and keep every hint

Review of the late-registration restore found three ways it did not hold up,
and the sandboxed run measured a fourth.

Ownership hints lived in one map that `onCommittedSelection` rebuilt from the
committed groups on every commit. A workspace listing two channels that only
its own request can disambiguate therefore lost the second name's hint as soon
as the first name committed, and the second failed as ambiguous — silently,
because the registration had already answered. Hints now come from two
sources: what the settings asked for, which accumulates and is never rebuilt,
and what the daemon committed, which still tracks the groups. Resolution reads
both, so a name that is not committed yet still resolves. That also settles the
case where a channel started by hand after a daemon-wide stop could not be
resolved until the daemon restarted.

An explicit `--channel` selection bounded what the daemon hosted at boot but
not afterwards, so a workspace registered later could add channels the operator
never selected. It now bounds the daemon's whole life, which is what the
protocol documentation already said.

`startupChannelsForWorkspace` assumed every activation was non-primary, but
activation also fires for the primary workspace when its trust is
re-materialized — enough to print a banner claiming that workspace "was not
restored". The flag is passed through instead.

Finally, the restore ran inside the runtime-added hook, which holds the
daemon-wide runtime-topology gate. A channel endpoint that accepts TCP and
never completes the handshake made `POST /workspaces` take 30s, and — measured
in the sandboxed verification — held an unrelated registration of a workspace
with no channels for 29.9s behind the same gate, accumulating per workspace.
Bringing channels up is not part of registering a workspace, so it now runs
after the response, with the ownership seeding still synchronous because a
racing runtime resolution has to see it. One name that cannot be hosted no
longer strands the rest of that workspace's list either.

Every fix is covered by a test that fails without it, and the two re-enable
paths the mutation matrix found untested — `PUT /workspace/channel` and a
per-channel start — now each have one.

* feat(serve): restore serve.channels for a workspace registered after boot

A workspace registered through the API after the daemon started kept its
channels offline: the runtime-added hook only un-drained a workspace that was
already part of the committed selection, and returned immediately when the
daemon had no channel worker manager at all. An operator who registered a
workspace whose channels are configured to start had to enable them by hand,
every time, even though the same settings now bring those channels up when the
workspace is present at boot.

Read that workspace's own `serve.channels` when it registers and enable the
names it asks for, recording what it asked for in the boot hint layer so every
name keeps its ownership hint through the commits the names before it cause. A
daemon that was hosting nothing loads the channel runtime and takes the service
lease at that point, which is the cost of honoring the setting at all.

Bringing those channels up is not part of registering a workspace. The hook
runs under the daemon-wide runtime-topology gate, so awaiting a worker there
holds that gate for as long as the worker takes to become ready — up to the 30s
startup budget — and every other registration and trust reconcile queues behind
it, including workspaces that configure no channels at all. The restore now
runs after the registration answers, with the ownership record still written
synchronously because a racing runtime resolution has to see it, and one name
that cannot be hosted no longer strands the rest of that workspace's list.

An explicit `--channel` selection bounds what the daemon hosts for its whole
life, so a workspace registered later adds nothing to it, which is what the
protocol documentation already said. Activation also fires for the primary
workspace when its trust is re-materialized, so the primary flag is passed
through rather than assumed; a primary `all` is no longer reported as a
workspace that "was not restored".

Two operator decisions outrank the setting, and neither could be inferred from
the committed selection: removing a workspace also drops its names, so a
selection diff would read "removed" as "stopped on purpose" and would refuse to
restore exactly the workspace re-registration this is meant to serve. Instead
the management service reports an explicit runtime intent when a channel is
started or stopped, and the daemon-wide `DELETE /workspace/channel` records
that hosting is off until something enables it again. Both live only in this
process: the next boot reads the settings from disk, which is what the setting
means.

* fix(serve): never let a later registration claim a channel someone else hosts

The late-registration restore recorded an ownership hint for every name the
arriving workspace listed, overwriting whatever the hint already said, and
wrote it on top of the layer the committed groups own instead of under it.
Both halves are wrong, and together they move a running channel.

`startupChannelsForWorkspace` resolves one workspace at a time, so every name
that workspace lists comes back hinted — the multiple-claim case the resolver
detects at boot cannot arise inside a single-workspace resolution. A channel
defined once in user scope with no `cwd` is owned by every registered
workspace, which is exactly what the per-workspace startup toggle produces, so
a second workspace listing that name would rewrite the hint to itself. The
reload this hook triggers immediately afterwards re-resolves the committed
selection with the new hint, and reconcile then stops the worker where it is
running and starts it under the newcomer. Registering an unrelated workspace
was enough to take another workspace's channel.

A second claim now withdraws the hint instead of overwriting it, which is what
`resolveChannelWorkspaceGroups` documents as the caller's obligation: one map
holds one owner per name, so the only honest answer to two claimants is no
hint, leaving the channel config and the groups the daemon already committed to
decide. That withdrawal is logged. Hint rebuilding also moves into one helper
both writers share, so the committed groups always layer over the boot hints
rather than the other way round.

* refactor(serve): narrow the late-registration restore to one mechanism

The review of the previous revision found twelve critical defects, and almost
all of them were consequences of machinery this PR had added rather than of the
feature itself: an ownership hint written and withdrawn at runtime, two
operator-intent markers with different scopes than the actions that set them,
and a hand-rolled detached task that bypassed the drain handling the runtime
route already has. Patching each one had produced the next, so this replaces
the machinery instead of extending it.

A workspace registered after boot now restores its own `serve.channels` exactly
once per daemon run, and that single rule replaces both markers. Activation
fires again when a workspace is removed and registered back, and when its trust
is re-materialized; restoring there is what would undo an operator who stopped
one of those channels in between, which is the case #11452 asks this not to do.
Recording the workspace rather than each channel's runtime state removes the
`onRuntimeIntent` hook, the daemon-wide stop marker whose scope did not match
the per-workspace action that cleared it, and the marker that was cleared
before the selection it described had committed.

The restore no longer writes ownership hints. Resolving one workspace at a time
made every name it listed look unambiguously its own, which is precisely the
multiple-claim case `resolveChannelWorkspaceGroups` documents as the one a
caller must not hint; withdrawing the hint afterwards then broke the protocol's
promise that boot attribution lasts as long as the daemon runs. Without hints a
name this daemon cannot attribute simply is not restored late, with the error
logged, and the sentence about durable boot attribution stays true.

The names join the committed selection through `setChannelWorkerSelection`, the
same funnel the runtime route uses, in one change rather than one per name.
That inherits the draining checks — a restore in flight when the daemon starts
closing now refuses instead of starting a worker behind the shutdown — and
removes k-1 selection commits, each of which re-resolved every group. A
committed `all` selection is left alone, because one flat selection cannot
express "everything the primary workspace has, plus these two".

Settings are read from disk at activation rather than from the boot snapshot,
and the primary workspace is left to the boot path, so a trust
re-materialization no longer reports it as a workspace that was not restored.

* fix(serve): count a registration whose names were all hosted as its one restore

The once-per-daemon record was written only on the branch that proposed a
restore. A workspace whose every listed name was already hosted returned at the
empty `pending` check before being recorded, so it could come back later — after
an operator stopped one of those names — and bring the stopped name up again,
which is the exact thing the record exists to prevent.

Record the workspace as soon as it has asked for anything, before the hosted
names are filtered out. A workspace with no `serve.channels` still returns
before the record, so one that gains a list later still restores when it
registers again.

The fix and its regression test are from the maintainer verification on the PR,
which reproduced the revival on a real daemon; this test is also the one that
pins the record itself, since removing it no longer turns the single-channel
once-per-daemon case red once the stopped-hosting guard fires first.

Also render `requestedByWorkspace` in the boot restore log as `name@workspace`
pairs: the daemon log formats every field with String(), so the map I passed in
#12385 printed as "[object Object]".

* fix(serve): record an operator's stop explicitly and run late restores one at a time

Two defects the last revision introduced, both measured on a real daemon by
the round-3 review.

The late restore inferred "an operator stopped channel hosting" from a channel
manager that exists and reports `enabled: false`. That state is not specific to
a stop. Listing channels — a read, which the Web Shell does on its channel page
— creates the manager, and a manager that has never hosted anything reports
exactly the same `enabled: false, selection: null` as a stopped one; so does a
manager whose own late restore just failed. On the daemon this feature exists
for, one status read switched the restore off for the rest of its life, and
the log blamed a stop nobody performed. The stop is now recorded where it
happens: `DELETE /workspace/channel` sets it only when it actually stopped
something, and it is cleared only after a `PUT /workspace/channel` commits, so a
selection that fails leaves hosting as stopped as it was.

Each late restore replaced the whole committed selection with the one it read
at hook time. On a daemon that has just booted with no channels, no manager
exists yet to order two registrations, so the second read the selection before
the first committed and its commit dropped the first workspace's channels. Late
restores now run one at a time, each reading the committed selection when its
turn comes.

The failure log for a late restore now redacts credentials the way the boot
restore's does: the message can carry an adapter's own error text.

The documentation is corrected where it had overstated things: registering a
workspace with `serve.channels` is a third way to load the channel runtime and
reserve the lease; the restore detaches from the registration, but the hook's
existing bookkeeping on the channel-control lane can still delay other
registrations for up to the startup budget; and unlike boot, one unhostable
name costs a late workspace its whole list. Both design documents state the
same rule again.

* fix(serve): name the workspace in a late restore's failure record

A failed late restore was the one record in its block without a workspace, so
when two workspaces registered close together the error could only be
attributed by its position in the log — and two workspaces listing the same
name produce identical error text. Pass the workspace as the same structured
field its sibling records carry.
2026-09-23 04:42:06 +00:00
jinye
5feb7a4f34
feat(serve): Add opt-in fixed ACP child heap enforcement (#12353)
* feat(serve): Add opt-in fixed ACP child heap enforcement

Apply the boot-resolved fixed partition to managed ACP children while reusing shared count admission and idle reclamation. Preserve legacy modes, validate inherited heap options, and report enforcement only for managed wiring.

Refs #8182

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(serve): Reuse resolved ACP child environment

Keep the standalone process-environment fallback at a single guarded access while using the same environment for heap validation and child spawning.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(serve): Align heap enforcement guidance

Pin NODE_OPTIONS rewriting to Node itself, correct stale CLI/status wording, and document the existing watcher-gated heap telemetry used for rollout calibration.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-09-22 10:14:05 +00:00
samuelhsin
ec109102e0
feat(daemon): Add batched workspace session catalogs (#12254)
* feat(daemon): Add batched workspace session catalogs

* test(serve): include batch catalog in capability expectations

* fix(daemon): address batch catalog review feedback

* fix(daemon): retain catalog reads across policy revisions

* fix(daemon): bind session cursors to catalog read modes

---------

Co-authored-by: yungsen.hys <yungsen.hys@alibaba-inc.com>
2026-09-21 15:26:34 +00:00
qqqys
e1213d57d9
feat(serve): restore every trusted workspace's own serve.channels at boot (#12385)
* feat(serve): let channel grouping take an ownership hint and skip tolerated names

`resolveChannelWorkspaceGroups` resolves every selected channel name to the
one workspace that owns it, and fails the whole selection when a name has no
owner, more than one owner, or an untrusted owner. Both behaviors block a
daemon from restoring several workspaces' `serve.channels` at boot: a channel
defined in user scope without a `cwd` matches every registered workspace, and
one workspace's stale entry would strand every other workspace's channels.

Add two optional inputs. `preferredOwners` names the workspace that asked for
a channel, and breaks an ambiguous tie in its favor — but only among owners
the ownership predicate already accepted, and the owner's trust check still
runs afterwards, so a hint can neither invent an owner nor bypass folder
trust. `tolerant` marks names whose failure must not fail the selection; those
are dropped and reported in `skipped` while every other name groups as usual.

Callers that pass neither input get the previous behavior and the previous
result shape: `skipped` is omitted when nothing was skipped.

* feat(serve): restore every trusted workspace's own serve.channels at boot

A flagless `qwen serve` boot read `serve.channels` from the primary workspace
alone, so a channel a non-primary workspace was configured to start came back
only if an operator called the runtime API again. The management API writes
that setting per workspace and reports `startsWithServe` from it, so the
toggle in a non-primary workspace promised an automatic start the daemon never
performed, and logged nothing when it did not.

Build the boot selection from every registered workspace instead: each
contributes the list in its own workspace-scope settings, which the fast path
already gates on folder trust. The names form one flat selection, as before,
plus the attribution the resolver needs. The workspace that listed a name owns
it when ownership is otherwise ambiguous, which is what a channel defined in
user scope without a `cwd` always is; that hint also travels to runtime
resolution, so a later `PUT /workspace/channel` resolves the way boot did. A
name a non-primary workspace contributed is dropped, with a log naming it,
when it cannot be resolved, and the committed selection is rewritten to what
the groups actually host so the lease and `/workspace/channel` agree. The
primary workspace's own names keep failing the restore as a whole, which is
what a single-workspace daemon does today.

`all` stays primary-only: one flat selection cannot express "every channel in
A plus two in B", so a primary `all` wins outright and an `all` anywhere else
is reported and ignored. Exceeding the channel-control workspace limit still
fails the restore loudly rather than hosting a silent subset.

* fix(serve): keep boot channel owner hints across runtime commits

Three review follow-ups to the multi-workspace startup restore.

`onCommittedSelection` rebuilt the owner hints from the committed groups
alone, so a name a commit did not carry lost its boot-time attribution for
good: stopping a user-scope channel and enabling it again resolved against
every registered workspace and failed as `ambiguous_channel_workspace` until
the daemon restarted, which is what that hint exists to prevent. Keep the boot
hints as a layer the committed groups override. A hint naming a workspace that
is no longer registered is inert, since resolution only takes a hint matching
one of the live owners.

Tolerance leaked to names the primary workspace listed: `claims.some(...)`
made a name droppable as soon as any other workspace listed it too, so a
second workspace copying `serve.channels` silently downgraded the primary's
fail-fast restore to a logged skip. Key it on the absence of a primary claim,
which is what the protocol doc already describes.

The `claimed_by_multiple_workspaces` diagnostic wrote a boot banner for a
healthy configuration — one channel, an explicit `cwd`, listed by two
workspaces, which is exactly what the per-workspace toggle produces. It stays
in the daemon log and out of stderr; a resolution that actually fails still
reports itself.

The remaining gap — no hint at all when the primary, or two workspaces, claim
a user-scope name — is an ownership policy decision rather than a fix, and is
tracked in #12402.
2026-09-21 10:59:51 +00:00
易良
3dc9778dea
perf(cli): optimize one-shot headless execution (#12340)
* perf(cli): optimize one-shot headless execution

* fix(cli): preserve unset shell schema default

* fix(cli): fall back when process replacement fails

* fix(cli): keep relaunch supervisor for headless slash-command prompts

Process replacement leaves no supervisor to consume exit code 43, so a
headless `/update` would exit having installed nothing. Keep the parent
process when the one-shot prompt dispatches as a slash command, and pin
both directions in llm.test.tsx.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(cli): update settings dialog snapshots for mode-based PTY default

With no fixed schema default the dialog now renders "(not set)" for
Interactive Shell (PTY) instead of true; refresh the ten snapshots.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* docs(cli): document process-replacement supervisor and reset semantics

Explain at the execve call why afterSpawn has nothing to protect there,
and record in the design doc that onUpdateRelaunch intentionally never
fires on the replacement path and that "reset to default" now clears the
interactive-shell key instead of writing true.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* Revert "fix(cli): keep relaunch supervisor for headless slash-command prompts"

The guarded path is unreachable: both relaunchForUpdate() call sites sit
inside `if (context.executionMode === 'interactive' && projectRoot)`
(update-command.ts:89), and a one-shot prompt resolves executionMode to
'non_interactive' (config.ts:1837-1839 -> nonInteractiveCliCommands.ts:431),
so a headless `/update` updates standalone installs in-process or prints
manual instructions and never exits 43. Per review correction, drop the
guard and its test rather than pin a non-bug.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(cli): pin stream-json shell default; document schema default contract

Add the missing --input-format stream-json leg to the shell-default
matrix, and note at the SettingDefinition declaration that `default` is
required for type inference even when its runtime value is undefined.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(cli): pin the full replaceProcess predicate for one-shot launches

The verify lane's mutation run left M7 (jsonFd term) surviving and the broader predicate shape unpinned after the slash-command guard was reverted. Assert the replaceProcess value relaunchAppInChildProcess receives across the launch modes: plain and slash-command one-shot prompts replace the process; ACP, -i, file, json-fd, stream-json, and dual-output file inputs keep the supervisor. Mutation-checked: deleting the jsonFd term fails the json-fd row, forcing the expression true fails all six supervisor rows.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmuakc4l1ae

* style(cli): prettier-format the replaceProcess predicate test

The Lint & Static lane's Run Prettier step failed on the test file
pushed in 22dd3240aa. Format-only change, no semantics.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmuakc4l1ae

* fix(cli): surface execve fallback reason and pin no-prompt predicate

* refactor(cli): name the one-shot prompt predicate and drop redundant pins

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-09-21 10:51:00 +00:00
qqqys
065dd351c8
fix(serve): tell clients which history entries cannot be retried (#12349)
* fix(serve): tell clients which history entries cannot be retried

A retry or rerun from history is refused when the run's history does not
have the args to start it with -- they were too large to keep, or the
snapshot predates keeping them and so cannot say whether there were any.
Only the first of those reached the wire, as `argsOmitted`, so a client
rendered a Retry for the second that the call always refused.

Both now project as `argsUnavailable` on the task, from the same
predicate the daemon refuses on, so the two cannot drift apart.
`argsOmitted` stays beside it as the reason a client can name. The args
themselves still never leave the daemon.

* fix(serve): keep argsUnavailable's literal type through the projection

A plain `true` in the ternary widened to `boolean`, which the wire type
does not accept, and the consistency test handed the predicate a fixture
typed loosely enough that its argument did not match either. Both were
type errors only; the behaviour and every assertion are unchanged.

* fix(cli): ask for a resumed run's args when its history cannot name them

The interrupted-run notice hands the user a call to paste, and nothing
on that path refuses it the way the daemon refuses a history retry. It
asked for the original args only when they had been too large to keep,
so a run recorded before args were kept got a call that looks complete
and would resume with none -- replaying nothing and re-dispatching every
agent under the old run id.

It now reads the same predicate the refusal and the projection read, so
all three agree about one snapshot. The protocol's table row says both
cases too, rather than only the one it happened to be written for.

* fix(workflows): claim a run that had no args as one that had none

`argsRecorded` arrived with the wire marker, so a checkpoint written by
v0.24.1 does not carry it. Claiming such a run produced a snapshot with
no `args`, no `argsOmitted` and no `argsRecorded` -- which reads as
"nobody knows whether this run had args". The run then lost its retry,
was marked `argsUnavailable` on the wire, and its resume notice asked
the user for args it never had.

Every checkpoint format has recorded the run's args, so a checkpoint
carrying neither them nor `argsOmitted` is a run that had none. Say so
when claiming it.

Two more from the same review:

The panel withholds Retry and Rerun for a run whose args its history
cannot supply, which also took away the only place the daemon's reason
appeared -- it arrived by pressing Retry and being refused. It now says
why, without the failed round trip.

The consistency test asserted the predicate the handler itself calls
while claiming to assert the wire. Its name now says what it checks,
and the projection's own suite pins the other end against the same
exported predicate rather than restating it. The `invalidParams` stub
is installed only in the branch that consumes it.

* style(docs): re-pad the workflow refusal table after the merge
2026-09-21 09:30:09 +00:00