Commit Graph
484 Commits
Author SHA1 Message Date
liuxuely0626andluoyun 444ff228ee fix(subscription): restore model switching and align session recovery (#16738)
* fix(cli): restore model switching after subscription logout

* fix(cli): mention /effort after subscription sign-in

* docs(serve): guide new setups through subscription sign-in

* docs(users): remove obsolete Qwen OAuth troubleshooting guidance
- Remove the error message about the Qwen OAuth free tier ending on April 15, 2026.
- Remove the explanation of the error and the steps to resolve it.
- Remove related guidance on using API keys and Alibaba Cloud Coding Plan as authentication alternatives.
- Remove SSL certificate error messages and troubleshooting steps.

* fix(auth): align subscription settings scope with API key setup

* fix(subscription): use gateway model order at sign-in

Send the running Qwen Code version in model catalog requests so the gateway can identify client versions. Select the first returned model on explicit sign-in.

* feat(subscription): use gateway model metadata

* fix(subscription): restore startup models and sync desktop defaults

* fix(subscription): align workspace startup and draft model handling

* fix(subscription): preserve gateway model order in pickers

* fix(subscription): align draft startup and history recovery

* test(cli): update subscription success rendering expectations

---------

Co-authored-by: luoyun <[email protected]>
2026-11-09 20:07:34 +08:00
Shaojin WenandQwen-Coder fa40879b4a feat(channels): protect the GitHub mention lane from watch budget spend (#16701)
* feat(channels): protect the GitHub mention lane from watch budget spend

The PR state watch runs before the notification lane in every poll and is
best-effort, so when it spends the REST budget down to a rate-limit answer
the interactive mention handling in the same poll is what fails. The
channel was also budget-blind on the success path: x-ratelimit-remaining
was read only after a request had already been rejected.

A single request hook now records the remaining REST budget and its reset
time from every successful response, regardless of the call's return
shape (it sees each paginate page too). When the last known remaining is
at or below a configurable reserve (prWatchBudgetReserve, default 200, 0
disables), the watch pass yields for that poll so a human mention keeps
the budget; a stale reading whose window has reset does not gate. The
skip and the recovery are journalled as edge-triggered degraded rows, and
the reactive fail-fast backstop is unchanged.

The reserve is wired through the descriptor, validation, the Web Shell
editor (Bound Sessions panel, EN/zh labels), and the timeline labels the
new recovery reason as recovered rather than degraded. A reserve of 0
short-circuits the gate outright so an exhausted budget (remaining 0)
cannot skip a watch the operator opted out of protecting.

Co-authored-by: Qwen-Coder <[email protected]>

* fix(channels): release the watch budget latch when the watch loses eligibility

An adversarial audit pass reproduced a suppressed starvation report. The
low-budget latch was only cleared inside the branch that runs when the
watch is eligible, so a binding refresh that started failing while a skip
was latched left the flag set: the budget could recover with no recovery
row, and — worse — the next genuine starvation after the watch returned
was swallowed as a repeat of an episode that had already ended. The
timeline's last budget word then named a skip no longer in effect, which
contradicts the documented promise that both the skip and the recovery
are recorded.

Release the latch when the watch block is skipped wholesale, mirroring the
sibling watch-guard cause. Claim recovery on that path only when the
budget really recovered: the watch being ineligible says nothing about
the budget, so a still-low budget releases silently and the next eligible
low poll journals a fresh skip.

Two cases pin it: recovery across an ineligible window, and a second
starvation after that window still journalling. Disabling the release
reds both; making the recovery claim unconditional reds the second.

The design docs also named a watchBudgetCause field that the
implementation never had; corrected to the watchBudgetLow latch in both
languages.

Co-authored-by: Qwen-Coder <[email protected]>

* fix(channels): close the review round on watch budget awareness

Seven review findings plus three converged suggestions, all addressed here.

The reserve was only checked at the poll boundary, so a pass entered just
above it could spend the whole reserve before the notification lane ran —
the starvation the reserve exists to prevent, and it widened as prWatchLimit
rose. The pass now re-reads the budget before each watched PR and breaks,
journaling the same low edge through a shared helper; break rather than
return so the baseline observed so far still reaches savePrWatchState.

A low-budget episode could still end with no recovery row: the skip latch
was released during an ineligible window, so a budget that recovered while
the watch stayed ineligible never journalled its closing edge. Episode
tracking is now separate from the skip latch — the latch still releases so
a later starvation journals a fresh skip, while the episode stays open
until the BUDGET recovers. Both flags are in-memory, so the guide now says
the recovery is recorded while the channel stays connected instead of
promising it unconditionally.

"Skipping only delays detection" overpromised: watchOnePr compares against
the stored baseline, so a transition that appears and reverts between two
passes is never observed, and a closed gate widens that window. Narrowed in
the user guide, both design docs and the code comment.

The timeline labelled the recovery row but rendered none of the low row's
payload, so 199/200 and 12/200 looked identical. It now renders both numbers
in EN and ZH.

Coverage gaps closed: a mention delivered in the very poll whose watch was
skipped (the feature's central promise, previously untested); the recovery
row asserted payload-free on both emission sites; an episode closed by
window reset rather than a climbing count; and the hook driven through a
REAL multi-page paginate, which is the per-page coverage the design claims.

Also: reject a negative reserve at the validation boundary instead of
letting the getter silently default it; hoist RECOVERY_REASONS to module
scope; drop the new disabledHint keys, which no editor branch reads.

Co-authored-by: Qwen-Coder <[email protected]>

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-11-09 15:01:19 +08:00
25e69c0f69 feat(daemon): expose last user prompt time and session sorting (#14654)
* feat(daemon): expose last user prompt time in session summaries

* fix(daemon): keep last user prompt metadata hot

* feat(daemon): support last-question session sorting

* test(ci): align session sort capabilities and admission waits

* test(managed-agent): use the managed store in row lock test

* fix(daemon): isolate prompt-time reads and gate sorting on presence

An unreadable transcript must not fail a whole session list or the status
route: the reader deliberately throws when a snapshot is replaced mid-read,
so degrade the single row instead, keeping abort semantics intact.

The SDK forwarded any defined sortBy while only pre-flighting the non-default
value, so `sortBy: 'updatedAt'` reached daemons whose query allowlist and
strict catalog schema reject the parameter itself. Gate on presence.

Also parse the cursor before enriching, so a cross-sort cursor is rejected
without scanning every transcript, and drop the pagination cursor when a
sort change discards the loaded pages.

* fix(sessions): keep question sort consistent across hosts and pages (#14654)

Fan-out host groups now read their own stored sort and only ask for
question ordering when that host advertises it. Organized pagination
keeps an unknown-time live-only row reachable on later pages, and the
sort control falls back to the projects header when the brand row is
hidden.

Add witnesses for the warm-cache ownership gate, the numeric-timestamp
case, and the bridge notification/registration wiring.

* fix(sessions): pin question-order paging and drop unrelated Java churn (#14654)

The metadata page cursor now has a witness that ties its boundary key to
sortBy instead of activity time. Restore two runtime-broker files to the
merge-base content so the PR no longer carries unrelated line wrapping.

* test(core): pin that the numeric-timestamp case really parses

The cache reset added for this case is itself unobserved: removing it leaves
the whole reader suite green, because the assertion is then satisfied by the
previously cached undefined snapshot. Assert the index was rebuilt once, so
dropping the reset turns the case red instead of silently making it vacuous.

* fix(sdk-typescript): keep sortBy session lists on native REST (#14654)

The ACP session/list mapping drops sortBy, so a client with an ACP
transport received updatedAt order after the capability pre-flight
succeeded. Force native REST whenever sortBy is requested, mirroring
liveOnly, and pin the routing with a DaemonClient test.

Also cover archived question-time sorting in the CLI session-list suite,
where the archived transcript is the source of truth.

Co-authored-by: Qwen-Coder <[email protected]>

---------

Co-authored-by: yungsen.hys <[email protected]>
Co-authored-by: Shaojin Wen <[email protected]>
Co-authored-by: qwen-code-dev-bot <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
2026-11-08 14:29:35 +00:00
Shaojin Wen c503584221 docs(serve): state the worktree lock key rule and the restore's lock-mismatch 500 (#16601)
* docs(serve): state the worktree lock key rule and the restore's lock-mismatch 500

The ownership lock key is derived by the restore, the reset route and
the delete-path orphan cleanup, and the restore also compares it with
the checkout path its validation resolves. The rule they must share
(JS fs.realpathSync of the recorded worktree path; the restore and the
cleanup fall back to the raw path, the reset refuses an unresolvable
one) is now stated with acquireWorktreeOwnershipOp, and each site points
to it.

The protocol reference and the session lifecycle page now say what the
design document says: a later Part 4A sidecar read that names a
checkout the restore did not lock fails closed with a 500 that carries
no code, instead of a typed restore 409 or an attestation, and a retry
classifies under the lock once its reads agree. The design document's
superseded-restore bullet gains the same exception.

* docs(design): add the Chinese version of Channel Named Sessions Part 4B

docs/design/README.md requires both language versions of a design, and
adding the missing one whenever a single-language design is updated.
Part 4B had only the English version and this branch updates it, so add
the complete Simplified Chinese translation of the current English and
link the two versions below their titles.

Translating it showed that the English "Sidecar schema addition"
paragraph called `supersededBy` the forward link and `supersedes` the
reverse link, the opposite of every other section; it now names the
fields instead.

* docs(serve): list the restore's worktree 500 under load/resume errors

The load/resume Errors list named no 500 for worktree validation, so a
client enumerating the branches it must handle would not find it. Add a
bullet saying what happens to a session restored before the failure,
that these bodies carry no daemon-defined code, and which causes a retry
resolves. The Part 4A paragraph points to it.

* docs(design): list every reader of the worktree supersede links

The Part 4B "Sidecar schema addition" paragraph said only the restore
and reset paths read `supersededBy`/`supersedes`. The shared
worktree-context restore, the exit_worktree tool's stale-marker check
and the delete-path orphan cleanup read `supersededBy` too, and the
guards they back keep a resumed superseded session and the cleanup off
the replacement's checkout. Name them in both language versions.

The #11024 requirements list read as binding rules, but the cleanup
built from it departs from them: only `supersededBy` refuses, because
refusing on `supersedes` too left every post-reset worktree
uncleanable. Its intro now says the list is the review's inputs and
points to channel-worktree-orphan-reap.md for the rules as built.

* test(serve): pin the restore lock-mismatch 500 body and detach branch

The protocol pages say the lock-mismatch 500 carries no `code` and that
an already-live session is detached rather than closed. Assert the
missing `code` in each lock-mismatch case, and drive a lock mismatch on
an attached restore: the request's client is detached, the session is
not killed and the sidecar stays.
2026-11-08 11:01:42 +00:00
e12a6bc2d1 feat(web-shell): add workspace collapse controls and drag sorting (#16197)
* feat(web-shell): add workspace sidebar controls and shared ordering

* fix(web-shell): address CI baseline and repeated expansion reads

* docs(serve): index workspace-order REST endpoints

* fix(web-shell): address workspace sidebar review findings (#16197)

Co-authored-by: Qwen-Coder <[email protected]>

---------

Co-authored-by: 钉萁 <[email protected]>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Qwen-Coder <[email protected]>
2026-11-08 18:38:34 +08:00
Shaojin Wen b577cbdbbe fix(channels): journal GitHub mentions added by editing an older comment (#16588)
GitHub notifies the bot when an edit adds its @mention to an older
comment, but the comment lanes read only comments created after the poll
cursor, so the notification was marked read with no trace.

The bot still does not answer such an edit. The channel now journals an
inbound/skipped event with the reason edited_comment, once per window,
for a listed comment created at or before the cursor and updated after
it that mentions the bot, is not the bot's own, and is not already in
the channel's dedupe record. Dispatch, the cursor, the dedupe state and
mark-read are unchanged.

Document it in the GitHub channel guide, including that an edited issue
or PR body is answered only on first contact, and list the new reason in
the channels overview, the serve protocol reference and the channel
events timeline design (English and Chinese).
2026-11-08 13:57:23 +08:00
7fd185883f docs(tools): add You.com free MCP to the web search MCP services (#14000)
Adds You.com to "Supported MCP Web Search Services" in
docs/developers/tools/web-search.md, after the SerpApi entry, in the same
format as Firecrawl/SerpApi: description, Documentation/Cost/Get API Key/Best
for bullets, Available Tools, and setup (CLI, settings.json without a key,
settings.json with a key). One new section; nothing else on the page changes.

Links use bare URLs (no UTM parameters) to match every other entry on the
page. Rebased onto current main so it applies cleanly after the SerpApi entry.

Fixes #13987.

Co-authored-by: valueadd-lgtm <[email protected]>
Co-authored-by: 易良 <[email protected]>
Co-authored-by: Brian Sparker <[email protected]>
2026-11-08 10:50:46 +08:00
ZedingZhang edaa4e3826 feat(agents): make default background execution configurable (#15178)
* feat(agents): add configurable default background execution

Signed-off-by: ZedingZhang <[email protected]>

* fix(agents): clarify configurable background guidance

* docs(agents): align execution guidance with configurable defaults

---------

Signed-off-by: ZedingZhang <[email protected]>
2026-11-08 02:45:59 +00:00
1150e89c37 docs(daemon): document session-scoped permission vote for channel auto-approve (#16541)
The permission auto-approve sequence diagram now shows
session.respondToSessionPermission for daemons advertising the
session_permission_vote capability, and keeps the legacy
session.respondToPermission branch for hosts that do not advertise it.

Part of #10849 (finding rv:5090771085, deferred from PR #10793).


Patrol-Run: qwen-issue-patrol/jmw6cw7jo0v

Co-authored-by: yiliang114 <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
2026-11-08 01:41:59 +08:00
Shaojin WenandQwen-Coder 5b44cfad71 fix(web-shell): include channel sessions in release dialog (#16537)
* fix(web-shell): include channel sessions in release dialog

The release dialog inherited the sidebar's default source filter, which
excludes sourceType: "channel". A workspace filled by idle channel
sessions therefore opened an empty recovery list.

Query the workspace catalog without a source filter for this dialog and
keep the default, channel, and qwen-live rows. The sidebar and every
other useScopedSessions caller keep the default filter.

Add hook/dialog regressions and verify banner -> release -> create with
a real sourceType: "channel" session filling a one-session workspace.

* fix(web-shell): keep live sessions visible in release dialog

Expanding the recovery list from the sidebar's default source filter to
persisted history can still bury the sessions holding slots: closed
channel rows fill the first page before they are reached.

Add a liveOnly session-list option on the daemon route and SDK so callers
can read the workspace bridge instead of persisted history. The release
dialog requests the shared page size with that option and keeps listing
default, channel, and qwen-live rows, so closed history can no longer
crowd out what can be released.

Co-authored-by: Qwen-Coder <[email protected]>

* fix(sdk): serve liveOnly session lists over native REST

The ACP session/list mapping drops liveOnly, so a DaemonClient
configured with an AcpHttpTransport silently returned persisted
history for the release dialog's live-only request. Route liveOnly
through the native REST transport for both the legacy and
workspace-qualified list methods, and cover the real ACP transport
with a regression test.

Co-authored-by: Qwen-Coder <[email protected]>

* test(web-shell): pin the release dialog's source whitelist

Live-only listings can include side_task, scheduled_task, and standalone
sessions that the server does not hide. Add those rows to the dialog test
so widening the whitelist fails, and state the 100-row live-page ceiling
next to the page-size request.

Co-authored-by: Qwen-Coder <[email protected]>

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-11-07 15:31:35 +00:00
1853b1505f fix(subscription): isolate activation and restore sign-in recovery (#16453)
* fix(web-shell): wait for session before checking subscription commands

* fix(cli): keep signed-out subscription startup in the composer

* fix(subscription): preserve model selection across sign-in

* fix(subscription): align sign-in with model selection scope

* fix(subscription): apply reasoning defaults across models

* fix(subscription): tag Qwen web links by client

* fix(subscription): prefix Qwen web client tags

* fix(subscription): attribute Qwen web links by paywall scene

* fix(subscription): recover historical sessions with saved API models

* fix(cli): remove the signed-out subscription startup notice

* fix(subscription): support standalone desktop activation and expose errors

* fix(subscription): isolate activation and session model selection

* fix(subscription): enforce auth policy and restore home login

* test(cli): preserve the default initializer mock on restore

---------

Co-authored-by: luoyun <[email protected]>
Co-authored-by: 易良 <[email protected]>
2026-11-06 20:11:52 +08:00
chencheng (云谦) 1b1f4455d9 ci: build the PR tree once and shorten the required path (#16450)
* ci: build the PR tree once and shorten the required path

The no-AK integration gate, the four web-shell smoke shards and Lint & Static each rebuilt the tree test_build already uploads, and the build cache rarely helped: most runs logged "29 of 29 workspaces changed" because the arch-keyed cache was split across the mixed x64/ARM64 pool and a prefix restore returned the newest entry instead of the base commit's.

- test_build restores the build its base commit saved (ci-build-cache.mjs key --at), drops runner.arch from the key, and runs the serve fast-path closure and core subpath checks against the built tree.
- The no-AK integration gate unpacks that tree and runs as three shards; integration_no_ak_result keeps the required check name.
- The web-shell smoke unpacks the same tree instead of building; consumers recopy the opentui native assets for their host (host-assets).
- The standalone runtime regression moves from unit shard 1 to shard 4.
- A java_only profile skips the TypeScript lanes for changes confined to the Java packages; Lint & Static runs the scripts/tests suites that name them.
- Drafts skip the integration gate and the browser smoke; ready_for_review starts the full run.

* ci: check the settings schema in test_build, which has the core dist

Lint & Static only had core's dist because the serve fast-path check built
it; with that check moved, Generate settings schema failed to import it.

* ci: keep the standalone regression on unit shard 1

Moving it did not shorten shard 1: the regression runs in the side lane,
in parallel with the CLI and core suites, and shard 1's time is its CLI
slice (576 s against 147 s on shard 4 for the same 181 files). That
imbalance is left to duration-based sharding.
2026-11-06 03:30:11 -08:00
jianyx1andqwen-code-dev-bot a051a6a796 fix(web-shell): batch session deletions at the route limit (#14637)
* fix(web-shell): batch session deletions at the route limit

* test(web-shell): cover bulk delete in visual previews

* fix(web-shell): retain bulk delete interruption errors

* fix(web-shell): retain deletion failures across workspace batches

Collect per-session and request failures throughout a deletion without losing
later causes or earlier successful current-session cleanup. Keep archive
behavior unchanged and document the daemon request-limit coupling.

Add gated regressions for both cross-workspace failure orders and multiple
per-session errors across batches. The new gates reject parallel-workspace
and per-workspace-error-reset mutations.

* test(web-shell): cover bulk delete merge boundaries

Preserve workspace and standalone failure causes for per-session and rejected requests. Verify each deletion batch captures its UI owner before the request and invokes its own invalidation callback with that batch result.

---------

Co-authored-by: qwen-code-dev-bot <[email protected]>
2026-11-06 10:28:24 +00:00
ff775787f5 feat(channels): journal private bound-session delivery and scan health (#16237)
* feat(channels): journal private bound-session delivery and scan health

The event timeline could not tell the private delivery lane's story. A
prompt injected into a PR-bound session ended on the same `running` word
as an event still in flight, no task row named the session it reached,
and the three private watch retires plus the dead-letter left only stderr
behind — so a silent consumption looked like a quiet repository. A bound
event rejected by the channel's own gates was worse than invisible: its
skip row was followed by running rows, reading as a delivery.

The private delivery method returned a boolean meaning "settled", which
conflated an injected prompt, a private retire and the public fallback.
It now returns a verdict, and the task runner derives the journal's
terminal word from it, so an event that was never delivered can no longer
be recorded as delivered. Routing, fallback, retry-budget and cursor
decisions are unchanged.

The binding scan is a new hard dependency: while it has never succeeded
the cold-start gate stalls every notification, yet the poll reports ok.
Degraded rows now surface that beside the stderr lines the adapter
already throttles, and on the edge of the existing watch-reconcile flag,
so a permanent outage cannot flood the journal. Attaching this to the
poll detail was rejected: both cold-start gates return before the detail
is set, which is exactly when it would matter.

No new event type and no protocol change — an outcome is already an open
string. The dead-letter's message body and its failure-path payload dump
stay out of the journal. Also labels `failed_ambiguous`, which was
already written but rendered as a raw token.

* fix(channels): make the bound-delivery journal claims true

Review round 1 found sixteen places where the journal, its labels or its
documentation promised something the code did not do. All sixteen are
addressed here; none is deferred.

Three were ordering or attribution defects in the new rows. A watch retire
was journaled before the removal it describes, so a throwing removal left a
terminal claim for an event the next poll re-runs and retires again under a
different reason. Rows written while a delivery is cleared lost the session
they were about, which is the one fact the private lane's attribution adds.
And the pre-claim watch retire sliced the loaded state before saving with no
rollback, so a failed save lost the event with no trace at all — the comment
claimed the opposite.

Two were counts that measured something other than their label: the partial
scan published the routing map's size, which counts bound PRs, as a session
count; and the streak advances per binding scan, not per poll, because a
gone-session retry refreshes again inside the same poll. The session count now
comes from the scan's own rows, and the streak is labelled as scans in both
languages and in the stderr lines that share the counter. Re-cadencing the
counter itself would change the throttle timing the previous PR shipped, so
the label moved instead.

Two were missing surfaces: the pending-queue-overflow retire wrote only
stderr and the dead-letter file, and an unreadable watch state was reported
as a pending backlog whose stated remedy cannot clear it. The overflow now
journals a body-free skip, and the watch guard classifies a read failure
separately while its gate stays fail-closed.

The recovery signal the prose promised did not exist: a poll/ok row is
written after every poll that does not throw, including all of an outage, so
it cannot mark the end of one. The watch guard now journals its clearing
edge, and the prose says why ok rows cannot serve.

The rest were documentation and test-coverage corrections: the protocol
reference promised detail.streak on every degraded row when only the
binding-scan rows carry it; the design file map certified the protocol
reference as unchanged while this branch edits it; the override's stated
consequence was backwards; and five guards had no witness or only half of
one. Each now has a test, and the suite grew from 438 to 444 in the GitHub
package.

Verified by 37 mutations, all killed. Two initially survived and were
investigated rather than accepted: a loose sessionId count that three
unattributed-row-free rows still satisfied, and a filter that matched no test
after a rename. Both were tightened until the mutation died.

* docs(channels): align the journal ledger with the round-2 verification

The validation section still cited the round-1 mutation count after the
review fixes added fifteen more, and the Chinese half had lost the count
altogether during the rewrite. Both now state 37 applied and 37 killed.

Also records why two mutations initially survived and what was done about
them, since a sweep that cannot distinguish "no test ran" from "the guard
held" is not evidence: a loose sessionId count that three correctly
attributed rows still satisfied, and a filter left matching nothing by a
test rename. The Web Shell line now mentions the absence coverage too.

* fix(channels): keep the lost bound session named on every journal row

The second review round of the bound-session journal found fifteen gaps.
Three were consequential, and two of those were introduced by this PR's own
previous round:

The gone-session retry never updated the local delivery, so with two
followers both gone the terminal row named the first one — a session evicted
a step earlier — while the row above it named the last. An operator reading
"which session lost this event" would open the wrong workspace.

Remembering the target only for the transition that clears the delivery is
not enough: once the clear is persisted, every later poll re-enters the
absence gate with no delivery to read, and the dead-letter at the deferral
horizon reads a record that has none either. A driven run of that sequence
left 239 of 242 rows unattributed, with the terminal row naming nobody — the
one fact this lane's attribution exists to supply. The lost target is now
persisted as attribution only; the delivery is deliberately not restored,
since that would re-enter bound delivery and change routing.

The overflow retire was journaled before its queue shift was durable, with
no rollback, so a failed save left a skip row for an event a restart would
later deliver. It now saves before it journals and restores the shift when
the save throws, matching the sibling pre-claim path.

Also: the partial-scan session count now includes the sessions retention
keeps routing (a draining workspace lists none while still delivering to
three), and both pre-claim retires carry the frozen target so the timeline
and the dead-letter agree about the same event.

Four coverage gaps whose mutants left the whole suite green are closed: the
guard keeping the partial-scan row off the cold-start path, the session on
the no-candidate retire, a second healthy poll so the recovery row's edge
trigger is distinguishable from an unconditional write, and both a shape
test and a Chinese render test for the interpolating labels.

The docs said more or less than the code: detail.sessions is carried by one
row, not by every partial scan; the normative sessionId definition read a
retire chip as a delivery; the emit-point inventory missed the overflow
site; and bindings_partial was described as recovering implicitly when a
successful multi-page scan is its producer. Auditing that last correction
found the stderr census was still incomplete — the lane has five recurring
health and capacity sites, not three — so it now names all five, ranks them
by consequence and states its own scope.

* docs(channels): define what the partial-scan session count actually counts

The count became listed-plus-retained when retention was included, but three
surfaces still described the old value. The protocol reference called it "the
sessions the map still routes to", which is wider than the code: the map also
skips channel-owned sessions and unparseable binding URLs. Both design halves
still said it was "the scan's own row count", which is narrower than the code
and was the exact wording the review asked to correct.

Define it as a session-row count — the rows this scan listed plus the sessions
retention keeps routing, bounded by the retention cap — and say explicitly that
it is not the number of entries surviving the map's own filters. Also record
that "listed" in the row label and in the stderr line is shorthand for
listed-or-retained and that both print the same number, so the wording cannot
quietly drift apart from the value again.

Documentation only: the adapter and its tests are byte-identical to the previous
commit, so every runtime result reported for this branch still applies.

* fix(channels): close the review gaps in the private-lane journal

Address the eight open suggestions and the five deferred findings from
the round-3 review, each with a mutation-verified guard (10 new mutations,
10 killed; one cap-count fixture initially survived because the first
empty partial already capped lastBindings, and was rebuilt so retention
is above the cap exactly at the emitting scan).

- R1-15: the overflow dead-letter append and the sidecar save now fail
  independently. A failed save rolls the shift back, names the watch-state
  path instead of blaming the dead-letter file, drops the payload dump
  (the body is already durable), and remembers the head so the next
  detection skips the re-append. The save-throws test now drives a second
  detection and asserts exactly one dead-letter line.
- R2-6: the pre-claim skip comment and both design languages now say only
  the overflow retire writes a dead-letter record; watch_no_candidate and
  no_role_matched leave the skip row as the event's only trace.
- R2-3: bindings_cold_empty no longer claims implicit recovery — an empty
  partial is a successful scan, the streak resets only on a complete or
  non-empty snapshot, and past the escape the silence is the throttle
  maxing out, not recovery.
- R3-1: a 1100-session fixture pins detail.sessions to the 1024 retention
  cap at the emitting scan; swapping retainedCapped.length for
  retained.length reds it while the two sibling count tests stay green.
- R3-5/R3-6/R3-15: the design's outcome cell lists failed_ambiguous and
  degraded, the watch-guard bullet admits the read-failure error field,
  and the sessionId carve-out covers every row written on a clear plus the
  pre-claim skip; the chip sentence says reached-or-lost. Both languages.
- R3-7: the three private-retire watch tests assert the public path stayed
  empty; leaking the envelope through handleInbound reds them.
- Deferred: the gone-horizon attributes the in-memory task before retiring
  and the dead-letter record names the lost session; the watch-guard row
  re-journals when the blocking cause changes; the subscriptions-off
  recovery edge has a test; the recovery row renders Recovered/已恢复
  instead of Degraded (wire outcome unchanged); a gone-retry fixture pins
  the scan-scoped streak at 10 inside poll 4.

Verified: channels/github 595 passed, web-shell channels 343 passed,
eslint and prettier clean; design mutation ledger updated to 47/47.

* fix(channels): rework the overflow retire ordering and close round-4 review

Critical R4-1: the overflow branch saved the sidecar mid-pass, committing the
advanced baseline before the pass's later events were queued; a crash then
never re-derived them. Remove the mid-pass save entirely — the head is sliced
in memory only, and the baseline and full queue commit together in the single
end-of-pass save, with the skip rows journalled after it.

The dead-letter record is now the retire's commit point. A lazily loaded,
file-backed key set (restart-durable, replacing the process-local map) skips
the re-append (R4-16), and both drains recognize a dead-lettered head and
complete its retire instead of delivering it, so no event is both delivered
and filed as retired undelivered (R1-15 follow-up).

Also addressed:
- R4-12/13/14: terminal, transition lost-session and pre-claim rows carry
  sessionWorkspaceCwd so Open-session reaches a session in another workspace.
- R4-11: the no_role_matched comment states the real ordering — the dedupe
  cursor is in memory until the base saves it, so the skip row is at-least-once.
- R4-15: already_delivered_other_role is documented as a defensive,
  unreachable guard and dropped from the published vocabulary.
- R4-2/3, R3-5: §1 outcome cell adds suppressed/posting, reason covers task
  and poll rows, sessionId carve-out adds cancelled and distinguishes live
  running rows. Both languages.
- R4-4/5/6: the pre-claim census acknowledges the role-aware drain's
  pre-existing fourth retire; the retire-carries-session rationale notes the
  preflight exception; the dead-letter write-failure note names the
  non-recoverable public-suppression exception. Both languages.
- R2-3/R4-7: cold-empty reset is keyed on the routing map and trust flag, and
  the escape's lane-open claim is qualified for the refresh-failure producer.
- R4-8/9: the recurring-site census adds the credential/rate-limit pass abort
  and corrects the no-existing-flag claim for the stale-map refresh failure.
- R4-10: watch-guard error wording covers reconciliation failure too.
- R4-17/18/19: openTarget joins the interpolation guard with a ZH render
  assertion, the EN streak label says consecutive, and ZH degraded/retired/
  recovered labels get render assertions.

Verified: channels/github 601 passed, web-shell channels 331 passed, eslint
and prettier clean; 4 new mutations killed (mid-pass save, file-dedupe, drain
guard, workspace lookup); design ledger updated to 51/51.

---------

Co-authored-by: verify-round <verify-round@local>
Co-authored-by: verify-pr <verify@local>
2026-11-06 18:21:47 +08:00
jinyeandQwen-Coder 738c4bbf7e fix(serve): bound command snapshot replay memory (#16413)
* fix(serve): bound command snapshot replay memory

Suppress unchanged command snapshots and retain only the latest full skill details so periodic refreshes cannot grow daemon heap indefinitely. Bound replay rings by serialized bytes while preserving ACP live delivery and reconnect IDs, and expose retention diagnostics.

* fix(serve): avoid redundant replay seed serialization

Size only retained complete command snapshots when seeding replay history, and forward the replay byte limit through the development daemon launcher. Pin snapshot accounting and lifetime eviction counters with independent regression assertions, and document the count/byte retention bounds and conditional command notifications.

Co-authored-by: Qwen-Coder <[email protected]>

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-11-06 16:13:17 +08:00
2666e3552d fix(core): make persisted session pagination safe for equal mtimes (#16024)
* fix(core): make persisted session pagination safe for equal mtimes

listSessions() ordered files by mtime alone and resumed the next page with a
strict `mtime < cursor`. An mtime is not unique — bulk copies, backup restores
and coarse-timestamp filesystems all produce ties — so a group of files sharing
an mtime that straddled a page boundary was dropped while hasMore still reported
progress, then the next page came back empty: 25 files at one mtime with size 5
showed 5 items in total.

Sort on (mtime desc, name asc) so the order is total, and let the cursor carry
that position: nextCursor is now `"<mtime>|<name>"`. A bare number is still
accepted with its existing strict `<` meaning, so callers holding a legacy
cursor keep working, and the daemon's opaque string cursor now passes the
composite form through instead of rejecting it as invalid.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-issue-patrol/jmvwpo7jxrk

* fix(cli): carry the composite session cursor type through callers

listSessions() now hands back a "<mtime>|<name>" cursor so a page boundary
inside a group of equal mtimes does not drop sessions. The widened
`number | string` contract left the CLI callers that store or compare that
cursor typed as `number`, which is a type error in `tsc --build` even though
nothing changes at runtime.

Widen the three consumers to `number | string`: the daemon's persisted scan
cursor, the live coordinator's paging plus its loop guard, and the session
picker state used by both the ink and opentui pickers. No behaviour change.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-issue-patrol/jmvwrtdc1rm

* fix(cli): route composite session cursors to the persisted listing

The persisted listing mints `"<mtime>|<name>"` cursors when a page ends
inside a group of files sharing an mtime, but the route still chose between
the persisted and the live listing path with a numeric-only predicate. A
composite cursor therefore fell through to the live path, whose parser only
accepts numbers, and the second page answered 400.

Export one predicate from session-list.ts carrying the same shape test the
cursor parser already applies, and use it for that routing decision.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-issue-patrol/jmvwtyj3bro

* fix(session-list): keep composite cursors usable by ACP and VS Code clients

listSessions mints "<mtime>|<name>" cursors whenever a page ends inside a
group of files sharing an mtime, but two consumers still parsed every cursor
as a number: ACP session/list rejected page 2 with "not a valid numeric
cursor", and the VS Code companion turned the composite cursor into NaN so
hasMore read false and only page 1 was ever listed. A malformed "|name"
cursor also slipped through the persisted-cursor classifier and answered 200
with an empty page instead of invalid_cursor.

- acpAgent: accept and forward "<mtime>|<name>" cursors to listSessions
- session-list: require a non-empty mtime prefix before classifying a
  cursor as persisted
- vscode companion: pass the daemon cursor back verbatim, widen the cursor
  type and treat only a non-empty cursor as "append to the current page"

Tests: ACP composite passthrough + empty-prefix rejection, VS Code
getSessionListPaged composite nextCursor round trip.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmvxke6bm6i

* fix(vscode): keep composite session cursors type-safe in the companion

021698420c widened the companion's paged session cursor to number|string but
left AcpConnection.listSessions typed number-only, so the package build failed:
TS2345 at qwenAgentManager.ts:573 and TS2365 at :626.

- accept the daemon's "<mtime>|<name>" cursor in AcpConnection.listSessions,
  which already forwards String(cursor) to ACP
- derive the mtime floor from the cursor prefix in the file-system fallback;
  the previous comparison coerced the composite string to NaN and silently
  returned an empty page

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmvxmjc316l

* fix(session): align persisted-cursor docs, error kind, and tie-break test

- test(core): feed readdir a non-name order so deleting the sort tie-break
  fails the equal-mtime pagination test (mutation-verified: 9/25 without it)
- fix(cli): report a persisted cursor as such instead of "numeric" in
  InvalidCursorError, matching the message ACP already uses
- docs(cli): describe the opaque persisted cursor in the serve protocol doc
- docs(core): drop the mtime-only-cursor rationale this change removed

Declined: folding the cursor codec into one validated helper across core and
the transports — a cross-package API change outside this PR's scope.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmvzvzfp79t

* docs(session): describe composite persisted cursors on every continuation page

`listSessions` mints `"<mtime>|<name>"` whenever a page has more results, so
the protocol text must not suggest a bare numeric response. The standalone
list route dispatches through the metadata parser, so its invalid-cursor test
should pin the metadata-kind message instead of the persisted one.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmw009r8j9z

* test(session): pin the composite persisted cursor shape and narrow its docs

R3-1: the cursor-contract sentence claimed the numeric-cursor input and the
"<mtime>|<name>" reply for every persisted-backed list, but view=organized and
the parentSessionId / sourceType filtered lists page through their own opaque
activity cursors and reject a numeric cursor with 400 invalid_cursor. Narrow
the subject to the legacy recent list and name the other cursor families.

R3-2: no assertion pinned the documented shape. Every persisted-path cursor
assertion was shape-agnostic, so a regression back to a bare numeric mtime
would have kept the suite green. Assert the shape on a page whose boundary
mtime is unique; mutating the mint site to String(lastProcessedMtime) turns
this assertion red.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmw04k2rma5

* docs(daemon): name the persisted list cursor in the SDK catalog guide

The batch-cursor sentence kept the legacy "numeric list cursors" wording while
the list route now mints the opaque `<mtime>|<name>` token, so an SDK
integrator parsing the returned cursor got `NaN` and a cursor they could not
send back. Match the protocol doc and point at the persisted list cursors.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmw1fplwqc0

---------

Co-authored-by: yiliang114 <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
2026-11-06 15:43:21 +08:00
callmeYe da7af136e5 feat(skills): expose native archive routes and receipts (2/4) (#16422)
* feat(skills): add file-backed archive installation kernel

* feat(skills): expose native archive routes and receipts
2026-11-06 12:09:38 +08:00
callmeYe 9b1ce46d77 feat(serve): add atomic user Skill activation (#16186)
* feat(serve): add atomic user Skill activation

* test(serve): cover conditional user Skill activation capability

* docs(serve): index user Skill activation endpoint

* fix(serve): preserve User Skill refresh recovery
2026-11-06 11:18:44 +08:00
曹潇缤andShaojin Wen ecc64a735a feat(qqbot): deliver proactive loop messages as active group messages (#15635)
* feat(qqbot): deliver proactive loop messages to the group target

* fix(qqbot): fail blocked proactive deliveries and post loop output once

- pushProactive now raises ChannelProactiveDeliveryError when
  sendMessageWithReplyContext reports a blocked outgoing route: the
  blocked send resolves instead of throwing, so a loop delivery that
  never reached the wire was being recorded as delivered. The block's
  disposition is kept so the scheduler can distinguish a permanent
  route failure from a retryable one; ACTIVE_MSG_DISABLED still throws
  unwrapped from the send path.
- QQ's streaming chunk sink is skipped for unattended turns (loop and
  webhook task, identified by the new protected isProactivePromptTurn
  predicate), so the full answer is posted once through pushProactive
  instead of being streamed to the group first and posted again. Base
  chunk bookkeeping, lifecycle events and attended streaming are
  unchanged.
- Tests: permanent and transient route blocks reject the loop and never
  send; streamed chunks of an unattended turn produce exactly one send
  carrying the full response.

* fix(qqbot): keep permanent delivery errors permanent and document proactive scope

- pushProactive rethrows QQ's permanent DeliveryError codes
  (RETRY_EXHAUSTED, ACTIVE_MSG_DISABLED, FALLBACK_FAILED) as
  ChannelProactiveDeliveryError('permanent', ...) so the daemon boundary
  classifies a group that cannot receive active messages as rejected
  instead of retryable. RATE_LIMITED stays transient.
- Drop the threaded-target guard copied verbatim from the base override;
  every entry point already gates on supportsProactiveTarget and QQ never
  produces a thread id, so the copy was unreachable drift.
- Name all four callers in the pushProactive doc comment (loop, webhook
  task, background/named-task response delivery, daemon deliverProactive)
  and state that background responses intentionally go out unanchored now.
- Document QQ in the proactive-delivery capability matrix and in the QQ
  channel guide: active messages, group quota, ACTIVE_MSG_DISABLED and
  the 5-failure auto-disable, no threaded targets, learned chat type
  required.

* feat(qqbot): retire loops for chats that can no longer receive proactive messages

A loop created before a group disabled active messages, or before the bot
was removed from the group, kept firing: each run rebuilt a session, ran
a full unattended agent turn, and could never deliver. Both chat-wide
state changes now retire the chat's loops.

- base: add an optional chat-scoped controller lookup (listForChat) and
  protected disableLoopJobsForChat(chatId), which disables every enabled
  loop in a chat regardless of the creator's exact target; the CLI
  controller implements the lookup over the loop store.
- qqbot: handleGroupDelRobot folds the disabled count into its existing
  cleanup log line; handleGroupMsgToggle disables the group's loops when
  active messages are turned off (both handlers become async and are
  dispatched with an explicit rejection log).
- qqbot: refuse proactive targets for a group whose active-message
  toggle is known to be off (supportsProactiveTarget), so loop creation
  is rejected up front and a firing refuses before burning an agent
  turn; direct messages never consult the group toggle.
- tests: creation refusal via the loop tool, run refusal with the prompt
  never called, removal and toggle-off teardown against the real
  controller, DM exemption, mid-flight toggle classification, and the
  base helper's contract.

* test(qqbot): keep the loop-store integration test honest

- Pin a rendered next-fire ISO timestamp instead of base's 'n/a'
  fallback: the copied controller now delegates nextFireTime the same way
  the production controller does, and the store clock is fixed so the
  value is deterministic.
- Redirect QWEN_HOME at the test's tmpdir before the channel constructor
  runs and assert the channels directory lands inside it, so the test no
  longer creates ~/.qwen/channels on the machine running it.
- Resolve the session through the locally constructed SessionRouter
  instead of reaching the protected ch.router, which was a TS2445 that
  no gate in the repo catches (test files are excluded from tsc).

* fix(qqbot): close the round-2 proactive delivery findings

- Resolve the target chat type through chatTypeFor() instead of
  chatTypeMap, and refuse an explicit group target whose type is unknown,
  so config-declared groups and never-learned groups are gated on the
  same type resolveRoute routes on instead of burning an agent turn that
  cannot deliver.
- Route background/named-task responses through pushProactive
  unconditionally: a toggle-off group must not silently receive them
  through the anchored passive reply path.
- Extract one PERMANENT_DELIVERY_CODES/isPermanentDeliveryError predicate
  and use it at the proactive seam and the five existing classification
  sites, preserving each site's behaviour.
- Fix the two comments that named a scheduler disposition check that does
  not exist (the daemon classifyChannelDeliveryError is the only reader)
  and word the blocked-route error as 'outgoing route unresolved
  (<disposition>)'.
- Include the group openid in group-event failure logs and pin the
  websocket dispatch rejection guard with a corrupt-store test.
- Stop retiring a group's loops when its active-message toggle turns
  off: the toggle is reversible, the pre-flight gate already prevents the
  turn burn, and the scheduler's 5-failure auto-disable retires the job.
  Retirement stays on bot removal, where the gate cannot see the change.
- Pin the permanent/transient arms with FAILED-fallback and RATE_LIMITED
  tests.

* test(cli): pin the chat-scoped loop lookup

listForChat decides which loops chat-wide teardown retires, and the QQ
suites substitute hand-rolled controllers whose filter mirrors it. Test
the real controller over a real ChannelLoopStore seeded with two chats
and two channel names, so dropping the filter (or only its channelName
clause) reds instead of silently retiring unrelated users' loops.

* docs(qqbot): record the proactive delivery contract

- Add the design record in English and Chinese covering the capability
  flip and the four base call sites it activates, the single-delivery
  rule for unattended turns, permanent/transient classification, chat
  retirement on bot removal, and the group active-message toggle
  contract.
- Complete the opt-in adapter list (DingTalk Workspace, Email with its
  proactiveRecipients qualifier) and state QQ's target restrictions and
  the unanchored background-response behaviour.
- Document isProactivePromptTurn in the base hook table and the
  adapter-author proactive delivery section.

---------

Co-authored-by: Shaojin Wen <[email protected]>
2026-11-05 18:28:31 +00:00
Shaojin Wen 6611b8aeb2 fix(cli): land a Web Shell update on the current latest release (#16374)
* fix(cli): land a Web Shell update on the current latest release

The daemon pins its update target when it prepares. Once the state is
`ready` the check returns the cached status even for `?refresh=true`, so
polling never re-downloads — and Web Shell prepares automatically on
mount. For a long-lived daemon the prepared release is therefore whatever
was latest shortly after it started. Clicking Update restarted into that
stale target, and every release published in between needed another
prepare-and-restart round, which reads as advancing one version per
click. The CLI resolves and installs at each process start, so it always
reached the current latest in one step.

Re-resolve the release once at activation, after the restart has been
acknowledged, and stage the newer one when it is higher and one-click
covers the installation. Both the standalone and the managed npm paths
share one resolver, so eligibility cannot drift between the check and the
click. Readiness stays pinned and background polling still never
re-downloads; only the explicit click re-resolves.

A failed re-resolve or staging applies the prepared release instead, so a
registry failure cannot strand an explicit update request on the running
version. The activation guard still preserves an equal or newer active
installation, and a failed activation of the newer release cleans both
payloads.

Staging runs after the acknowledgement and can take minutes, during which
the daemon keeps answering. The browser now measures its give-up deadline
from continuous unreachability rather than from the click: a reachable
daemon is still working, and the old click-time budget would have
reported a failure while the update was in fact succeeding.

The protocol reference and both language versions of the update design
doc state the pinned-readiness contract and the new deadline semantics.

* fix(cli): keep an update restart observable and its payload committed

An audit of the activation-time re-resolve found three defects in it.

Discarding the superseded payload ran before the newer one was committed,
so a failed discard fell back to a payload that might already be half
removed and orphaned the one just staged, losing the update entirely.
Commit to the newer payload first and discard the old one best-effort: a
failed discard now leaks disk instead of losing the update.

Dropping the browser's click-time deadline had also removed its only
overall bound. A daemon whose process replacement does not land on the new
build comes back reachable and settled on the same version, and the
control then waited forever for a version change that would never arrive.
Restarting is sticky until the process is replaced, so an acknowledged
restart followed by any other state with an unchanged running version now
terminates the wait and reports a failure. That path publishes the settled
state itself, because the poll immediately after it can drop while the
daemon is still coming back, which would otherwise leave a stale
restarting status rendering no control at all. The rule is gated on the
restart having been acknowledged: polling starts on the click, before the
request resolves, and can still observe the pre-click state.

Browser fixtures now model the daemon's contract — restarting from the
acknowledged restart until the process is replaced — because a fixture
that keeps reporting a prepared state after the click cannot exercise that
distinction. Assertions are unchanged; only the stimulus became faithful.

Also pin the edges the re-resolve depends on: a prerelease below the
prepared release, a registry rollback, build metadata that does not change
precedence and a malformed registry value must none of them restage, and
an in-flight restart must survive the operator disabling automatic
updates, concurrent polls and retries, and ordinary shutdown. Every
mechanism added here is mutation-witnessed, including the two halves of
the deadline change and the acknowledgement gate.

* fix(cli): sample the restart acknowledgement when the poll starts

The settled-state termination read the acknowledgement ref after awaiting
the status request, which does not establish that the response was sampled
after the acknowledgement. The status and restart requests are independent,
so the first restart poll can be issued while the restart is still pending
and deliver its pre-restart snapshot after the 202 arrives. The ref is then
true, the stale snapshot looks like a daemon that settled without updating,
and the control reports a false failure and clears the restart — after which
a genuine version change is handled by the idle branch, so the page never
reloads and the captured task URL is never restored.

Sample the flag when the poll is initiated instead. A response delivered
after the acknowledgement by a poll started before it is now ignored, and
the next poll — initiated afterwards — is the first one allowed to conclude
the replacement did not land. The regression test holds the status request
open, acknowledges the restart, then delivers the old snapshot, and requires
no error plus a successful reload once the new version is reported.

Also restore an absolute backstop, which the move to a continuous-
unreachability deadline had removed. A daemon that stays reachable but never
leaves restarting — a hung activation or process replacement — would
otherwise leave the control disabled forever with no error, and a page
reload hides it entirely. The cap sits above the daemon's own staging
ceilings (five-minute archive download, ten-minute managed npm child) so a
slow but progressing update never reaches it, and a test pins both halves:
the wait survives fourteen minutes of a reachable restarting daemon and ends
at the cap.

Both the protocol reference and the two language versions of the design doc
state the sampling order and the backstop.

* fix(cli): log why a click fell back, and keep the entry after giving up

Three review findings on the update restart path.

A registry outage at click time left no trace. The release check resolves
an error result rather than throwing, so the fallback returned silently and
"the registry said nothing newer" was indistinguishable from "the registry
was unreachable" — both install the older prepared release. Log the
classified failure before falling back. The catch below only ever sees
detection or staging throw, so it now names restaging instead of sending the
reader to the registry. The test that covered this used a rejected check
promise, a shape the real function does not produce for an outage; it now
resolves the real error result and asserts the log, and a second test pins
that a staging failure is not reported as a registry failure.

Giving up took the update entry with it. While the wait runs, each
successful poll publishes the daemon's state, so a wedged daemon leaves it at
restarting; the give-up branch cleared the restart without publishing
anything, and the render gate does not display restarting, so the entry
unmounted and could not return while the daemon kept reporting it. The user
got one notification and then no affordance at all. Every give-up path — both
deadlines and a daemon-reported error — now keeps a retryable entry on screen.
The design sentence claiming the control is never hidden was untrue of these
paths; it is now true of all of them. The wedged-daemon test asserted only
that the spinner was gone, which a vanished control also satisfies, so it now
asserts a visible enabled entry.

The absolute cap was sized against a ceiling the code does not have. The
standalone archive download tries OSS and then GitHub sequentially with a
five-minute budget each, and the checksum and signature fetches add up to two
more minutes, so one restage can spend about twelve minutes downloading and
verifying before activation — not the five the docs and the constant's
comment claimed. That left the fifteen-minute cap about three minutes of
headroom while the text promised a slow update would never reach it. Raise
the cap to twenty minutes and state the real chain in the protocol reference,
both design docs and the comment.
2026-11-05 22:29:12 +08:00
易良andQwen-Coder b87d60ddf0 fix(serve): serve qualified hooks status for untrusted workspaces (#16388)
* fix(serve): serve qualified hooks status for untrusted workspaces

The workspace-qualified hooks status GET sat behind the trusted-runtime
gate alongside env/preflight, so with folder trust enabled an untrusted
workspace got 403 exactly when the Web Shell approval card needed the
pending decision. The card treats the failed read as no hooks and never
renders, leaving no interactive way to approve. Route the hooks read
through the same trust-free resolver as the qualified approve POST;
env/preflight keep the trusted gate.

Fixes #16387

Co-authored-by: Qwen-Coder <[email protected]>

* test(serve): pin pending hooks decision for untrusted workspace

Co-authored-by: Qwen-Coder <[email protected]>

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-11-05 22:00:59 +08:00
liuxuely0626andluoyun 0cc37bf444 feat: add Qwen subscription support across CLI, VS Code, and Desktop (#16378)
* feat(subscription): add development login, models and web search

* feat(subscription): refine account flows and host integration

Align desktop and VS Code subscription UI, add account logout and confirmed plan display, and map subscription thinking parameters. Remove simulation code and obsolete native authentication paths, consolidate design documentation, and cover cancellation and recovery boundaries.

* fix(subscription): preserve request errors and remove unused compatibility

* fix(subscription): refine command visibility and client logout flows

* fix(subscription): simplify account actions and desktop sign-in feedback

* docs(subscription): clarify sign-in and provider guidance

* feat(subscription): support switching accounts across clients

* fix(subscription): align account actions and open plans on demand

* fix(subscription): restore model selection after logout

Filter subscription-only commands consistently across terminal renderers, preserve model catalog error classification, and display the subscription authentication name consistently.

* fix(subscription): unify status authentication labels across clients

* fix(subscription): preserve provider connections across client logout

Restore configured providers after subscription logout across CLI, Desktop, and VS Code. Keep unrelated connections and conversation history, synchronize model state silently, and return to an unconfigured chat when no provider remains.

* fix(subscription): correct native client logout state

* feat(subscription): enable sign-in without development mode

* refactor(ui): 优化会员登录成功提示和界面交互体验

- 移除订阅欢迎提示相关状态和行为,简化 UI 状态管理
- 替换登录成功提示为四行摘要,包含邮箱、套餐、模型及常用命令提示
- 更新国际化文本,支持新的登录成功摘要内容
- 取消订阅模式下输入框下方的命令提示行,改为在登录成功摘要中展示
- 移除多余的测试用例,新增登录成功消息的单元测试校验
- 优化 AppHeader 组件,不再显示订阅启动重复提示
- 调整 Composer 组件逻辑,删除订阅提示相关副作用及渲染条件
- 修改历史消息显示组件以适应新的成功提示格式

* fix(subscription): enable Flash reasoning controls in shared UI

* fix(subscription): prioritize authorized models and simplify login dialogs

* fix(subscription): preserve gateway errors and request IDs

* fix(subscription): synchronize account state across clients

Preserve model selections after sign-out, synchronize account commands and model catalogs, and retain explicit browser sign-in flows across CLI, Web Shell, and VS Code. Cancel subscription background agents on logout and normalize expired refresh credentials without SDK error wrapping.

* fix(subscription): reset default model on explicit sign-in

Choose the default served model when explicitly signing in to Qwen Subscription, preferring Max over a previously saved Flash selection. Preserve normal startup behavior and other provider flows, and cover CLI and workspace activation with regression tests.

* fix(vscode): refresh models after successful sign-in

* fix(web-shell): hide account actions during automatic sign-in

* fix(web-shell): align model details and auth keyboard navigation

* docs(subscription): revert previous guide changes

* fix(core): reuse subscription catalog for same-login model switches

* docs(subscription): document Qwen membership sign-in

* fix(subscription): unify quota notices and preserve provider switching

Carry structured subscription quota details through chat and search errors for localized CLI, Desktop, and VS Code display. Keep other providers available when subscription discovery fails and refine sidebar account actions.

* fix(web-shell): align Chinese subscription entry title with CLI

* fix(subscription): open plans for saved non-member accounts

* fix(web-shell): show subscription commands after desktop sign-in

* fix(subscription): reauthorize explicit account sign-ins

* fix(auth): add model switching hint to subscription success

* docs(auth): list Qwen OAuth as the first option

* docs(models): simplify subscription sign-in guidance

* docs(models): remove legacy OAuth from supported auth types

* docs(subscription): link membership references and remove obsolete allowance note

* fix(subscription): preserve session until reauthorization succeeds

* fix(subscription): use pre for browser authorization and plans

* fix(subscription): use production endpoints

* test(vscode): fix editor message test formatting

* fix(subscription): preserve unauthenticated session startup

* fix(cli): classify subscription auth during ACP preflight

* fix(subscription): scope activation to the current project

---------

Co-authored-by: luoyun <[email protected]>
2026-11-05 20:44:21 +08:00
Shaojin Wen bff144a528 fix(channels): keep GitHub notifications unread on transient thread errors (#16333)
* fix(channels): keep GitHub notifications unread on transient thread errors

A notification whose comments, events or issue could not be read was
skipped while the same poll still marked every notification read and
advanced the cursor, so a GitHub 5xx, network error or rate limit on one
thread lost its @mention for good, without a timeline entry.

Keep the cursor and the unread mark when a thread fails transiently, so
the next poll reads it again. Permanent answers (404, a SAML-protected
403) still skip the thread at once, and a thread GitHub keeps failing is
skipped after 30 polls so it cannot hold mark-as-read back for every other
thread. First-contact issue fetches follow the same rule, and every such
failure is recorded in the channel event timeline.

* fix(channels): keep exhausted GitHub retry streaks until the window commits

Dropping a thread's failure counter when it reached the cap re-armed the
same unread notification whenever another thread still held the cursor:
two threads that started failing one poll apart kept resetting each other
and the window was never marked read. Keep the exhausted streak until the
window commits or the thread succeeds, so the hold ends once every failing
thread has recovered or been skipped, and say so in the docs.

* fix(channels): separate shared GitHub limits from per-thread failures

Treat a rate limit or credential failure on one thread as what it is, an
answer every thread would get: hold the window and stop reading the rest
of the poll, without charging the thread's 30-poll budget, so a limit is
not hammered once per notification every poll. Only a 5xx (Octokit also
reports network errors as 500) counts against a thread's budget. A local
exception without a status skips the thread again, as before, which
keeps the inbound-task persistence guards observable on their own.

A held window re-reads failing threads every poll, so report a streak in
stderr and the event timeline when it starts, every tenth retry and at
give-up instead of on every poll. Pin the shared-limit stop, the
local-exception skip, the reset after a success and the clear on commit.

* fix(channels): bound the GitHub notification hold per window, not per thread

- A success reset a thread's 5xx streak while the window stayed held, so
  two threads failing in turn held mark-as-read and the cursor forever. The
  30-poll budget now belongs to the window: on the 30th poll that sees a 5xx,
  the threads still failing are skipped and the window can be marked read.
  Rate-limited polls do not spend it.
- A held window re-reads every thread and comment on each poll. Permanent
  failures and given-up threads are journaled once per window, rate limits on
  their first and every tenth stopped poll, and the duplicate and
  mention_required skips of a re-read comment once per window.
- Tests stub the first-contact issue fetch they fall through to, so they no
  longer pass only in file order.

* fix(channels): spend the GitHub 5xx budget only on a window that can commit

- A thread left unread at the cursor (its mark-as-read never applied) with
  nothing newer to mark read held nothing, yet each failing poll spent the
  window budget, which then never reset; the next new notification was
  skipped on its first failing poll and its comment lost. Only a window with
  something to commit now spends the budget.
- An error a failing task store raises inside a thread is journaled as
  retried, not skipped: the store failure holds the whole window and the
  thread is read again.
- Pin the 500 boundary (Octokit's network-failure status) and the stderr
  throttle; list github_api_error among the Channel events skip reasons.

* fix(channels): read only failed or changed threads while a GitHub window is held

- A held window re-read every listed thread on every poll, so one failing
  thread cost a read of the whole window per poll, and a secondary limit set
  off by that volume stopped every poll at the same thread: the window could
  neither commit nor reach the notifications behind it. A thread read without
  error is now remembered with its notification's updated_at until the window
  commits; it is read again only once new activity moves that timestamp, so a
  hold re-reads the failing threads and a poll stopped by a rate limit
  resumes where the last one stopped. A failing task store or audit keeps the
  thread unremembered.
- A notification listed again at or before the cursor (its mark-as-read did
  not apply) has nothing new to read; a failure reading it no longer holds
  the window or spends its budget.
- The once-per-window journal keys stop growing at their bound instead of
  clearing, so only the overflow repeats.
- Tests pin the budget surviving a failed commit, a 403 whose only signal is
  retry-after, and two repositories with the same issue number.

* fix(channels): journal each GitHub thread outcome under its own window key

- A retried store failure, a stale notification's skip and a real skip
  shared one once-per-window key, so whichever came first silenced the
  others: a thread could be skipped, and its comment lost at the commit, with
  the timeline still showing only the earlier row. Each outcome now has its
  own key.
- Only a local error (no HTTP status) counts as held by a failing task store
  or audit; a 404 on another thread in that poll is a skip.
- A permanent answer or a stale notification reads the same next poll, so
  the held window remembers it; a thread it gave up on is still read again.
  Stale failures no longer count toward the thread's failure milestones, and
  the per-thread counts share the per-window bound.
- A stale notification that hits a rate limit still holds the window (the
  threads after it were not read); the doc now limits the stale rule to 5xx.

* fix(channels): read cleanly read GitHub threads again on every held poll

Skipping a thread that read cleanly while its notification timestamp stayed
put lost data: the comment lane defers a first-contact body to the re-read
after a failed comment turn, so the body was never fed; and with GitHub's
one-second timestamps or a late notification, an unchanged updated_at does
not prove the thread has nothing new, so a comment posted after the read was
missed before the commit. A held window is read in full on every poll again,
as on main; only threads that failed with a permanent answer or were stale,
which read the same next poll, are not read again until the window commits.

* fix(channels): read a GitHub thread again after an error without a status

A thread whose read ended in an error without an HTTP status was settled for
the held window like a permanent answer. Such an error can come from the
channel's own state part-way through the thread: an unreadable
pending-deliveries file throws after the first comment was claimed, with no
persistence flag set, so the later comments of that thread (or its comment
lane after a direct dispatch) were never read while the claimed task held
the window, and were lost at the commit. Only permanent HTTP answers and
stale notifications are settled now; any other failure is read again, as on
main.

* fix(channels): read every GitHub thread again on each held poll

Settling a thread for the held window after a failed read lost items main
delivers. A stale notification is not empty: a late notification at the
committed cursor's second can carry a first-contact body or a direct event,
neither bounded by the cursor window, and its read can fail on one poll and
succeed on the next. A permanent HTTP answer can be short-lived too, such as
the 404 of an issue that was just created. Every thread of a held window is
read again on each poll, as on main; a stale notification's 5xx still neither
holds the window nor spends its budget.

* docs(channels): say what the GitHub failed and skipped timeline rows mean

The journal contract listed inbound rows as accepted or skipped only; GitHub
thread read failures now add a failed row (the window is held and the thread
is read again) beside the skipped one (the channel stopped holding the
window for it). Name both in the protocol reference and the channel guide.

* fix(channels): share the GitHub rate-limit test with the watch pass

A secondary rate limit sent as a bare 403 (quota left, no retry-after)
stopped the notification poll but not the PR watch pass, which kept
issuing one rejected pulls.get per watched PR. The limit's wording now
lives in the shared predicate, so both stop on the same answers.

A failing thread whose count no longer fits the per-window bound was
reported on every poll; it is now reported once per window. A test pins
that the hold budget starts again after a restart, and the journal
helpers in the test file share one reader.

* docs(channels): say what a github_api_error row means, not only its cause

A failed row also comes from a local error while channel state cannot be
persisted, the journal schema now lists the reason on inbound failures,
and a rate limit stops a held window's poll at the first refused thread
rather than reading it in full.

* fix(channels): report a GitHub thread its own task still holds as retried

A local error inside a claimed task's run, such as an unreadable
pending-deliveries file, leaves that task running: the cursor waits for
it and the thread is read again, yet the timeline called the failure
skipped. The poll now checks the thread's own tasks, as the store was
last read or written, and reports such a failure as failed until the
task settles.

The restart case now saves the cursor and loads it in a fresh instance,
as a real restart does, so a hold count kept in the cursor would fail it.

* docs(channels): keep the task store wording for the guide's first hold exception

* docs(channels): state the bounds of the GitHub held-window journal rows

A skipped row for a notification listed again at or before the cursor
normally loses nothing, since its activity was read before an earlier
window was marked read. The design page now lists the held-window causes
as examples, including an audit it cannot read and a failing
mark-as-read. The once-per-window rows are tracked under up to 8,192
keys, which the guide, the protocol page and both design pages now say,
and a credential failure is journaled like a rate limit.
2026-11-05 17:22:27 +08:00
Jiankai XiaoandJiankai Xiao de6ccb4e3c feat(serve): allow configuring the Conversations working-directory root (#16105)
* feat(serve): allow configuring the Conversations working-directory root

* test(serve): register Conversations root E2E in no-AK CI

* fix(serve): address Conversations root review feedback

* fix(serve): clarify Conversations root configuration errors

---------

Co-authored-by: Jiankai Xiao <[email protected]>
2026-11-05 16:53:05 +08:00
liuxuely0626andluoyun 2f4608c00b fix(web-shell): align welcome models and make UI language sessionless (#16297)
* fix(web-shell): align welcome models and make UI language sessionless

* docs(serve): index workspace UI language endpoint

* fix(web-shell): address sessionless language review feedback

* fix(serve): preserve workspace language during deferred sync

---------

Co-authored-by: luoyun <[email protected]>
2026-11-05 16:23:23 +08:00
Shaojin Wen 13e65c1a6e feat(web-shell): link GitHub PR/Issue targets in channel events (#16226)
* feat(web-shell): link GitHub PR/Issue targets in channel events

The channel events page showed the repository#number target of GitHub
journal events as plain text, so operators had to copy the reference into
a browser to inspect the PR or issue behind an event. Chat prompts and the
PRs panel already link out; this closes the last unlinked surface.

The URL is synthesized in the client from the channel config and the
journal fields the GitHub adapter already records, so existing journal
history becomes linkable without any daemon or journal change. The origin
mapping mirrors the adapter's web origin derivation (github.com, GitHub
Enterprise /api/v3, ghe.com data residency), and links pass the same
safety and desktop-opener routing as chat links. Non-GitHub channels,
removed channels, and malformed references stay plain text.

* fix(web-shell): link GitHub events of channels inherited from user settings

The events page resolved link metadata from the management listing, which
is scope-local: a channel configured in user/system settings with cwd
pointing at the project (the setup the GitHub channel guide recommends)
has journal rows on the page but no listing entry, so its targets stayed
plain text.

The journal route now attaches non-secret link metadata (platform type and
API base URL only) resolved from the merged settings the workers actually
run with, filtered by the same cwd-ownership rule the worker grouping uses
so one workspace never sees another's channel metadata. Resolution failure
degrades to unlinked rows instead of failing the journal read. The page
falls back to this metadata when the listing has no entry, and a source
contract test now ties the client origin mapping to the adapter's so the
deliberate mirror cannot drift silently.

* fix(web-shell): make channel event link metadata safe and authoritative

Review of the journal-metadata round found the default settings loader
leaking the queried workspace's .env into the daemon's process environment
(and able to rewrite a corrupt settings file) on a read-only polling route,
and the client preferring the scope-local listing over the merged metadata
even though system settings override workspace values.

The route now resolves metadata through readSettingsSnapshot on the
runtime's effective environment — side-effect-free, corruption throws into
the existing degrade path — and the helper requires that view instead of
defaulting to loadSettings. The page prefers the journal's merged metadata,
keeping the listing as the older-daemon fallback, derives the displayed
number from the thread exactly like the URL does, and strips trailing
slashes from the base URL on both sides of the origin-mirror contract.
The serve protocol doc now documents channelLinkMeta.

* fix(cli): resolve channel link metadata against the boot environment snapshot

The serve process-env guard flagged the journal route's live process.env
fallback: a read-only polling route must not couple to shared mutable
process state. Thread the daemon's frozen boot-time environment snapshot
through the route deps and use it as the parent-process fallback, the same
source the session host resolution already uses, instead of reading the
live environment or adding a guard exception.

* fix(web-shell): emit the resolved GitHub web origin from the daemon

Review round 2 flagged that the web-origin mapping had two owners pinned
only by a source-scraping mirror test, and that the boot-environment guard
had no test able to fail. Retire the second owner: the channel package now
exports resolveGithubWebOrigin as the single mapping owner, the adapter and
the journal route both use it, and the route emits the resolved origin as
channelLinkMeta.webOrigin so the browser only concatenates. The client's
baseUrl derivation remains solely as the older-daemon/listing fallback, and
the scraping contract test is replaced by a mapping-owner table (including
whitespace-padded env-resolvable values), route origin assertions, and
emitted-origin consumption tests.

Pin the boot-snapshot guard behaviorally: mount() accepts a daemonEnv
override and a new case resolves channels from a boot home that disagrees
with the process environment — reverting to a live process.env read now
reds that case. Also call the exported resolveChannelOwnerCwd instead of
re-implementing the ownership predicate, and correct the protocol doc's
read-scope wording for the added settings snapshot.
2026-11-04 23:24:13 +08:00
56fec7f3f5 ci: shard the Linux unit tests and web-shell smoke, and reuse cached builds (#16133)
* ci: shard the Linux unit-test lane eight ways behind one build

The Linux Test job ran 45-59 minutes: a ten-minute prepare build, then the CLI and core suites back to back. A new test_build job runs the prepare build once and uploads its outputs; eight shards install without the build, unpack them and run --shard=N/8 of every Vitest suite. Test (ubuntu-latest, Node 22.x) becomes an aggregate that passes only when the build and every shard pass.

* ci: record ci.yml's growth from the test_build prelude

test_build carries the same pre-checkout cleanup, checkout and install prelude as the test shards; ci-platform-lanes.test.js pins the two copies identical.

* ci: record the Linux lane's two new jobs in the PR-stage budget

test_build and the test_ubuntu aggregate add two jobs; the declared timeouts drop from 305 to 295 minutes.

* ci: shard the web-shell E2E smoke four ways

The smoke lane ran 44-46 minutes on every recent PR, about 33 of them in the browser tests at two workers, and is the longest lane once the Linux unit tests are sharded. Each shard runs --shard=N/4 of the smoke; the transcript gate runs on the first. web-shell E2E Smoke (ubuntu-latest, Node 22.x) becomes an aggregate over the shards.

* ci: bound the unit shards' tree download and retry it once

A pool host stalled 17 minutes in download-artifact while the other shards took under a minute.

* ci: rebuild only the workspaces a change reaches

scripts/ci-build-cache.mjs hashes each workspace's tracked build inputs (tests excluded) with its workspace dependencies' hashes and the shared root inputs. test_build and the web-shell smoke shards restore the newest cached build and rebuild only workspaces whose hash changed. A push to main always builds from scratch and saves, so a pull request starts from a clean build of main.

* docs(ci): describe the incremental build cache

* ci: keep the build cache path the same on every pool host

actions/cache versions an entry by its path; under runner.temp the path differs per host, so a run could never restore another host's save.

* ci: save the ARM64 build cache from the smoke lane, and run the aggregates on the shards' runners

The smoke shards run on the ARM64 pool, which test_build may not, so the first shard saves the clean build it made when its restore found nothing. The two aggregate checks queued 9 and 13 minutes for a hosted runner after their shards finished; they now take the shards' runners.

* ci: bound the sharded test step and skip shards when the build fails

Address the first automated review round on the Linux sharding lane:

- Gate every build-consuming shard step on `test_build` success. `!cancelled()`
  stays true when a needed job fails, so a failed build still started all eight
  shards, each paying a full install before dying at the missing artifact and
  reporting artifact plumbing as its failing step.
- Drop the test step cap to 45 minutes. The job cap is 60, so the old 110 could
  never fire; a step timeout keeps the diagnostic steps below it alive while a
  job timeout does not.
- Keep the built-tree artifact for a week so a late "re-run failed jobs" still
  finds it, and correct the trigger comment that still named three post-merge
  jobs when five now accept push.

Co-authored-by: Qwen-Coder <[email protected]>

* ci: keep disk telemetry for a failed build and correct the design doc

`test_build` installs with the disk sampler and then builds for up to its
whole timeout, but the sampler dies with the install step's trap and no
collector ran, so an ENOSPC build (or the OOM that took a shard host) kept
no evidence. Give it the same failure-gated dump and last-step upload as
`lint_and_static`, under its own artifact name.

The incremental-lint design still said the test job keeps normal prepare
behaviour; this lane now installs with `QWEN_SKIP_PREPARE=1` and unpacks
the tree built once by `test_build`. Correct both language versions.

Co-authored-by: Qwen-Coder <[email protected]>

* ci: share the built-tree definition and the build preflight, harden the guards

The archive step re-implemented `builtPaths()` from `scripts/ci-build-cache.mjs`,
so the tree the shards unpack had a second definition that could drift from the
one `pack` stores. A `paths` mode now prints that list for `tar -T -`, and a new
test pins the four artifact-name literals that tie the producer to the eight
consumers.

The cached branch of `build` also hand-reproduced a subset of `scripts/build.js`
and so skipped its ink-patch preflight: the same missing
`frame-controller.d.ts` self-healed on a cache miss and surfaced as ~50 tsc
errors inside ink's declarations on a hit. The preflight is now a function
reachable as `scripts/build.js --preflight` and runs before both branches.

The remaining review suggestions were all meta-test gaps: the standalone-runtime
expression and the `test_build`/`test` prelude were guarded by nothing that
compares them in order, `ci-build-cache.mjs`'s three CI modes were never
executed by a test, and the infra-classification filter's step name was not
joined to `ci.yml`. Each now has an assertion that goes red when the guard is
removed.

Co-authored-by: Qwen-Coder <[email protected]>

* ci: bound the build steps so the failure collectors can run

Round-2 review found four issues in the previous round's fixes and one gap
in the new build tests.

A job timeout cancels the job and skips the failure-gated collectors, so the
`test_build` steps that can block now carry caps below the job's 45 minutes:
the install, the build, the pack and the archive. The install caps are
identical across `test_build`, `test` and `lint_and_static` so the prelude
parity guard still compares the three copies field for field. A test pins
every blocking cap under the job ceiling.

The `--preflight` early exit was only asserted as a string, so it could be
renamed with nothing going red while the cache-hit path silently ran the
full build. A spawned case now copies `scripts/build.js` into the fixture
and asserts the flag exits without generate, a workspace build or the
schema step.

The CLI mode tests write extension-less `#!/bin/sh` stubs and join PATH with
':', which cmd.exe cannot resolve; the Windows lane collects that file and
downgrades its failure to a warning, so it would have carried a permanently
red block. The suite is excluded on win32 alongside the other bash-fixture
suites.

The `test_build`/`test` intersection grew to 16 with the disk collector, but
the floor stayed at 15, leaving exactly one shared step uncompared; the floor
now matches the membership. The cache-hit-with-changes branch — the code
that decides what is rebuilt and what is deleted — gains a case: a changed
workspace is purged and rebuilt by directory, and an untouched workspace
keeps what the cache restored.

ci.yml's recorded size baseline is updated to the current file so the next
PR gets the full growth allowance back.

Co-authored-by: Qwen-Coder <[email protected]>

* test(ci): keep cache fixture call logs outside the archived tree

Co-authored-by: Qwen-Coder <[email protected]>

---------

Co-authored-by: 易良 <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
2026-11-04 14:52:12 +08:00
Shaojin Wen bf2f003294 feat(web-shell): edit GitHub repository settings from the channel editor (#16149)
* feat(web-shell): edit GitHub repository settings from the channel editor

Three settings decide how a GitHub Channel behaves and none of them could be
changed from the Web Shell: which accounts may speak in a given repository,
whether that repository answers without a mention, and how often the
notifications API is polled. Narrowing one repository meant hand-editing
settings.json on the daemon host, so the permissive channel-wide default stayed
in place by inertia.

The poll interval becomes a declared descriptor field, which is also what makes
it persistable: the store rejects an undeclared key outright. Per-repository
settings stay in the editor, because the store already validates their shapes
and a new descriptor field kind would touch the SDK and every client for a
shape only one platform family uses.

Each repository row is tri-state and defaults to inherit. The save compares
every field against the same reading of the stored object it seeded the draft
from, so an untouched repository keeps its keys verbatim -- including an empty
allowlist, which means nobody, and a list with no policy beside it, which
inherits the policy but overrides the members. Materializing resolved defaults
instead would freeze today's inheritance and stop a later change to the
channel-wide default from reaching those repositories.

The section map is keyed by field name, so Email's existing poll interval moves
to Connection alongside GitHub's; a test pins that rather than leaving it to
prose.

* fix(web-shell): let a chosen repository allowlist own its empty speaker list

Picking "Allowlist only" on a repository row and leaving the speaker box empty
persisted the policy with no list at all. The runtime resolves a missing
per-repository list through `??` to the channel-wide one, so the operator's most
restrictive available action kept the wider audience while the field beside it
said "Empty means nobody."

The merge only wrote the list when the speaker text changed. On a freshly added
row the text starts empty and stays empty, so changing only the policy skipped
the write entirely -- the default state of the feature, not an edge case.

Choosing the policy now owns the list too. The rule is deliberately scoped to a
policy the operator actually changed: a stored allowlist with no list is a
legitimate hand-written shape meaning "members inherited", and writing an empty
list there would narrow a channel nobody edited. Both directions have their own
case and their own mutation.

* fix(web-shell): make the repository speaker list symmetric and its hint truthful

Two defects in the per-repository speaker list, both in the path where the
operator moves the policy without touching the text.

Leaving an allowlist behind kept its empty list. An empty array is not nullish,
so the runtime kept denying everyone while the row read "Inherit" and the hint
promised the channel-wide list was back. No gesture the form offers could repair
it, because every one of them reads back as an empty box and wrote nothing. The
policy-changed branch now handles both directions: entering an allowlist owns an
empty list, leaving one releases an empty list, and a list with names in it stays
because the box still shows it.

The hint was derived from the draft policy alone, which tells two deliberately
preserved stored shapes the opposite of the truth: a stored allowlist with no
list inherits the channel-wide members but was told "empty means nobody", and a
stored empty list with no policy admits nobody but was told "leave empty to keep
the channel-wide list". The row now asks the save path what it will own, so the
copy and the persisted object cannot diverge again.

Also pins the paths the previous suite left unexecuted: returning a stored
require-mention and policy to inheritance, writing a list while the policy stays
inherited, keeping a stored list when the policy moves to an allowlist, each row
showing and editing only its own speakers, an explicitly open row, and the
localized interval label.

* fix(channels): bound the poll interval and stop a new bound stranding old channels

Declaring pollInterval exposed two problems the field did not have while it was
unknown to the descriptor.

Nothing bounded it from above. Node clamps a timeout past its 32-bit range to
1ms, so "poll once a month" persisted a hot loop that burns the notifications
quota and settles into error backoff. Number descriptors now carry a maximum
beside exclusiveMinimum, mirrored through the SDK, the registry's metadata
validation, the store and the editor, and GitHub declares the same one-day
ceiling Email already enforces at runtime. PollingChannelBase also falls back
past it, so a hand-written configuration cannot hot-loop either.

And a stored value the runtime tolerates became unsavable. A channel holding
pollInterval 0 works today because the poll loop falls back to 60s, but the
editor re-sends that 0 verbatim and both the editor and the store now rejected
it -- blocking an operator who only wanted to add a repository. Both skip the
range check for a finite number deep-equal to what is stored. The tolerance is
deliberately not generalized: an existing contract pins that an unchanged stored
scalar of the wrong type is still rejected, and a type error should be fixed
rather than forgiven.

Also names the repositories whose settings outlive a switch away from the
allowlist policy. The runtime resolves per-repository senders and requireMention
without consulting groupPolicy, so those entries stayed enforced while the rows
disappeared; deleting them would destroy hand-written configuration, so the
editor lists them and says how to edit them instead.

Closes the test gaps the review found: the delete-to-inherit paths, writing a
list under an inherited policy, the array guard, per-row speaker values and
two-row editing, an explicitly open row, the localized label, a mixed-case chat
group id, unmodelled keys on a case-retyped repository, the descriptor field
itself, and row-scoped visual assertions with the last row in frame.

* fix(web-shell): make the repository hints describe what saving actually does

Three places where the form promised something the save would not do, plus a
ceiling that repaired too much.

The speaker hint asked the projection what the current draft owns. A repository
storing a speaker list with no policy beside it therefore read "empty means
nobody", while clearing the box deletes the list and hands the repository back
the channel-wide members -- widening it while reporting success. The hint now
asks the projection about an empty box, which is the hypothetical it describes.

Row edits were projected only under the allowlist policy. Editing a row and then
switching the group policy rebuilt groups from the stored config, so the save
closed the dialog and restored the value the operator had just changed. The
non-allowlist branch projects the same overrides now, and still drops entries
that carry nothing.

The retained-settings alert claimed the settings still apply under every policy
that is not Allowlist. Under Disabled the group gate drops every envelope before
either lookup, so the alert now says they are kept but not applied, and the
Chinese copy names the option the select actually renders.

The interval ceiling fell back to 60s for anything above one day. Values between
one day and Node's 32-bit timer limit were honoured before and are not hot loops,
so a two-day GitLab interval became 1440 polls a day. Clamp instead of replacing,
and state the ceiling in the GitLab and hosting-contract docs.

The range message also invented a lower bound of 0 for a field declaring only a
maximum, and now names only the bounds the descriptor declares.

Tests: the three registry maximum-validation branches, the maximum half of the
unchanged-value tolerance on both the editor and the store, the inherit sentinel
mapping through both row selects, the disabled alert, the empty-box hint, and
both shapes of the range message. Docs: the design no longer claims no SDK or
schema change, credits the right class for speaker resolution, and the serve
protocol reference documents maximum.

* fix(web-shell): say what an empty speaker box saves, and sweep the invariant

The inherited-speaker hint promised to "keep" the channel-wide list, but a
repository storing its own list is not keeping it: clearing the box deletes
that list and hands the repository the channel-wide members, which can be
many more people. "Use" holds for every shape that reaches the hint. The
case pinning that shape now also clears the box and asserts the request, so
the copy and the save are pinned together rather than separately.

Pass the group policy into the retained-settings alert instead of deriving
it a second time. The two derivations were equivalent, but nothing kept them
that way, and a drift between them is what made the alert claim settings
still apply under a disabled policy.

Add a three-part invariant sweep over twelve stored shapes crossed with
every single-field edit, driven through the real save path: an untouched row
reproduces its stored object byte-for-byte, the empty-box hint agrees with
what that save admits, and no edit widens the admitted set unless the row's
own controls asked for it. Admission resolves per-repository then wildcard,
so an absent list means the channel-wide members rather than nobody.

Correct the docs where they described the superseded implementation: the
hint decision still named the current-draft projection, the user guide
contradicted itself on clearing an explicit allowlist, the clamp's blast
radius omitted Email not extending the base at all, and the files table
listed fifteen of the twenty-four files this change touches.

* fix(web-shell): keep repository edits on every non-Allowlist save path

The branch that retains per-repository settings when the group policy leaves
Allowlist was gated on the STORED policy, so a channel already open never
reached it: an operator who switched to Allowlist, narrowed a repository and
switched back saved successfully while the request restored the old open
entry. It also iterated stored groups only, so a repository typed during that
visit was never written and fell back to the wildcard. Both admitted a sender
the operator had just excluded, and the alert promises those edits are kept.

Projecting the draft onto conflicting aliases was the third path. The draft
holds one entry per lower-cased id, so two stored spellings that disagree both
received the last one's settings, turning an alice-only repository open while
the conflict check stayed silent — it only runs when the final policy is
Allowlist. Those entries are now left as stored, which is what the runtime
resolved before and cannot widen anything. Refusing the save here instead
would strand the operator: the conflict error is reported on a field that is
not rendered once the policy leaves Allowlist. Both consumers now share one
alias set rather than two judgements that could drift.

The invariant sweep missed all three because its fixture hardcodes Allowlist,
so it never varied the policy dimension. A second sweep crosses the same
shapes with the stored and final policies, for a stored repository and one
that exists only in the draft, asserting the runtime's own rule: speakers are
resolved without consulting groupPolicy, so a row must write the same entry
whichever policy the save lands on.

* fix(web-shell): keep an ambiguous repository alias exactly as stored

Preserving conflicting aliases from the projection was not enough: the
empty-entry filter still ran, so a no-op save on a channel stored Open
deleted an empty alias. The runtime matches that exact key and inherits the
wildcard allowlist, while deleting it lets the case-insensitive fallback
reach the wider spelling — a repository nobody edited went from one allowed
sender to everyone. The same gate one type over deleted a hand-written
non-record value with the same result; the store preserves an unchanged
non-record value, so passing it through keeps the save a no-op instead.
An ambiguous entry is now exempt from the filter whatever it holds, and a
non-ambiguous empty entry still drops so a row returned to inherit leaves
no stub.

Skipping the draft for ambiguous ids also discarded edits the operator had
actually made, and the dialog closed on success while the request restored
the stored policy. An edited ambiguous row is now refused, with the error on
groupPolicy because the allowlist field it used to be reported on is not
rendered once the policy leaves Allowlist. The refusal is scoped to a real
change against the reading that seeded the draft, so an untouched switch
still saves; refusing everything would strand an operator who only changed
the policy.

The alias dimension is now crossed into a sweep rather than left at two
examples: every stored shape against a disagreeing lower spelling, over both
stored policies and all three non-Allowlist final policies, asserting that
an untouched save is a byte-for-byte no-op admitting the same people through
the runtime's exact-then-fallback lookup, that a real edit is refused, and
that a no-op edit still saves. Two dialog cases click the same flows and
assert whether onSave is reached, which the state layer cannot see.

* fix(web-shell): enforce the interval floor and stop rewriting entries the form cannot show

The poll interval had a ceiling but no floor. The descriptor's lower bound is
exclusive, so 0.5 passed the editor and the store, and setTimeout rounds it to
about 1ms — the hot loop the ceiling exists to prevent, reached from below and
persistable only because this change made the field managed. The getter now
clamps into both ends from one pair of exported constants, and the GitHub
descriptor declares its maximum from the same export so the two cannot drift.
An exclusive minimum of 1 is not available, since it would reject the
documented 1, so the field description and the user doc state that a smaller
positive value is raised to 1.

The claim that the store preserves an unchanged non-record group entry was
wrong, and it was mine: the tolerance covers the whole groups value, while a
single non-record entry is refused on every save. A case against the real
store now pins that. Rather than weaken per-entry validation to make the
comment true, the editor refuses such a save with a message that names the
entry and points at settings.json, because no row can represent it and a
generic daemon error leaves nothing to act on. The entry is still not deleted,
whether or not it has an alias twin: deleting the exact key the runtime
matches first is a rewrite nobody asked for.

The retained-settings alert enumerated storage instead of the save, so it
never named a repository typed during the visit even though the save writes
it, and it announced an allowedUsers list beside an inherited open policy as
still applying when senderGateFor builds no allowlist for it. The list now
comes from the request the save builds and is classified by resolved effect.

An unrelated edit also rewrote the file: a channel that never had an allowlist
lost an already-empty entry, because the filter treated every empty entry as a
stub. It now distinguishes leaving Allowlist from never having had one, while a
repository typed during the visit stays a placeholder that is dropped when it
carries nothing.
2026-11-04 01:46:24 +08:00
Shaojin WenandQwen-Coder f30da36c6f feat(channels): deliver GitHub events to PR-bound sessions (#16117)
* feat(channels): deliver GitHub events to PR-bound sessions

A session that follows a PR (it created, reviewed, or verified the PR and
carries the PR binding) never saw GitHub channel events: the channel routed
everything to its own chat_thread sessions, and pure CI failures or merge
conflicts produced no notification at all. This connects the two systems.

When deliverToBoundSessions is enabled, a new read-only process-global route
(GET /sessions/pr-bindings) reverse-looks-up the sessions bound to a PR, and
the GitHub adapter injects the event into the most recently updated bound
session as a prompt instead of a channel-owned session. The injected prompt
marks GitHub content as untrusted, keeps the reply inside the session (no
auto-publication), and runs the same group/sender/pairing preflight as the
channel path. Dormant bound sessions are woken through the bridge's resume
path; only a truly deleted session falls back to the normal channel path.
Queue-full is treated as backpressure and does not consume the retry budget.

prWatchEvents adds edge-triggered state watching for bound PRs inside the
existing poll cycle (ci_failure, merge_conflict, head_change, state_change),
with per-head dedupe, a bounded grace window for late-registering checks,
partial-snapshot-safe pruning, and state persisted before dispatch so
restarts never re-fire a transition. Both fields default off; existing
deployments are unchanged.

Design: docs/design/2026-11-02-github-channel-bound-session-events.md (EN)
and .zh-CN.md (ZH). Verified with 2039 unit tests across channels/base,
channels/github and the cli worker/route suites, plus isolated-daemon E2E
against a mock GitHub API (bound delivery, dormant wake, watch transitions,
default-off regression).

* fix(channels): harden bound-session delivery against incomplete scans and crash windows

Address the six review findings on the bound-session event delivery, each
with a regression test:

- Partial binding snapshots no longer replace the routing map or the
  accessor's wake cache: known bindings and cwds absent from an incomplete
  answer are retained, so a workspace listing failure can never switch a
  private delivery to the public channel path or turn a queued dormant
  delivery into a gone-session fallback.
- A binding recorded merged/closed by the independent refresh stays watched
  until the poll itself observes the terminal transition, so state_change
  is never skipped by the refresh winning the race.
- An empty combined commit status (total_count 0, which GitHub reports as
  state "pending" for SHAs without legacy statuses) no longer counts as
  active CI; Actions-only and no-CI heads settle instead of re-fetching
  checks on every poll.
- Watch events now persist a per-head fired ring, so a flapping aggregate
  (failure -> success -> failure on the same head) delivers ci_failure
  exactly once, across restarts.
- The bindings route flags live-merge degraded successes (liveMergeFailed)
  as partial, alongside truncation and scan-cap exhaustion.
- Detected watch events are queued durably (pending) in the same write as
  the baseline, then claimed, marked fired, and delivered; a failed claim
  leaves the event queued, and a crash at any point leaves either a pending
  event or a claimed task for recovery. The watch pass runs before generic
  inbound recovery so recovery cannot remove a watch task the watch pass
  would then re-claim.

Design docs (EN/ZH) updated to match. Tests: channels/github 258, cli
worker+route 139, channels/base 1652 — all green.

* fix(channels): close bound-delivery lifecycle and snapshot-authority gaps

A multi-round undirected + adversarial audit of the bound-session delivery
feature (61 confirmed findings, probe-verified) plus a follow-up review round
and the triage escalation surfaced gaps around the very mechanisms the first
hardening commit introduced. This closes them in one pass:

Delivery lifecycle: watch dedupe keys now identify transition instances
(episode counter) so dirty->clean->dirty, close->reopen->close and SHA
revisits fire again while ci_failure stays once-per-head; pending events
reconcile before generic recovery and independently of the API pass; terminal
task records are replaced instead of silently swallowing recurring keys;
acceptance is persisted before task removal so a crash cannot replay an
injection; crash loops charge one attempt per recovery pickup; queue-full and
the new BoundSessionUnavailableError defer within a bounded horizon and retire
to a private dead-letter log instead of freezing the shared notification
cursor or vanishing silently; watch events are private-only (maintainer
decision: no public fallback that would publish operator-facing prompt text).

Snapshot authority: the bindings route enumerates workspace entries and flags
non-active trusted workspaces and multi-page scans partial; retention is
session-granular and recency-competing; the comment lane defers until the
first complete snapshot; cold-cache wake treats partial absence as transient;
www host aliases normalize; malformed 200 bodies fail closed.

Watch detection: periodic settled-head re-check (GitHub does not bump PR
updated_at on check activity), ci_failure subscription gate, startup_failure
conclusion, fail-closed state reads, array-shape guard, tmp cleanup on failed
writes, rate-limit fast-skip so the watch cannot pin the notification lane.

Worker hygiene: channel-turn _meta classification with worker authorization,
wake registration detach after the retried prompt settles, response-body
cancellation and body-read deadlines; the restore route distinguishes
permanent workspace absence (workspace_not_found 404) from reversible
draining states.

Docs: both design languages record the new decisions (private-only watch,
bounded backpressure + dead-letter) and corrected semantics; user doc cost
claims qualified. ~90 new regression tests pin each fix witness.

* fix(channels): repair bound-delivery build, error taxonomy, and watch dedupe

A second undirected + adversarial audit of the bound-delivery fix commit
found a broken build and 33 further confirmed findings. This closes them
all in one pass:

- build: transitionInboundTask's Pick omitted deliveredAt/deferCount/
  backpressure, failing the github package's tsc (vitest masked it).
- cold start: gate the comment lane on the first successful snapshot
  (boundSnapshotSeen), not complete-only, so a permanent partial no longer
  stalls the lane forever.
- private-only watch: route watch envelopes by identity so a delivery-cleared
  watch task can never leak to the public path; retire watch events
  symmetrically on unbind; restrict terminal-task replacement to watch keys
  so a cancelled comment is never revived after a crash.
- error taxonomy: superseded/archived sessions are gone, the worktree-reset
  barrier and cold-partial absence are backpressure, on both the direct
  prompt and wake paths — permanent states fall back, reversible states
  defer instead of burning the retry budget.
- cross-workspace: the worker token is a process-level relay credential
  admitted for any served (primary/trusted) workspace, restoring channel-turn
  classification for cross-workspace bound delivery.
- snapshot authority: flag an untrusted primary as partial; log per-workspace
  scan failures.
- watch robustness: fail fast on secondary rate limits; pin ci_failure
  once-per-head against fired-ring eviction (ciFiredSha); roll back a claim
  whose pending->fired save fails; reconcile pending events independently of
  the binding fetch; surface watch-cap overflow.
- dead-letter: surface the payload and stop claiming success on write failure.
- restore contract: migrate unknown-cwd tests to 404 workspace_not_found and
  pin reversible states (draining/transitioning/blocked) out of it.

Adds 38 regression and test-gap tests; each code fix mutation-verified red
without its test. Both packages type-check; design docs (EN/zh-CN) and the
user doc synced.

* fix(channels): harden bound-delivery snapshot authority, recovery ordering, and watch fail-fast

Round-3 undirected + adversarial audit found further defects; all
high-confidence ones are closed here.

- snapshot authority: a bound-session Gone evicts the dead session from the
  binding maps before the refresh, so a permanent partial snapshot cannot
  retain it by recency and shadow a live follower; a Gone from the re-delivery
  hop falls through to the public/retire contract instead of consuming the
  failure budget; a binding refresh that has never succeeded logs a distinct,
  throttled, actionable stall message (the gate stays fail-closed).
- recovery ordering: a bound task's deliveredAt acceptance mark is persisted
  before the cross-lane pending-deliveries read, so a corrupt or unreadable
  file cannot leave the task running without the mark and replay the injection;
  watch state drops malformed per-entry values (keeping healthy entries)
  instead of throwing every poll.
- error taxonomy: a post-wake retry 404 is reversible backpressure (the owner
  may be draining; the classification keys on "the wake ran", not the detach
  return), not Gone; the interrupted-worktree 409s (worktree_marker_missing /
  worktree_reset_interrupted) are gone for delivery (a resume retry never
  converges); an authoritative Gone evicts the cached wake cwd.
- watch robustness: fail fast on any rate limit (GitHub does not always send
  retry-after) and permanent per-PR 404/410/451, not just primary limits.
- GHEC data-residency: map api.<tenant>.ghe.com to the <tenant>.ghe.com web
  host so bound delivery works there.
- docs: PR breaking-change notes, dead-letter filename, Files-affected table,
  ci_failure SHA-revisit semantics (both design docs, EN + zh-CN), plus two
  test-harness isolation fixes (shuffle-safe).

Adds regression + test-gap tests; every code fix mutation-verified red without
its test. Deferred with disclosure: a low-confidence pre-existing sidecar
enrichment silent failure in the shared session-list surface.

* docs(channels): note the post-wake 404 exception in the accessor comment

Co-authored-by: Qwen-Coder <[email protected]>

* fix(channels): close comment-round findings on absence, idempotency, and watch isolation

An incomplete binding scan never establishes absence: empty-partial cold
answers defer the lane for a bounded number of polls, and unmatched events
defer recoverably (dead-letter at the horizon) until a complete snapshot
lands. Scanned sessions with no bindings emit tombstone rows through the
route, projection, and accessor so an observed unbind stops private
delivery even on partial answers; unreadable sidecars flag partial.

Prompt admission gains an idempotencyKey dedupe ring so a retry after a
lost acceptance mark or a lost 202 re-injects nothing; the accessor sends
the durable task id on every POST. Cross-workspace channel classification
now requires the explicit bound-relay marker, scoping the process-level
worker credential to its reason. 429 and other 5xx join the backpressure
taxonomy.

Watch hardening: reconciliation failure gates watch-task recovery, shared
credential/rate-limit failures abort the whole pass (401 included), the
pending CI re-check is interval-bounded, a failing Checks endpoint no
longer suppresses the other detections, and pending envelopes validate
with the task store's own validators. Bound prompts surface both
truncation levels and the comment author; a failed dead-letter write
keeps the task. Docs (user, protocol, design EN/ZH) and Web Shell i18n
synced; every fix mutation-verified.

Co-authored-by: Qwen-Coder <[email protected]>

* fix(channels): make the aggregate per-comment display cut visible

Real-scenario verification of the truncation fixes surfaced a third
silent-tail surface in the same flow: the aggregate lane caps each
comment at 400 display characters with no marker, and the comment is
already recorded in dispatchedComments — a long comment's tail (possibly
its actionable last line) vanished from every surface while the bullet
read complete. The cut now carries an explicit [comment truncated: N
more characters] marker, alongside the existing prompt-level and
batch-level notices. Regression test added and mutation-verified; user
doc and design docs (EN/ZH) synced.

Co-authored-by: Qwen-Coder <[email protected]>

* fix(channels): close round-1 audit findings on event loss and admission

Undirected + adversarial audit of the merged diff found four event-loss
paths and an admission-contract outlier; all fixed with regression tests
and mutation witnesses:

- The aggregate lane recorded dispatchedComments keys BEFORE the task
  claim; an unwritable task store plus the unconditional end-of-poll
  cursor save persisted the keys with no task, silently consuming the
  comments forever. Keys now land via applyTaskDedupe only after the
  task write succeeds.
- The idempotency key is now the event identity (source chat + message
  id), not the task id: a crash after task removal but before the cursor
  save re-creates the task, and a task-scoped key let the daemon admit
  the same event twice.
- A persistently failing Checks endpoint degraded the pending re-check
  interval to one doomed request per PR per poll; failed attempts now
  advance the interval clock, and the undefined-ci branch is bounded too.
- Pruning a watch entry whose earlier transition still has a surviving
  task reset the episode counter; a rebind then collided keys and silently
  consumed the new transition into the old envelope. Prune now skips
  entries with recoverable watch tasks (and skips entirely when the task
  store is unreadable).
- sendPrompt's unknown/closing admission rejections threw as rejected
  promises, letting the prompt route return a phantom 202 and the ring
  remember a non-admission; they now throw synchronously like the sibling
  admission paths, and the route forgets the key on any deferred
  SessionNotFoundError as defense in depth.

Also: bounded the accessor wake-cwd cache under permanent partial, honest
relabel of the routing-gate regression test, field-level body assertions,
Web Shell coverage for the two new descriptor fields (EN/zh), descriptor
reasonFilter description synced to the checkbox contract, and the protocol
doc's ring bounds completed (1024-session cap and its lifetime exception).

Co-authored-by: Qwen-Coder <[email protected]>

* fix(channels): close round-2 audit findings on watch key identity and admission

The round-1 event key still collided across PRs (one repo, one session,
parallel episode counters) and across episode-counter resets (prune or
corruption recovery plus rebind), letting the daemon's long-lived
idempotency ring silently swallow new transitions. Watch keys now bake in
the PR number and detection timestamp; ci_failure keeps once-per-head via
PR+SHA prefix matching in the fired ring and pending queue. Over-length
event keys hash deterministically instead of earning a route 400 that
burns the retry budget. The prompt route forgets the idempotency key on
any pre-admission deferred rejection, keyed off the admission signal
rather than the error type, covering the standalone working-directory
RequestError. Permanent partial snapshots can no longer grow binding
retention or watch entries without bound (1024 cap, oldest-first,
surfaced on stderr). Design docs (EN/ZH) and contract comments are synced,
including the ring-lifetime exception at the 1024-session cap.

Co-authored-by: Qwen-Coder <[email protected]>

* fix(channels): close round-3 audit and maintainer review findings

The public-routing gate trusted a historical complete snapshot: under a
later partial answer an unmatched PR event could take the irreversible
public path while a live follower hid in an unlisted workspace. Absence
is now authoritative only on a current complete successful snapshot, or
per PR when every previous non-channel binder is listed and excludes it
(a listed session's PR set is complete, so its tombstone stays
authoritative). Never-known PRs, omitted or retention-evicted binders,
and failing refreshes defer as bounded backpressure; the new clauses are
PR-scoped so issue events and channel-only bindings keep flowing publicly.

The aggregate batch idempotency key identified only the first comment, so
a superset batch re-listed after the cursor-save crash window deduped into
the original admission and permanently consumed the new members. The
batch message id is now a content hash of the sorted member identities;
identical batches still dedupe across the acceptance crash window.

Partial retention rebuilt only the old winners, so a listed tombstone on
the newest follower emptied the routing map despite a retained alternate;
the rebuild now iterates the retained session rows. Watch-entry cap
eviction gained the prune's retained-binding and in-flight-task
protections (an evicted entry re-baselines silently, so the earlier
re-fire claim was wrong and is corrected). Disabling watch subscriptions
no longer clears the reconciliation guard while unreconciled pendings
exist, and the per-entry pending queue is capped at 64 with oldest
overflow retired to the dead-letter. Cross-instance watch delivery is
documented as per-subscription at-least-once, and the admission residual
(202 before admission completes) is stated precisely in the route and
contract comments. Design decisions 10/11 (EN/ZH) are synced.

Co-authored-by: Qwen-Coder <[email protected]>

* fix(channels): close round-4 audit findings on absence authority and republication

A partial binding snapshot can never prove a PR unbound: an unscannable
workspace may hold a never-observed follower, and a listed tombstone
proves only that the listed session unbound. The round-3 per-PR authority
mechanism granted public routing when every observed binder was listed
and excluding the PR, which a probe showed misroutes a same-poll comment
irreversibly. Remove the mechanism; unmatched PR events now defer as
bounded backpressure until a complete snapshot lands, while listed
tombstones still stop private delivery and watching immediately. The
partial/failed clauses stay PR-scoped so issue events keep flowing once
the cold-start gate opens.

Close a pre-existing public-reply double-publish window: a reply could
land (publication audit posted), the task be removed, and the cursor
save lost; the re-listed comment was claimed and published again because
only recovery checked the audit. The dispatch path now honors the same
audit record before claiming, fail-closed on an unreadable audit, and
records the dedupe keys so the cursor advances.

The worker accessor flagged a listed session partial only when ALL its
PR entries failed validation; any dropped entry now degrades the answer
so a partial projection never masquerades as complete. The round-3
cap-eviction task protection gains a non-vacuous witness (the protected
entry moved outside the retained candidate range). Bound preflight
rejections and the disabled-subscription guard now surface accurate
stderr, and the protocol doc states the 202-before-admission residual.

Co-authored-by: Qwen-Coder <[email protected]>

* fix(channels): close round-5 audit findings on audit fail-closed and wake classification

The round-4 claim-time publication audit check claimed fail-closed but
only skipped the single notification: with no task claimed, the
end-of-poll cursor commit (mark-read included) advanced past the skipped
event, so a persistently unreadable audit silently consumed every new
public event instead of retrying. An unreadable audit now freezes the
whole poll's cursor via a dedicated flag, and the next poll retries the
read and the events.

The audit consumption verdict treated any posting/posted/suppressed line
as consumed regardless of a later terminal failed, so a reply that never
landed (posting then failed) plus a lost cursor save permanently swallowed
the event. The verdict is now the LAST outcome per source key: a terminal
failed keeps the event retryable; a bare posting (crash mid-post) still
consumes at-most-once.

The gone-session public fallback bypassed the absence gate: under a
partial scan an unscannable workspace may still follow the PR, making the
public reply irreversible. The fallback now defers as backpressure until
a complete snapshot establishes absence (delivery cleared so recovery
re-resolves without a doomed POST); deliverToBoundSession returns a
settled flag so runInboundTask's success path cannot retire a task the
fallback deferred. The wake/resume path classified 429 and non-503 5xx as
delivery failures, burning the attempt budget into an unrecoverable
dead-letter on a transient resume rate limit or spawn jitter; they are
backpressure now, aligned with the POST path. The watch drain stops at
the first failed delivery to preserve per-PR ordering, and the worker
flags ANY dropped session row partial, symmetric with the PR-entry rule.

Co-authored-by: Qwen-Coder <[email protected]>

* fix(channels): close round-6 audit findings on freeze bypass and ordering

The round-5 audit freeze only froze the cursor commit, but the direct
lane and the first-contact body feed recorded their dedupe keys
unconditionally after dispatch; the run loop's unconditional cursor save
then persisted the keys, so a transient unreadable audit permanently
filtered the re-listed events — the same silent consumption the freeze
was meant to prevent, revived through two lanes. Both lanes now honor
the dispatch refusal before recording, matching the comment lane.

The audit consumption verdict treated every terminal failed as a
provable no-write, but ambiguous errors (502/timeout) may have landed the
comment; unlocking those keys let a crash window double-publish an
irreversible public reply. Ambiguous failures now audit as
failed_ambiguous and stay consumed (at-most-once); only definite
no-write failures (secondary rate limit with remaining=0) unlock retry.

The per-PR drain ordering held only within a single pass: the watch pass
re-drains after reconcile and the next poll's reconcile runs before
recovery, so a later event could still arrive before its unsettled
predecessor. The drain now skips the whole entry while an earlier watch
transition of the same PR has an unsettled task (a task whose key equals
the pending head is that event's own crash-window claim and drains
normally). A claim persistence failure propagates out of the drain so
reconcile fails and the recovery guard holds instead of delivering an
accepted task whose pending record is unreconciled.

Disabling watch subscriptions with legacy unreconciled pendings parked
the accepted tasks as backpressure so they stop freezing the shared
notification cursor channel-wide; an unusable watch state container is
surfaced on stderr like the corruption path instead of wiping silently.

Co-authored-by: Qwen-Coder <[email protected]>

* test(channels): cover the audit-freeze recovery with deliverToBoundSessions off

The maintainer's P2 reproduction exercised the unreadable-audit window
with deliverToBoundSessions both enabled and disabled; parameterize the
R5-A1 freeze/recovery test over both modes so the public-only path is
pinned too.

Co-authored-by: Qwen-Coder <[email protected]>

* test(channels): mock pulls.get in the direct-lane freeze test

processDirectLane fetches PR meta before dispatch; without an explicit
pulls.get mock the fetch throws under isolated runs, the per-notification
catch skips the event, and the test never reaches the freeze guard —
passing only by mock leakage from earlier tests in a full-suite run and
failing (vacuously) in isolation. The mock makes the witness genuine in
both run modes; the guard-off mutation now kills it isolated too.

Co-authored-by: Qwen-Coder <[email protected]>

* test(channels): pin the reader's failed_ambiguous consumption verdict

The writer-side failed_ambiguous tests only read the audit file; no test
drove the reader's consumption of an ambiguous verdict, so deleting the
reader's failed_ambiguous branch kept the suite green. The distinguishing
sequence is posting -> definite failed -> failed_ambiguous: the ambiguous
retry must supersede the stale definite-failed verdict and stay consumed,
or the double-publish window reopens. The new test seeds that sequence
(plus a definite-failed retryable contrast) and drives dispatch; the
reader-branch mutation now kills it.

Co-authored-by: Qwen-Coder <[email protected]>

* fix(channels): let the notification cursor flow while watch reconcile is blocked

Co-authored-by: Qwen-Coder <[email protected]>

* test(channels): prime the comment lane in the direct-lane freeze test

Co-authored-by: Qwen-Coder <[email protected]>

* fix(channels): decouple watch retires from the public reply store and sanitize dead-letter dumps

Co-authored-by: Qwen-Coder <[email protected]>

* fix(serve): throw synchronously on the standalone admission guard so relays never consume a phantom 202

Co-authored-by: Qwen-Coder <[email protected]>

* test(serve): harden deferred-forget retries and align admission contract comments

Co-authored-by: Qwen-Coder <[email protected]>

* fix(channels): protect the dispatched-comment ring from frozen-cursor eviction and sanitize error diagnostics

Co-authored-by: Qwen-Coder <[email protected]>

* fix(channels): remove the hard-cap comment refusal that silently consumed events; harden the cursor ring

Co-authored-by: Qwen-Coder <[email protected]>

* fix(channels): prune the comment-time map every poll, not only on dispatch

Co-authored-by: Qwen-Coder <[email protected]>

* fix(channels): iterate live followers in the gone-fallback and prune the time map in finally

Co-authored-by: Qwen-Coder <[email protected]>

* fix(channels): exempt issue events from the whole bound-absence gate

The per-event absence gate's !boundSnapshotTrusted clause was not
PR-scoped, so under a permanent partial binding scan (a multi-page scan,
a long-lived non-active workspace, an untrusted primary, or a corrupt
sidecar) a complete snapshot never lands and unmatched issue events
deferred to the 240-deferral horizon and dead-lettered without a public
reply — even though bindings are PR-only and an issue's absence is
provable without any snapshot. PR-scope the whole gate, cold start
included; issue events route publicly at once. The labeling assumption
is verified against 50 real GitHub notifications (zero subject-type/URL
mismatches) plus GitHub's exclusive issue/PR number space.

Sync the design EN+ZH (gone-fallback iteration, the finally prune timing,
the whole-gate issue exemption) and the user doc, and align the stale
R13-M1 test comment with the finally semantics.

* docs(channels): qualify the issue exemption and document the audit write trade-off

Round-15 audit follow-up, documentation only:

The user-doc sentence added in round 14 overstated the issue exemption
("never held by the binding scan ... at once"): round 14 PR-scoped only
the per-event absence gate, while the lane-level cold-start gate still
holds every notification (issue events included) until the first
successful binding lookup, and the empty-partial gate holds the lane up
to 30 polls. Qualify the sentence to the per-event gate, state the
lane-level hold explicitly, and tighten the design's stale-bindings
shorthand to match (EN + ZH).

Also document the publication audit's WRITE semantics in key decision 10
(EN + ZH): the write is best-effort (fail-open) so an unwritable audit
store does not break the public-reply contract — a deliberate trade-off
pinned by the "keeps successful publication when its audit write fails"
test. Under total storage failure no durable consumption intent can be
recorded, so a failure spanning the posting record, the POST, and the
task removal can let recovery re-publish; the read path stays fail-closed.
Notes the fail-closed write-ahead alternative and its availability cost.

* fix(channels): tolerate .diff/.patch binding URLs and pin the aggregate sort

parsePrBindingUrl rejected GitHub's /pull/N.diff and /pull/N.patch
spellings while the refresh sweep's pullRequestKey tolerates them. The
bind route accepts any http(s) URL without normalizing the path, so a
manually pasted .diff/.patch binding persisted and was then skipped by
the routing-map build, the gone-fallback, and the PR watch — silently
degrading that PR to public replies and never watching it. Align the
suffix alternation with pullRequestKey; the existing number-match guard
still rejects mismatched url/number bindings. Add parameterized .diff /
.patch regression tests.

Also pin the aggregate content hash's order-independence: the existing
R3-B1 test only fed ascending members, so removing the implementation's
sort survived. Add an out-of-order batch test asserting the key equals
the sorted batch's key.

Documentation: key decision 11 (EN + ZH) still carried round 14's
overstatement that issue events are exempt from the "ENTIRE absence gate,
cold start included"; round 15 fixed the user doc and the stale-bindings
paragraph but missed this numbered decision. Qualify it to the per-event
absence gate (including its !boundSnapshotTrusted cold-start clause) and
state explicitly that the separate lane-level cold-start gate still holds
issue events until the first successful lookup.

* docs(channels): document the hidden-catalog delivery limitation

Round-17 audit follow-up, documentation only (EN + ZH):

Document the bindings snapshot's hidden-catalog exclusion as a deliberate
design limitation: the machine-owned multi-agent task sessions
(agent-host / agent) are excluded by the session-list path without
setting partial, and they are not interactive delivery targets, so a PR
bound only to such a session takes the public channel path. The public
reply is safe; the private-delivery contract does not extend to those
sessions. Making the snapshot authoritative over them or gating the
write path is left to the multi-agent subsystem's design.

Also finish the per-event qualification sweep: the absence bullet still
said issue events are exempt from "the whole gate"; qualify it to "the
whole per-event gate" to match key decision 11 and the sibling surfaces.

* fix(channels): address review round — wake-cache presence, watch 403 isolation, aggregate ack

Resolve the review-bot findings (one Critical) plus the doudouOUC doc gap,
each pinned by a regression test and an isolated mutation witness:

- R2-1 (Critical): the cold-cache wake branch read the target's cwd from the
  FIFO-capped hint cache, so a listed, live, dormant session beyond the
  1024-row cap on a complete snapshot was misreported as gone and its event
  posted publicly. Read presence from the fresh full snapshot instead; the
  cap stays a bound (fast-path hint only) and the Gone verdict is unchanged.
  A present session is now woken even on a partial answer (presence is
  authoritative); only an absent session defers there.
- R1-7: a failing refresh after one successful empty partial stalled the
  comment lane forever (the bounded escape only advanced on the success
  path) and hid behind a misleading "keeping previous map" log. Advance the
  same escape and key the distinct stall log off the no-authoritative-map
  state.
- R1-11: a per-repo permission 403 aborted the whole watch pass, starving
  every other watched PR. Abort on a 403 only when provably a shared rate
  limit; otherwise isolate it per PR.
- R2-2: the aggregate batch's non-numeric hash messageId silently disabled
  the eyes reaction. Record the batch's first comment as a bounded reaction
  anchor resolved by the prompt-start/end overrides.
- R2-18: export PR_WATCH_EVENTS and build both the validator set and the UI
  option list from it; add the four localized option labels (EN + ZH).
- doudouOUC: document the >100-session permanent-partial steady state and
  add a throttled diagnostic naming the listed session count.

Test gaps closed: prsReadFailed production setters (R2-3), both idempotency
FIFO bounds (R2-4), the real-fetch comment-time capture (R2-5), and the
editor field descriptions (R2-6). Documentation: name the third relay _meta
credential key (R2-7), complete the Files-affect inventory (R2-8), and
qualify the unconditional acceptance criteria to the two FIFO caps and the
restart recovery (R2-9), mirrored EN + ZH.

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-11-04 00:43:20 +08:00
易良 dff7deb19c docs(release): explain hosted-runner queueing for publish (#16194)
* docs(release): explain hosted-runner queueing for publish

* docs(release): link the current release pool record
2026-11-03 22:41:31 +08:00
Shaojin Wen 02b8b0108c feat(web-shell): show a Channel event timeline (#16106)
* feat(web-shell): show a Channel event timeline

Channels reported what they did only through stderr, the publication audit,
and the inbound task store, none of which the daemon could read. An operator
wondering why a GitHub comment got no reply had to read the daemon log.

Every Channel now appends one bounded journal per instance: the inbound
messages it accepted, the ones it skipped and the machine reason for each
skip, publication outcomes, inbound task transitions, and poll results. The
journal holds routing decisions and identifiers only, never message text; it
rotates at 1 MiB to a single backup, and a write failure is swallowed into one
stderr line so diagnostics cannot break message handling.

The daemon serves the journal through GET /workspace/channels/events and its
workspace-qualified form, with limit, type, channel, and since filters, a
32-file and 2 MiB read bound, and the existing trust and client checks. The
channel parameter repeats once per instance name because a Channel name may
legally contain a comma.

The Web Shell gains a Channel events page and sidebar entry that render the
cross-Channel timeline for the resolved workspace, filtered by Channel and
event type and refreshed every five seconds, gated by channel_management.

Docs: the design in English and Chinese, the serve protocol, and the Channel
user guide. Channel tests isolate QWEN_HOME so journals never land in a real
home directory.

* fix(web-shell): scope Channel events to the selected workspace

Review found four correctness gaps in the event timeline.

The page read the provider's daemon-wide primary cwd, so a Channel started in
a registered secondary workspace could never be inspected. It now offers the
same workspace selection as the management page and passes the selected cwd
to both hooks; changing the workspace resets the Channel filter.

The poll loop captured the adapter's detail before the poll ran, so the first
success had no counters and every later one carried the previous poll's
counters against the current timestamp. The field is now cleared before the
poll and consumed after it settles, on both the success and failure paths.

The 32-file read cap was applied before the Channel filter, which made an
explicitly requested, quieter Channel unreachable once busier peers had newer
files. Candidate files are now narrowed by the requested identities first.

Channel identities were truncated to 80 code points while the management API
accepts 255 UTF-8 bytes, so longer legal names never matched their own filter
and names sharing a prefix became indistinguishable. The identity now keeps
its full legal length; only the UI shortens it for display.

Each fix has a regression test that was mutation-checked against the previous
behavior. Design (both languages) and the Channel user guide now describe the
workspace selection, the poll-detail timing, the filter-before-cap ordering,
and the identity limit.

* fix(web-shell): mask Channel events cached for another query

The workspace selector changes the query without remounting the hook, and the
underlying resource keeps its previous data while a request is in flight or
after it fails. Selecting a second workspace therefore showed the first
workspace's rows under the new selector until the new request succeeded, and
kept them when it failed — including their Channel names in the filter list.

Each snapshot is now tagged with the query that produced it (workspace,
Channel, types, limit) and mismatched data is masked, the same way the Channel
management hook already does. Two hook-level regressions drive the real hook
and resource with a pending and a rejected second-workspace request; both fail
without the tag.

* fix(web-shell): keep Channel event reads off the daemon's critical path

A verification round measured the journal read at the documented cap: one
page poll took ~860 ms of synchronous I/O, an unrelated /health request waited
~790 ms behind it, and four concurrent reads serialized to 3.4 s. The route
runs on the daemon that also serves sessions, so a diagnostic page must not be
able to stall it.

The read is now asynchronous and chunked (64 KiB awaits) under tighter bounds:
256 KiB per journal file, 4 MiB per request, 32 files, freshest first. On the
same 32-file / 32 MiB fixture the read is ~175 ms, a bystander /health stays at
6 ms p50 against a 2 ms baseline, and four concurrent reads finish in ~630 ms.

Two reporting gaps go with it. Anything the bounds clip — dropped files or an
exhausted byte budget — now sets `truncated`, so the page cannot show a
timeline that silently omits whole Channels. And a scope directory that cannot
be read propagates instead of returning an empty list, so the route answers
5xx rather than claiming the workspace's Channels did nothing; a missing
directory still means "nothing written yet".

The page also stops polling while its tab is hidden and reloads when it
becomes visible, so a forgotten tab no longer reads journals every 5 s.

Tests pin the two behaviors the wire depended on but the suite did not:
reading events that survive only in the rotated file, and clamping a limit
above the maximum. Both new mutations are killed, as are the truncation,
unreadable-directory, and hidden-tab guards; a bystander-timer test pins that
the read yields to the event loop.

* fix(web-shell): report a clipped journal tail as truncated

The per-file read window introduced with the asynchronous read is 256 KiB,
but a journal grows to 1 MiB before it rotates. `readTail` skipped the head of
a taller file without telling anyone, so a single busy Channel lost its own
older events while the response still claimed `truncated: false` — the exact
completeness contract the previous commit set out to fix. A 1 MiB journal of
1020 records answered 255 events with `truncated: false` at `limit=500`.

`readTail` now reports whether it started mid-file and the reader folds that
into `truncated`. The request budget is also spent in whole tail windows
instead of a final partial one, so its flag keeps the precise meaning "whole
journals were left out".

Three tests close the gaps this exposed: a journal taller than the tail window
must report truncation even when the returned rows fit the limit; the budget
fixture now uses twenty full-window journals whose combined rows stay under
the record limit, so only the budget can explain the flag; and a journal file
that cannot be read must fail loudly, like the directory case already does.
All four previously surviving mutants — tail flag, budget flag, budget debit,
swallowed file-read errors — are now killed.

* fix(web-shell): bound Channel event read retention

The events read parsed and retained every matching record across all
journals before applying `limit`, so an all-Channels poll on a busy
workspace held far more than the rows it returns. Trim the accumulator to
the newest `limit` once it passes four times that. A dropped record is
older than every kept one and the sort is stable, so the answer does not
change: a 60-scenario differential run against the previous behaviour
produced byte-identical output. Trimming counts as hitting the record
limit, so it sets `truncated` too.

Closes the remaining non-blocking review items as well. The provider
workspace action declares the full query type it already forwards, so the
interface no longer suggests that path cannot filter by Channel. The new
UI strings use the established 频道 label rather than introducing a second
word for Channel beside the existing sidebar entry; the design docs keep
渠道, which is what every other zh-CN design uses. The docs now state the
page's own request size (200) apart from the route default (100), and the
`truncated` comments describe every reason it can be set instead of only
the record limit. The NUL query-key separator is documented as
collision-free: instance names reject control characters, journal names
are sanitized, and a path cannot contain NUL.

* fix(channels): read a journal that fits the budget and keep aligned records

Reserving a full 256 KiB window per journal left a small journal unread
when the remainder was under one window, even when it covered that
journal many times over — coverage lost in exchange for a flag that was
already truthful. Reserve what the journal actually needs instead, using
the size the enumeration already stats, so the budget flag still means
"whole journals were left out" and a 200-byte journal is no longer skipped
beside seventeen full ones.

A tail window that landed exactly on a line boundary also dropped one
complete record, because the first line was discarded whenever the window
started mid-file. Read one leading byte and drop that line only when the
byte is not a newline. Folding the byte into the first chunk keeps the read
to one awaited call per chunk, which is what lets the daemon serve other
requests between them — now pinned by a test that compares the event-loop
turns a tall read takes against a short one over the same six journals. A
synchronous chunk read fails it.

Also records why an unparseable instant cannot satisfy `since` while an
unfiltered read still returns it, and pins both halves.

* fix(channels): collapse repeated reason-filter skips into a counted row

A real end-to-end run — the actual GithubAdapter over HTTP against a local
GitHub-compatible API, a real agent turn, the page in a browser — exposed an
amplification the seeded-journal runs could not. The poll cursor does not
advance while an inbound task is recoverable, so a task that failed and is
still retryable makes the provider return the same notifications on every
poll, and filtered notifications are skipped before any dedupe. Each poll
therefore re-journaled the whole batch: with a 2 s poll and 300 filtered
notifications the journal rotated and pushed the accepted/posted rows out of
the page's 200-row window.

Journal at most 20 reason_filter rows per poll and collapse the remainder into
one row carrying detail.filtered, which the page renders as a count in both
languages. The budget is per poll, so a genuinely new batch is still described
in full. Measured against the real adapter: 15 polls served 4,500 notifications
and wrote 315 rows instead of 4,500, without rotating. Against a stub that
honours both `since` and GitHub's read-state semantics, the redelivery stops
once the batch is marked read and a new batch gets a fresh budget.

The design docs record the mechanism precisely, including that an in-flight
turn cannot cause it because the poll loop awaits pollOnce.

* fix(web-shell): keep one Channel event polling loop across visibility restores

Restoring tab visibility while a journal request was still pending started a
second run. Clearing the timer handle cannot cancel an in-flight run, so both
runs settled, both called schedule(), and only the last timer handle was kept —
the orphan kept polling forever. Ordinary tab switches on a slow response
therefore multiplied journal reads permanently: one overlapping hide/show cycle
left two requests per interval, three cycles left four.

Guard the cycle with an in-flight flag so a restore during a pending request
lets that request finish and reschedule instead of starting a second loop. Both
read paths are time-bounded (the SDK's 30 s fetch default and the action
timeout), so the guard cannot stall polling. The regression test holds every
request pending, overlaps three hide/show cycles, and asserts one request per
interval afterwards; removing the guard reproduces the reported four calls.
2026-11-03 13:42:18 +08:00
ae7901552e fix(core): settle the loop reminder delivery at the model-acceptance boundary (#15559)
* fix(core): settle the loop reminder delivery at the model-acceptance boundary

takeLoopReminder() cleared the pending map and logged the 'reminded'
telemetry while the ToolResult request was only being assembled. Between
the take and turn.run(), the send still awaits microcompaction and
memory recall and yields pending goal events, so a consumer that stops
the generator there loses the streak's warning (dropPendingReminders
only unmarks still-pending keys) while telemetry credits a delivery the
model never saw.

The delivery is now only prepared at the take: the client commits it
when the first model-accepted event arrives and abandons it — restoring
the pending entries — in the send's finally when the request never
reached the model. A full reset drops a prepared delivery so a late
abandon cannot leak a stale warning into a new prompt. The agent-core
round loop keeps its take-time accounting with an explicit commit since
it has no consumer-visible suspension between the take and the model
call.

Fixes #15537

* fix(core): defer the agent-core loop reminder commit to model acceptance

* fix(core): drop the ineffective pre-round reminder abandons

The three abandonLoopReminderDelivery() calls at the abort/maxTurns/time
checks could not change anything: each is followed by a break out of the
round loop, the detector is method-local, and abandon only moves entries
between two private maps without logging. They made the take-site comment
and the maxTurns control read as if they were what keeps `reminded`
truthful, so drop them and say what the control actually pins — no round
crosses a model-acceptance boundary, so nothing commits.

Also, from the same review round:

- abandon's doc block named the wrong function. A retry drops the streak
  through resetToolCallCount(), which deletes the three hard-coded keys;
  dropPendingReminders() cannot reach entries already taken out of the
  pending map.
- client.test.ts asserted not.toHaveBeenCalled() immediately after
  mockClear(), which cannot fail. Compare the call count with the value
  captured before the send instead, so the "suspended before turn.run"
  premise is actually pinned.
- cover the multi-entry path: commit credits every entry of a delivery and
  abandon restores all of them, not just the first.
- telemetry.md and both languages of the loop-protection design doc now
  say `reminded` is logged at model acceptance, and the "sent, never
  accepted, credited on a later send" case is added to the enumeration of
  not-counted cases.

---------

Co-authored-by: suzhiguo7 <[email protected]>
Co-authored-by: Shaojin Wen <[email protected]>
2026-11-02 00:43:23 +08:00
Shaojin Wenandverify 6e49388c4a docs(serve): refresh daemon usage guidance (#16056)
Replace obsolete alpha-era deployment and persistence limits with current behavior.
Document authenticated background startup, token sidecar lifecycle, and service-manager choices.

Co-authored-by: verify <verify@local>
2026-11-01 17:01:13 +08:00
6bef5a01c6 feat(web-shell): add "Add to Split View" to session menus (R1 fixes for #14878) (#15956)
* feat(web-shell): add sessions to split view from the sidebar

* fix(web-shell): validate split targets and retain pending additions

* fix(web-shell): report transient split-classification failures per session (#14775)

* fix(web-shell): harden the first split open: race, per-id memo, pending mirror, failure notice (#14775)

* fix(web-shell): disable no-op split adds, clarify the capacity hint, document split (#14775)

* fix(web-shell): fix split classification type errors (#14775)

* test(web-shell): fix split pending-navigation and archived-offer test refs (#14775)

* style(web-shell): prettier-format the split classification memo type (#14775)

* fix(web-shell): adapt controlled pane reveal to prop-derived pane set

The main merge (#14577) derives paneIds from the sessionIds prop in
controlled mode, so the reveal effect could no longer sync local state
(setPaneIds does not exist) and its render-time ref check was dead: the
ref is current by the time the effect runs. Track the previous commit in
an effect-time ref instead. Also restore the test-block closer the merge
dropped from SplitView.test.tsx.

* Fix split-persist e2e spec: assert disabled state for already-open session (bot R2 blocker)

The first smoke test clicked 'Add to Split View' on Session A after it
was already a split pane. The R1-10 gate disables that menu item with
the title 'This session is already open in Split View' (sidebar
splitAlreadyOpen), so Playwright retried a click on a disabled item.
Replace the click with toBeDisabled() + title assertions, reusing the
shape of the six-pane spec in the same file. The no-op guarantees
(pane count, draft, DOM persistence) are kept as-is.

---------

Co-authored-by: luozongmin <[email protected]>
Co-authored-by: Сергей Панченко <[email protected]>
Co-authored-by: Shaojin Wen <[email protected]>
2026-11-01 16:52:54 +08:00
86f1338b86 feat(serve): batch workspace session live-state snapshots (#12513)
* feat(serve): batch workspace session live-state snapshots

* test(serve): include batch live-state capability in integration baseline

* fix(serve): close review gaps in the batch session live-state route (#12513)

Index POST /sessions/live-state in the REST reference's persisted-catalog
row so the docs contract sees the protocol section, give the batch route the
single-workspace route's server-side records (routing-failure warn on
untrusted members, daemonLog error on unexpected bridge failures), make the
mid-batch disconnect guard observable with an AbortController and a
macrotask yield between members, and move the duplicated member-failure,
generation-currency, and bound literals into workspace-route-runtime for
both plural batch routes. Six tests pin the new witnesses.

Co-authored-by: Qwen-Coder <[email protected]>

* fix(serve): align live-state scope docs and pin batch route guards (#12513)

Co-authored-by: Qwen-Coder <[email protected]>

* test(cli): pin grouped REST doc rows against bare SDK names and column shifts

Co-authored-by: Qwen-Coder <[email protected]>

* test(cli): fail closed on grouped REST doc rows and ambiguous SDK qualifiers

Co-authored-by: Qwen-Coder <[email protected]>

* test(serve): pin documented live-state batch bounds against silent drift (#12513)

Co-authored-by: Qwen-Coder <[email protected]>

* test(cli): isolate the suite from the operator's settings home (#12513)

The autofix verification gate runs the packages/cli suite with the host's
real HOME on a machine shared with other jobs. A malformed
$HOME/.qwen/settings.json there (observed content: "X") makes every
operator-settings reader fail closed at once — readOperatorSettingsScopes
is designed to refuse a corrupted policy file — and one such file failed
408 tests across 15 files in the measured gate rejection.

Pin a private empty QWEN_HOME per test-file process in the suite setup
when the environment did not select one, following the file's existing
doctrine for operator-owned environment. Tests that exercise QWEN_HOME
set, stub, or delete it themselves. settings.test.ts keys every
user-settings and .env path off a mocked os.homedir, so it now deletes
an ambient QWEN_HOME explicitly; with any ambient value set, 13 of its
tests fail regardless of this change.

* test(cli): harden the QWEN_HOME suite pin and batch maxima test (#12513)

- Pin on truthiness so an ambient empty QWEN_HOME no longer falls
  through to $HOME/.qwen, matching every production reader.
- Sweep the pinned home from afterAll instead of an 'exit' listener
  that vitest workers never fire (51 leaked dirs on src/config -> 0).
- Restore QWEN_DEBUG_LOG_FILE around loadCliConfig tests so the debug
  logger's fire-and-forget writes stop racing the sweep.
- Witness that the suite never resolves settings from the ambient
  $HOME/.qwen, and assert member shape in the 20-selector maxima half
  so twenty failed members cannot read as twenty snapshots.

* test(cli): keep AppContainer store work off the pinned per-file QWEN_HOME (#12513)

* test(managed-agent): settle fresh group leaders before age-judged sweeps (#12513)

A just-forked process can read in ps with procps's negative-elapsed
wraparound (e.g. 441077234-00:18:40), which parsePsElapsed deliberately
leaves undatable. An undatable leader judges every worker/group identity
'unknown', so an unwitnessed sweep conservatively holds the live process
and rejects unproven — flaking the real-process sweep tests within
milliseconds of the spawn. settledGroupTable now also waits for the
leader's row to be datable, and the four sweep sites that judged a fresh
process without the guard use it.

* fix(cli): keep the QWEN_HOME test pin unconditional and alive for the process lifetime (#12513)

The conditional pin honored an ambient QWEN_HOME, so agent-gate vitest
runs read and wrote the operator's live settings home; the teardown
sweep then deleted the live pin while un-awaited extension-store work
was still reading it, turning Test (ubuntu-latest) red on ENOENT. Pin
unconditionally, keep the pin for the process lifetime, reap stale pins
by age at setup, await the floated extensions-list handler, and add
teardown, pin, reaper, and memory-only-contract witnesses.

Co-authored-by: Qwen-Coder <[email protected]>

* fix(cli): keep the QWEN_HOME teardown witness inside test-setup.ts (#12513)

The previous commit registered the pin teardown witness as a second
setupFiles entry in packages/cli/vitest.config.ts, which deterministic
verification rejects as CI/verification machinery outside the PR
footprint. Revert vitest.config.ts and fold the witness into
test-setup.ts itself: vitest runs root-level afterAll hooks in reverse
registration order, so the hook registered immediately after the pin
runs last among this file's root hooks and still turns every file red
if a teardown sweep of the live pin is reintroduced below it
(mutation-probed). The separate witness setup file is deleted.

Co-authored-by: Qwen-Coder <[email protected]>

Co-authored-by: Qwen-Coder <[email protected]>

* test(integration): raise the installed distribution case ceiling

The tui-parity installed-distribution darwin-x64 leg failed on the
npx-lifecycle-enabled no-relaunch-fallback case with "Test timed out in
120000ms" (run 40537979533, job 122316223603). The five preceding cases
averaged ~117s on that host, right at the ceiling: startup alone can
spend the whole 90s ready-prompt budget there (#15677) and the /about
retry window is another 90s, so 120s cannot contain a healthy case.

Raise the per-case ceiling to 240s and keep the budget helper's note in
sync.

---------

Co-authored-by: 西岐 <[email protected]>
Co-authored-by: qwen-code-dev-bot <[email protected]>
Co-authored-by: qwen-code-ci-bot <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: qwen-code-dev-bot <[email protected]>
Co-authored-by: qwen-code-autofix <[email protected]>
Co-authored-by: qwen-code-autofix <qwen-code-autofix@localhost>
Co-authored-by: qwen-code-autofix[bot] <qwen-code-autofix[bot]@users.noreply.github.com>
Co-authored-by: Qwen Autofix <[email protected]>
2026-10-31 21:40:40 +08:00
Kun De dba7d065ce feat(web-shell): expose archived export and restore inline (#15872)
* feat(web-shell): expose archived export and restore inline

* test(web-shell): use inline restore in standalone E2E
2026-10-31 01:46:35 +08:00
Kun De 0976826090 fix(web-shell): align no-workspace category header with peers (#15897)
* fix(web-shell): align no-workspace category header with peers

* test(web-shell): preserve archived no-workspace header presentation

* docs(web-shell): show sidebar category header comparison
2026-10-30 08:23:06 +00:00
Shaojin Wen a1f567a64f feat(web-shell): extend one-click update coverage and surface manual steps (#15849)
* feat(web-shell): extend one-click update coverage and surface manual steps

* fix(web-shell): keep More menu open for the manual update panel

* fix(web-shell): shape-neutral update guidance and drawer overlay exemptions
2026-10-29 15:21:16 +00:00
64e3aa7a18 fix(web-shell): standardize workspace and session display copy (#15315)
* fix(web-shell): standardize workspace and session display copy

* test(web-shell): sync workspace menu text expectations

* docs(web-shell): clarify the scope of display terminology

* docs(web-shell): align remaining display-label references

* test(web-shell): match the renamed new-session action

---------

Co-authored-by: 大知 <[email protected]>
Co-authored-by: Shaojin Wen <[email protected]>
Co-authored-by: qwen-code-dev-bot <[email protected]>
2026-10-29 09:05:04 +00:00
Jiankai XiaoandJiankai Xiao 3131fcc230 feat(web-shell): allow embedding hosts to pick workspace directories (#15296)
* feat(web-shell): allow host workspace directory pickers

* docs(web-shell): add host directory picker demo screenshots

* fix(web-shell): preserve focus across host picker handoff

---------

Co-authored-by: Jiankai Xiao <[email protected]>
2026-10-29 10:38:04 +08:00
0ecdf0342d fix(serve): revalidate skill snapshots in the background (#14890)
* fix(serve): revalidate skill snapshots in the background

* test(serve): include snapshot metadata in skills route response

* fix(serve): preserve foreground skill observations

* fix(serve): publish valid slow skill observations

* fix(serve): retain skill snapshots when observations fail

* fix(serve): report failed skill catalogs consistently (#14890)

Co-authored-by: Qwen-Coder <[email protected]>

* fix(cli): trace discarded warm skills errors and pin observation age (#14890)

* fix(cli): tighten skill snapshot cache and settings lock scopes

Co-authored-by: Qwen-Coder <[email protected]>

* fix(serve): correct preheat contract and skills cache invalidation (#14890)

* fix(serve): evict invalidated skills enumeration and correct route docs (#14890)

Co-authored-by: Qwen-Coder <[email protected]>

* fix(serve): preserve skill toggle order and retained catalog fallback

Co-authored-by: Qwen-Coder <[email protected]>

* fix(serve): require an enumerated skill catalog for deletion (#14890)

Co-authored-by: Qwen-Coder <[email protected]>

---------

Co-authored-by: claudeethan <[email protected]>
Co-authored-by: Ethan Ye <[email protected]>
Co-authored-by: Shaojin Wen <[email protected]>
Co-authored-by: qwen-code-dev-bot <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: qwen-code-dev-bot <[email protected]>
2026-10-28 05:07:00 +00:00
qqqysandqqqys 9a41afe0d4 feat: add persistent session Workflow mode (DW-04A) (#15422)
* feat(core): persist session Workflow mode and dispatch reminders

* feat(sdk): expose session Workflow mode through native and daemon controls

* feat(cli): add Workflow mode controls and end-to-end coverage

* fix(cli): preserve pasted text when recalling prompt history

* fix(sdk): complete Workflow mode routing and owner-safe replies

* test: verify Workflow mode recording and Mod permission boundaries

* test(sdk): select the secondary workspace ACP mount

* test(cli): assert headless Workflow mode eligibility

* fix(workflow): enforce owner trust for prompt mode writes

* fix(workflow): keep daemon write policy out of MCP metadata

* fix(workflow): preserve retry and restore state across lifecycle failures

* fix(workflow): align ACP retry types with shared loop

* test(workflow): isolate ACP cadence from followup suggestions

* fix(sdk): close query after initialization failure

* fix(cli): serialize workflow mode writes with session switches

* fix(sdk): keep mode errors async and reject pending reinitialization

* fix(cli): filter workflow mode from SSH raw session state

* fix(cli): preserve workflow mode startup failures

* fix(acp): refresh restored session config options

* fix(core): rearm workflow notices after cancellation

* fix(core): rearm workflow notices after failed switches

---------

Co-authored-by: qqqys <[email protected]>
2026-10-28 01:09:37 +08:00
ytahdnand钉萁 6f53133d6d feat(web-shell): stream resumable BTW answers (#15709)
Co-authored-by: 钉萁 <[email protected]>
2026-10-27 12:19:00 +00:00
Shaojin Wen a92b4efe72 feat(web-shell): show /autofix-pr watches and stop them from the Web Shell (#15668)
* feat(web-shell): show /autofix-pr watches and stop them from the Web Shell

The daemon serves the /autofix-pr watches of a workspace's clone, as
`qwen autofix-pr status --no-work` lists them: GET /autofix-pr/watches
for the primary workspace and /workspaces/:workspace/autofix-pr/watches
for a registered, trusted one, advertised as `autofix_pr_watches`. It
finds the clone's main worktree with `git worktree list` and reads the
watches' files only; runStatus takes that root and no session instead of
the daemon's own directory and environment.

The Web Shell shows each watch on the card of its scheduled task and on a
new PR watches page in the sidebar: the PR, where the watch is (watching,
fixing in the background, a fix left behind, not checked recently, the PR
merged or closed, stopped), its pushes, its last check and round, and the
items waiting for a person. For a watch run by a conversation of this Web
Shell, Open session goes there and Stop watch sends `/autofix-pr stop <n>`
to it, as a scheduled task's manual run sends its prompt.

Docs: design Section 6.15 (EN and zh-CN), the user guide, and the serve
protocol.

* fix(web-shell): run no git for the watch listing, and match the gate's stale rule

- The daemon's listing runs no git command but the one that finds the
  clone: runStatus takes `git: false`, which skips the gitfile and
  submodule checks as well as the worktree scan, and the view leaves out
  the worktree path, gitTrusted, worktreeWork and pushTarget. It names the
  clone (`root`).
- State files, gate cursors and fix markers are read without following a
  link and without blocking, judged by what was opened: a FIFO put in
  their place can no longer hold the daemon, or a helper.
- A task is stale as its gate judges it: the watch ended, records no task
  where the task has an id, or records another one, a task with no id
  included.
- The PR watches page reads a locked workspace by its own route, finds a
  watch's task in any workspace it lists, lists a clone's watch once, and
  sends a watch no gate checks to a session here rather than a terminal.
- The rail offers the page only when the daemon lists watches, and the
  view goes back to the chat when it stops doing so.
- Docs follow.

* test(web-shell): drive the PR watches page and a watch's task card in Chromium

Against the mock daemon: the sidebar opens the page, each watch shows
where it is, a watch run elsewhere or by nothing says where to act, and
Stop watch sends /autofix-pr stop <n> to the session that owns it. The
scheduled-task card of the watch shows the same state.

* fix(web-shell): restore a watch's session in its own workspace, and fence the listing

- Open session and Stop watch, and a scheduled task's run and history,
  restore the session in the workspace of the task bound to it: from
  another workspace's conversation the daemon refused the restore. The
  PR watches page finds that task in whichever workspace holds it.
- The watch listing checks its runtime generation before and after it
  asks git: a workspace removed or replaced meanwhile lists nothing and
  answers 503 workspace_runtime_unavailable, on both routes. A failure is
  logged by the daemon.
- A task left behind by a take-over is shown stale on its card, as its
  gate stops it.
- The integration test's baseline capability list names
  autofix_pr_watches.

* fix(web-shell): name the one workspace of a single-project daemon for a watch's session

With one ordinary workspace the PR watches page and the scheduled-tasks
page recorded no workspace for a task, so Open session, Stop watch and a
task's Run now and history, started from a standalone or Live
conversation, restored the session in that context rather than the
workspace. Both pages now name the primary workspace when it is the only
one; its route stays the unqualified one.

* test(web-shell): capture the PR watches page and a watch's task card in the visual preview

No visual scenario answered GET /autofix-pr/watches, so the before/after
preview could not see either surface. The scenario serves three watches
(fixing in the background with an item for a person, run from a
terminal, merged with nothing running it) and the watch's scheduled task,
against the fixed capture clock.
2026-10-27 09:19:50 +08:00
Shaojin Wen eea44208b8 ci: run every suite but CLI and core in the side test lane (#15570)
* ci: run every suite but CLI and core in the side test lane

The Linux test job's main lane ran the CLI suite and then, one after
another, core, about twenty smaller workspace suites and scripts/tests,
while the side lane (web-shell, then the standalone regression) went idle
long before the CLI suite finished. The main lane now runs only the CLI
and core suites; the side lane runs every other workspace's test:ci, the
standalone regression and scripts/tests, each at half the Vitest worker
cap. Every side-lane suite runs even after another fails.

* ci: print the side lane of a test step killed before its replay

The lane script replays the side lane's log only after the main lane
ends, so a step timeout or cancel lost every suite but the CLI and core
ones. The script now writes that log to SIDE_LANE_LOG when the caller
names one; ci.yml names it under runner.temp, outside the TMPDIR the step
removes, and a failure()/cancelled() step prints whatever is left of it.

The lane test also checks that the main lane's output is present before
it checks its order against the replay, and a comment that still placed
scripts/tests in the main lane now names the side lane.

* ci: keep the lane script's log out of a nested run

scripts/tests runs `npm run test:scripts:unit`, which runs the lane script
again, inside the step that names SIDE_LANE_LOG. The nested run inherited
that name and truncated, replayed and removed the outer side lane's log, so
the outer replay had nothing to print. The script now unsets the variable
once it has read it, and the lane test drives a nested run with a named log
to pin that: the nested run must not see the caller's log, and the outer
side lane's own output must still reach its replay.

The print-step test also pins the `if [ -f "${side_log}" ]` guard around the
cat, which is what keeps that step quiet on an ordinary red test job.
2026-10-27 00:01:58 +00:00
易良 7b69fccd74 feat(tools): let an explicitly configured search backend name its host (#15638)
`classifyDashScopeBaseUrl` accepted a fixed list of DashScope-family
suffixes, so an organization that routes model traffic through its own
gateway had no configuration-only way to point the built-in `web_search`
backend at that gateway: the tool stayed off with an `unknown-host`
notice, and the alternatives were patching the source or dropping the
built-in tool for an MCP search provider (#15299).

Add `tools.webSearch.allowedHosts` / `WEB_SEARCH_ALLOWED_HOSTS`: extra
suffixes matched exactly like the built-in ones (exact host, or any
subdomain of a listed suffix). Empty by default, so behaviour is
unchanged for anyone who does not set it, and the `https:` requirement
still runs first because the side request carries a bearer key.

The list reaches only the two places where the user named the backend:
the `WEB_SEARCH_BASE_URL` path and the `modelProviders` path. Automatic
derivation keeps the built-in list in both of its host tests — the entry
usability check and the auto-eligible host check — so a host nobody
declared is never adopted implicitly and never receives the main
provider's key.

Entries are trimmed, lower-cased, de-duplicated, and entries without a
dot are ignored, so a typo narrows the list instead of widening it (a
bare `com` would otherwise accept every `.com` host). Both rejection
notices now name the setting, which is what a user hitting the gate
needs.
2026-10-26 18:52:12 +08:00
Edenmanand丁炳智 98312b31cc feat(daemon): add cached workspace artifact queries and batch removal (#15594)
* feat(daemon): add cached workspace artifact queries and batch removal

* fix(daemon): Share artifact cache with single-session queries

* feat(daemon): Add artifact library session metadata and streaming

---------

Co-authored-by: 丁炳智 <[email protected]>
2026-10-26 15:41:39 +08:00
Shaojin Wen ad8a542013 feat(web-shell): manage extra workspace directories from settings (#15393)
* feat(web-shell): manage extra workspace directories from settings

The CLI's `/directory add` extends a session's workspace context with extra
directories, persists them at workspace scope, and reloads memory. Web Shell
had no equivalent: the settings panel never rendered
`context.includeDirectories`, and no daemon route exposed it.

Add a workspace-scoped REST surface backed by a new ACP control method. The
handler validates the path, applies the delta to the workspace config and every
live session, persists `context.includeDirectories`, refreshes hierarchical
memory when configured, and notifies the model. The Web Shell exposes the list
under Settings -> Workspace and reuses the existing directory dialog for path
input, autocomplete, and the native picker.

The route is trust-gated. SSH, managed, standalone, and experimental
paired-engine daemons do not advertise or serve it. The primary directory
cannot be removed; a session that cd'd into an extra directory is skipped with
a warning, and a directory that disappeared from disk can still be removed from
the list.

* fix(ci): align capability baselines and keep settings keys out of the transcript bundle

`workspace_directories` is a new registered and conditional capability, so the
ordered baseline lists in the unit and integration suites need it at the
registry position, and the conditional drift test needs a predicate branch.

The Web Shell category label no longer uses a `settings.category.*` i18n key.
The transcript build shares the i18n dictionary and deliberately carries no
`settings.` literals; the section title already exists as a non-settings key.

* fix(cli): keep workspace directory edits consistent with persisted settings

Three review findings on the workspace-directory mutation:

- Re-read the workspace settings layer before deriving the next list. The ACP
  child's LoadedSettings snapshot can lag a write made by another owner, so an
  add or remove could overwrite or miss entries already saved on disk.
- Persist with `throwOnWriteFailure` and before touching live roots. A refused
  write now fails the mutation instead of reporting `changed: true` against an
  unchanged file.
- Resolve entries the way runtime configuration does (`~`, relative paths
  against the workspace root, then realpath) for display and matching, while
  persisting the original spelling. A relative entry such as `../first` is now
  removable from the UI, and removal still matches the persisted entry.

* fix(cli): resolve workspace directory entries like runtime settings

The second review round found two remaining P2s and one test-contract gap:

- Read, match, and remove entries from the settings layer with environment
  variables resolved (`workspace.settings`), so a `$VAR` entry is listed as the
  path sessions use and removal clears that path from live contexts. Persist
  the original spelling from `originalSettings`.
- Refuse adding the workspace's primary directory (`primary_directory`); it is
  always part of the workspace and previously persisted as an extra entry that
  DELETE then refused as `initial_directory`.
- Accept relative and `~` removal paths in the handler, not only in the route,
  and test the downstream handler with the relative spelling. The route test's
  bridge mock had masked that the handler still required an absolute path.

* test(cli): canonicalize temp roots in workspace directory tests

The route and handler fixtures composed expected paths from the raw
`os.tmpdir()` spelling. On macOS that is `/var/...` while the implementation
resolves entries through `realpath`, which returns `/private/var/...`, so the
three new cells failed only on macOS.

Realpath the temp root once at setup in both files, so fixture paths and the
implementation's resolved output share the same spelling. No production code
changes.

* fix(cli): list workspace directories through the selected runtime environment

A directory variable defined only in the workspace's `.env` reaches the ACP
child and its settings but not the daemon process. The GET route read settings
with `skipLoadEnvironment`, so it interpolated against the daemon's environment
and listed the unresolved placeholder while live sessions used the real
directory; deleting that displayed path answered 200 with `changed: false`.

Read the workspace scope through a settings snapshot built from the selected
runtime's environment (and its trust), matching what the mutation handler and
its sessions resolve. Cover a variable that exists only in the runtime overlay.

* fix(cli): drop the daemon-environment fallback from the directories list

The process.env guard in `packages/cli/src/serve/**` only permits registered
accesses. Reading `process.env` as the parent-process fallback for the
directories list tripped it and failed the Test lane.

Only a runtime overlay carries a workspace `.env`, so the overlay environment
is the whole source: a runtime without one resolves the persisted list against
an empty environment, which keeps placeholders rather than silently borrowing
the daemon's process-wide variables.

* fix(web-shell): close the four Critical review findings

- SSH detection used a `ssh://` cwd prefix, but registration rewrites an SSH
  workspace to a local mirror directory before it is served, so the gate never
  fired and the section rendered against a boundary that answers 501. The route
  now tests `routeFileSystemFactory.sshWorkspace` (as `capabilities.ts` does)
  and the section tests the workspace entry's `ssh` marker.
- The route mapped every unclassified bridge failure to a flat 500. The first
  mutation on a freshly opened workspace spawns a control channel, and that
  spawn times out or is refused while the child starts; those carry none of the
  feature's error kinds. The shared mapper is now the fallback, so a retryable
  state keeps its 503/504 + Retry-After semantics.
- The section did not serialize mutations: Add was gated only on the initial
  load, so an add could race a pending remove over the same settings
  read-modify-write and silently drop one change. One gate now covers both
  directions, with a ref so it holds before the next render.
- Browse rendered unconditionally, dropping the `native_directory_picker` gate
  the same dialog gets in App; a headless daemon serves this section but answers
  the picker with 501. Browse now follows the capability.

Tests pin each: the SSH route case uses the production shape (local mirror cwd +
runtime marker), the section case uses the capability marker, the fallback case
runs the real shared mapper, and a deferred remove asserts Add is disabled.

* fix(ci): type the shared-mapper mock and split the home-root trust gate

The prior commit typed the test harness's `sendBridgeError` option as a loose
`(res: unknown, ...)` function, which `tsc --build` rejects when it is handed
the real `SendBridgeError`: the whole `prepare` build failed, so every CI job
that installs dependencies went red. Type the option as `SendBridgeError`
instead.

Also separate two states the handler conflated: `workspaceSettingsActive` is
false both for an untrusted workspace and for a trusted workspace rooted at
`$HOME`, and only the first is a trust failure. Trust is now read from
`config.isTrustedFolder()`; a home-rooted workspace answers
`directory_management_unsupported` rather than an `untrusted_workspace` code no
grant can clear. `makeConfig` gains the `isTrustedFolder` the handler now calls.

The section seeds the dialog with the workspace root so browse mode lists
folders on open, and its placeholder no longer advertises an `ssh://` spelling
this route rejects.

* fix(web-shell): gate the settings category on the SSH marker in App

The Settings root still decided whether to supply the Workspace directories
section from `workspaceCwd.startsWith('ssh://')`. Registration rewrites an SSH
workspace to a local mirror directory, so that test never fires: for a
registered SSH workspace App passed a truthy element, SettingsMessage created
the category, and the child section then rendered nothing — a visible, selected
category with empty content and no list request.

Decide it from the selected workspace entry's `ssh` marker, the same signal the
section uses, so App passes `undefined` and the category is omitted.

* fix(cli): pair workspace directory entries by tail, not by index

`workspaceFile.settings` is the merged layer, and `context.includeDirectories`
merges with `MergeStrategy.CONCAT`, so a user-scope value lands ahead of the
workspace entries. The handler paired that merged array with the
workspace-scope array by index, so once a user scope also set the key, a
workspace entry resolved to an unrelated user path: the panel showed the wrong
directory and removal left the real root live in every session.

The workspace entries sit at the END of the merged array, so take that tail
instead. Each workspace entry then resolves to itself, and a `${VAR}` entry
still matches the path sessions use.

The regression case seeds a user-scope prefix ahead of one workspace entry and
asserts the removal clears it from both live contexts; index pairing fails it.
2026-10-25 16:59:44 +00:00
Shaojin Wenandverify 3eff5f7793 feat(web-shell): create workspaces from a Git URL (#15361)
* feat(web-shell): create workspaces from a Git URL

The Add-workspace dialog could only register an existing directory or
create an empty scratch directory, so starting from a repository meant
leaving the browser, cloning on the daemon host, and registering the
path afterwards.

Add a Git source that starts an asynchronous clone job, reports progress,
and supports cancellation. A successful clone is registered through the
existing workspace route, so capacity, nesting, persistence, and the
daemon's trust policy stay in one place.

* fix(cli): classify git_workspace_clone in the capability parity matrix

The parity matrix landed on main after this branch, and every capability
tag needs a row. Managed workspace creation only creates empty
directories, so record the clone surface as an explicit Managed gap
rather than claiming parity.

* fix(cli): reserve active clone targets before overlapping work

An outer clone's failure/cancel cleanup removes its target recursively,
so a nested clone or workspace registration must not start underneath a
running job. Reserve the canonical target for the job lifetime, reject
overlapping clone and registration paths, and release the reservation
only after cleanup settles.

Also resolve the success path before publishing `succeeded`, so a reader
never observes a terminal snapshot without the `cwd` it registers.

* fix(web-shell): keep clone polling recoverable

A failed poll left the last known status running, so the panel offered
Cancel instead of the recheck that can recover the existing job. Prefer
the unknown-state action.

Reset the unmounted guard in the effect setup: StrictMode's development
replay otherwise leaves it set and discards every later poll, including a
successful clone.

* feat(web-shell): reuse the directory path as the clone parent

When the source switches to Git, treat the directory path as the clone
parent and append the repository name from the URL. A path whose basename
already matches stays as-is, and a manually edited or browsed target is
never overwritten by later URL edits.

* feat(web-shell): suggest folders for the clone target

Headless daemons do not provide a native directory picker, so the clone
target had to be typed in full. Reuse the directory suggestion list:
selecting an existing folder uses it as the parent and appends the
repository name, while a manually edited target still wins.

* fix(cli): keep clone progress monotonic

git restarts the percentage for some clone phases (remote counting,
then receiving), so the Web Shell progress bar could jump backwards.
Clamp the reported percent to the running maximum; the status message
still follows the current git line.

Also pin the clone admission guards left unpinned by review: reserved
roots, unknown request keys, promotion of a workspace under an active
clone target, file URLs that name a host, percentages above 100, and
the dialog fallback when the daemon capability disappears.

* fix(cli): reserve the clone target before claiming it

The claim created the target with mkdir, then awaited the existence check
and the final realpath, and only reserved the canonical path afterwards.
A concurrent ordinary registration could admit the freshly created
directory in that window and then lose it to the clone's cancel/failure
cleanup.

Reserve the canonical target inside claimCloneTarget before mkdir, share
the reservation with registration, and release it on every claim failure
and on post-claim admission failures. Add a regression test that overlaps
the registration with the blocked claim and a unit test that asserts the
reservation exists before the directory does.

Also update the serve integration capability envelope for the
host-conditional git_workspace_clone tag, mirroring the daemon's probe.

* fix(cli): register git_workspace_clone in serve capability guards

Test (ubuntu) failed five capability-registry guards because the new
git_workspace_clone tag was not registered alongside the existing
conditional features:

- document it in the qwen serve protocol table;
- allow the clone spawner's process-environment copy and SystemRoot
  lookup in the serve process.env guard;
- add it to EXPECTED_REGISTERED_FEATURES and the conditional-feature
  drift-insurance branch.

Verified: capabilities-docs-contract, process-env-guard and server
suite 1399/1399; typecheck, ESLint, Prettier clean.

* fix(cli): keep clone targets away from reserved and Live paths

A clone target that contains the reserved Conversations root was accepted
when that root did not exist yet: creating the target made the root's
parent part of the clone, and the Live runtime could then be published
inside it. Because the Live exemption skipped the "canonical inside
target" direction for active clone targets, cancelling or failing the
clone deleted the registered Conversations workspace.

Reject clone targets that contain a reserved root, even before the root
exists, and apply strict two-way nesting to active clone targets in the
owned-runtime admission regardless of provenance.

Verified: workspace-git-clone + workspace-management 196/196;
typecheck, ESLint, Prettier clean.

* fix(cli): resolve reserved roots through existing ancestors

The reserved-root containment check fell back to the unresolved spelling
when the Conversations root did not exist yet. A symlinked ancestor such
as ~/Documents then made the canonical clone target and the lexical
reserved path disagree: the clone was accepted, the Live runtime created
the root inside it, and cancelling the clone left ConversationWorkspace
with a cached identity for a directory that no longer existed.

Resolve each path through its nearest existing ancestor and append the
missing suffix before comparing, so a clone target containing a missing
reserved root is rejected before mkdir even when ancestors are symlinks.

Verified: workspace-git-clone + workspace-management 197/197;
typecheck, ESLint, Prettier clean.

* fix(cli): cover case aliases and dangling links in reserved roots

The reserved-root guard compared the reconstructed missing suffix with
case-sensitive isWithinRoot, so a case-variant ancestor such as
`Documents/qwen code` did not match `Documents/Qwen Code/Conversations`
on a case-insensitive filesystem. It also walked past a dangling symlink
ancestor, so a clone target that was the link's missing destination
could later make the link valid inside the clone's cleanup scope.

Compare canonical paths case-insensitively after resolving through the
nearest existing ancestor, and follow dangling symlinks while
reconstructing the missing suffix. Add regressions for both aliases.

Verified: workspace-git-clone + workspace-management 199/199;
typecheck, ESLint, Prettier clean.

* fix(cli): harden git clone credentials, config, and admission

- Strip URL credentials before git's argv and supply them through a
  command-line credential helper backed by the child environment, so the
  secret no longer appears in /proc/<pid>/cmdline or .git/config.
- Disable global/system git config for the clone child so a repository's
  .gitattributes cannot run host-configured filter commands during checkout.
- Keep the first fatal/error stderr line for clone failures instead of the
  trailing SSH boilerplate.
- Only treat Receiving objects/Resolving deltas as progress phases, so
  remote counting cannot pin the bar at 100.
- Strip OSC/C1 and all other control characters from clone messages; add
  the missing Windows taskkill 'error' listener.
- Require operator authority for clone cancellation; reject clone targets
  inside the managed scratch root or equal to a registered/in-flight
  workspace; keep case-insensitive containment scoped to clone targets.

Verified: workspace-git-clone + workspace-management 205/205;
typecheck, ESLint, Prettier clean.

* fix(web-shell): gate the clone flow during submit and IME input

- Disable the post-clone Back button while registration is in flight and
  guard backToForm, so it cannot reset a submitting flag owned by onAdd.
- Ignore clone-target key handling while an IME composition is active.
- Dismiss the clone-target suggestion list when the git form is submitted,
  so its Escape interceptor cannot stay armed over the progress view.

Verified: AddWorkspaceDialog 57/57; Prettier and ESLint clean.

* docs: align git clone guarantees with credential and config isolation

- Document the `{ kind: "git" }` form and its 202 job response in the
  `POST /workspaces` reference.
- State that URL credentials are moved out of git argv and the stored remote
  URL, and that global/system git config is disabled for the clone child so
  committed filters cannot run host commands during checkout.
- Mirror both changes in the Chinese design document.

* fix(cli): use bracket access for clone credential env

The strict index signature on NodeJS.ProcessEnv rejects dot access to
QWEN_GIT_USERNAME/QWEN_GIT_PASSWORD; all CI jobs that build the CLI
failed at tsc with TS4111 before running any test.

* fix(web-shell): cancel the running clone from the left action

- Route the progress panel's left Cancel through cancelClone while the job
  is running, so it stops the daemon job instead of only closing the dialog.
- Clear the pending poll timer at pollCloneJob entry and before cancel, and
  guard registration per job id, so a terminal snapshot cannot register the
  same clone twice.

Verified: AddWorkspaceDialog 57/57; Prettier and ESLint clean.

* fix(web-shell): harden git clone env and clone-job dialog states

Pin LC_ALL/LANG for the clone child and sweep host Git config channels (GIT_CONFIG_*, GIT_ASKPASS, GIT_SSH*, GIT_PROXY_COMMAND) so a localized git still reports parseable progress and a browser-chosen URL cannot borrow host credentials. Decode URL credentials defensively, reject control characters, and print the inline credential helper with printf so dash does not mangle tokens.

In the add-workspace dialog, tear the clone state down when the capability disappears, report a pruned clone job instead of offering recheck forever, honour a cancel that loses the race with a finished clone, and close instead of cancelling an unconfirmed job. Correct the host-authentication prose in the design docs, README and URL hint to match what the isolated clone child actually supports.

* fix(cli): resolve relative symlinks physically and keep cancel intent

- Resolve a relative symlink target from its physical parent before appending
  the missing suffix, so a dangling relative Documents link cannot hide the
  reserved Conversations root from clone admission.
- Keep the per-job cancellation intent across overlapping poll responses, so
  a second succeeded poll cannot auto-register after Cancel. Clear it only on
  an explicit Back/new submission.

Verified: route + clone 209/209; AddWorkspaceDialog 61/61; typecheck with a
6 GB heap; Prettier and ESLint clean.

---------

Co-authored-by: verify <verify@local>
2026-10-25 10:16:38 +00:00
Shaojin Wen 1df5031e70 perf(ci): overlap web-shell and standalone tests with the CLI suite (#15438)
* perf(ci): overlap web-shell and standalone tests with the CLI suite

The Linux Test job ran every workspace suite, the standalone runtime
regression and scripts/tests one after another. The CLI suite keeps its
workers busy for 13-20 minutes, while web-shell's suite spends most of its
run on App.test.tsx alone and the standalone regression starts one
runtime process at a time, so those two added their mostly idle minutes
straight to the job.

Run them in a side lane beside the other workspaces instead.
.github/scripts/ci/run-unit-test-lanes.sh starts web-shell and the
standalone regression in the background, runs every other workspace's
test:ci (listed with npm pkg get, in npm's workspace order) and then
scripts/tests, and replays the side lane's log after the main lane so
the two never interleave. The step runs the script under the same
setpriv wrapper, so every test command still runs without the DAC
override capabilities.

test:scripts now reaches its Vitest run through a new test:scripts:unit
script with a trailing --, so the main lane can run it without the
standalone regression while test:ci, test:ci:workspaces and test:scripts
run what they ran before.

* fix(ci): name the cause when the lane script cannot start a lane

- Fail with an ::error:: line when mktemp cannot create the side lane's
  log file, instead of running the main lane alone and dying in tail.
- Check the exit status of `npm pkg get` and of the parser. A failing
  `npm pkg get --json` prints an error object, which used to parse as an
  empty listing and report "Found no workspaces".
- Note in the header that the replay puts side-lane failures after the
  main lane's in main-failure-signature.mjs's title and search markers.
- Lane test: clear the caller's NODE_OPTIONS, assert the main lane's
  output is present before checking its order, and cover the failed
  listing and the missing temp dir.
- Cross-check npm's workspace resolution against the selector in
  scripts/workspaces.js.

* fix(ci): halve web-shell's worker cap beside the main lane

Each Vitest run applies VITEST_MAX_FORKS / VITEST_MAX_THREADS on its own,
so running two lanes at once doubled the step's worker cap, and on the
hosted runners, where no cap is set, put two all-CPUs pools on 4 vCPU.
The lane script now runs web-shell at half the cap, at least 1, and at 2
where the cap is unset; the main lane keeps the full cap.

- Refuse a workspace name with whitespace in it: the main lane's
  arguments are read one per line, so a line break would split a name
  into other arguments, flags among them.
- Lane test: web-shell and the main lane's suites now wait for each
  other, so a side lane serialized before or after the main lane fails;
  record each call's worker caps; run the script against npm's real
  listing and compare its main lane with the selector the release pins
  use; cover a name with a line break in it.
- ci.yml: the standalone runtime cache comment names the lane script's
  side lane, not test:scripts, as what runs the regression in this job.
- ci-variables.md: say the cap applies per Vitest run and what the
  two-lane step runs at its peak.
- release-workflow.test.js: follow test:scripts through
  test:scripts:unit to vitest; #15102 pinned the shape before the split.
2026-10-25 10:05:56 +00:00