100 Commits
Author SHA1 Message Date
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
Shaojin Wen 0b54b9be99 fix(managed-agent): restore two runtime-broker classes to their size ratchet (#16542)
ClassSizeRatchetTest fails on main itself: RuntimeBrokerService is 2947 lines
against a 2943 ceiling and RuntimeBindingProvisioning is 1327 against 1326.
Both overruns come from #16535 replacing inline fully qualified names with
imports, which adds import lines while the usage lines keep their count — 4
imports in RuntimeBrokerService and 1 in RuntimeBindingProvisioning — without
updating the ceilings that #16501 had just recorded after the extraction.
The SDK Java lane runs the merge ref, so every PR that merges main inherits
the failure.

The ratchet only lets ceilings go down, so reclaim the lines instead of
raising the pins: join expressions that were already split across two lines.
RuntimeBindingProvisioning's single Objects.equals operand, and the four
BrokerValues.requireId assignments in release() and releaseMount(). Every
joined line is at most 88 characters; the diff is formatting only and keeps
#16535's import style.

ClassSizeRatchetTest red before / green after; RuntimeBrokerServiceTest 199
green; runtime-broker checkstyle and spotbugs clean. No pin was edited.
2026-11-07 22:24:43 +08:00
Shaojin WenandQwen-Coder cf29f90062 fix(ci): route the review/resolve permission gate to the ECS pool (#16482)
* fix(ci): route the review/resolve permission gate to the ECS pool

The authorize job gates every @qwen-code /review and /resolve command, but its
ECS routing required github.event.pull_request, which issue_comment and
workflow_dispatch payloads do not carry. Every command therefore fell through
to ubuntu-latest, and the gate sat in the hosted queue for hours (PR #15286:
3h10m for a 4-second job) while the ECS pool was idle.

Route issue_comment and workflow_dispatch to the persistent ECS pool, matching
qwen-autofix.yml's route/review-scan pattern for PAT-bearing API-only steps.
authorize runs no agent, checks out nothing, and processes no PR content, so
this does not widen the fork attack surface. PR-payload events keep the
same-repo fork guard; the hosted fallback and ECS kill switch are unchanged.

resolve-pr and publish-resolution stay on hosted by design (ephemerality); this
removes the gate wait only.

* fix(ci): harden the authorize ECS-routing pins and risk docs

Address review feedback on the gate routing change:
- Pin the checkout-free invariant the ECS routing depends on: a uses: step on
  authorize would execute contributor-authored files on the persistent pool
  with CI_BOT_PAT in the step env while the expression pin stayed green (R1-1).
  Scoped to authorize; mutation-witnessed (adding actions/checkout reds it).
- Record the trigger-set widening in the design-doc Risks (both languages):
  issue_comment makes the gate fireable by any GitHub user on a fork PR, and
  the should_review authorization fact is now produced on the shared host and
  consumed downstream without re-deriving (R1-2).
- Correct the precedent wording: route loads only the read-only github.token;
  review-scan is the job that loads CI_DEV_BOT_PAT on the pool.

* fix(ci): harden the shared-pool authorize gate against gh config reroute

Address the round-2 CHANGES_REQUESTED review (R2-1 Critical, R2-2):

R2-1: routing the gate to the persistent ecs-qwen pool moved its CI_BOT_PAT
gh api call onto a host with a shared, writable ~/.config/gh. A co-resident
survivor could plant http_unix_socket there, receive the PAT, and forge the
permission response downstream consumes as should_review. Prepend the same
pins as qwen-autofix.yml's route/review-scan — export GH_HOST=github.com plus
a private, guarded GH_CONFIG_DIR (a failed mktemp exits 1; the gate fails
closed). No `unset GH_TOKEN`: it is this step's only credential. Witnessed
with real gh 2.103.0: the as-written step leaks the PAT to a planted socket
and approves a stranger; the hardened step denies with the socket untouched.

R2-2: extend the checkout-free guard to the run: half — pin the step
inventory to the single permission step and forbid `${{` spliced into any run
body (event values must arrive via env:). Mutation-witnessed: removing the
preamble and adding a run-only second step each red the test.

Also correct the design-doc parity claim (both languages): the exposure
matches review-scan only WITH these gh pins; document the hardening.

* fix(ci): extend the ECS gate routing to triage and harden the gate pins

Two batches, verified together before push:

1. Extend the command-gate routing to qwen-triage.yml's authorize (the
   /triage, /tmux, /verify sibling blind spot): the same issue_comment /
   workflow_dispatch disjuncts, and — because that job has three steps that
   spend CI_BOT_PAT on gh — the same self-contained GH_HOST + guarded
   GH_CONFIG_DIR preamble on each, per the repo's per-step convention. Raise
   the recorded workflow size baseline to match the added hardening.

2. Address round-3 review (R2-2 fix-induced, R3-1): the gate pins tested
   substring presence only. Now evaluate the comment-stripped live lines and
   pin the guarded mktemp form + fail-closed shape, require GH_HOST /
   GH_CONFIG_DIR exported before the first gh api, and forbid executing
   repository code via relative paths or interpreters (the pool cwd is the
   reused $GITHUB_WORKSPACE). Applied to both the review and triage gate
   tests. Mutation-witnessed: an unguarded one-liner, a relocated preamble,
   and an appended `node ./…` line each red the tests.

Tests: qwen-pr-review-workflow.test.js 271, qwen-triage-workflow.test.mjs 131,
ci-runner-routing.test.mjs 103 — all pass; prettier + ESLint clean.

* fix(ci): supply RUNNER_TEMP to the triage gate replay tests; harden the gate pins

R4-1 (Critical): the new ${RUNNER_TEMP} gh-config preamble is the first
RUNNER_TEMP reference in the triage permission step, and three replay
harnesses in scripts/tests/qwen-triage-workflow.test.js execute that body
verbatim without setting RUNNER_TEMP — off-CI (npm run test:scripts) the
fail-closed guard exits 1 before writing should_run. Set RUNNER_TEMP to each
harness's existing mkdtempSync dir (the :1773 precedent); the workflow's
fail-closed guard is unchanged. Verified: the file is red under
`env -u RUNNER_TEMP` before and 203/203 green after; dropping the line reds it
again. (The review named two harnesses; running the full file surfaced a third.)

Also clear the round-4 deferred notes:
- R4-2: the /verify ack comment no longer promises a receipt "even when the
  sandbox lane is backed up" — the gate now also queues with the pool.
- R4-3/R4-4: the triage routing pin is now an exact expression equality, the
  step inventory is pinned, and PAT-bearing steps are identified by env
  GH_TOKEN rather than gh subcommand spelling.
- R3-1: tighten the no-repository-code deny-list (quote boundaries +
  interpreter as a word) so `bash -c './x.sh'` no longer slips past;
  mutation-witnessed in both the review and triage gate tests.

Tests: triage vitest 203 (off-CI), triage .mjs 131, review vitest 271,
ci-runner-routing 103 — all pass; prettier + ESLint clean; size gate green.

* fix(ci): correct the gate-routing narrative; close the round-5 verify gaps

R5-1 / F1 (narrative): the motivating incident is a /resolve, whose
end-to-end latency this PR does not change — resolve-pr stays on
ubuntu-latest, so only the gate wait is removed. State this in both
design-doc languages and the pinning test comment; the saving lands on
/review, /triage, /tmux, /verify (pool-routed downstream). Correct the
"cancelled without ever running" sentence to the run's timeline (resolve-pr
started when the gate finished and was cancelled ~29m36s later).

S1: the no-repository-code deny-list missed absolute-path interpreters
(/usr/bin/node x.js) and versioned Python (python3.11 x.py); widen the regex
(optional path prefix + python[0-9.]*) in both gate tests. Mutation-witnessed:
both previously-surviving mutants now red, clean tree green.

S2: each hardened gh step minted a $RUNNER_TEMP/*gh-config.* dir that nothing
removed; add trap 'rm -rf "$GH_CONFIG_DIR"' EXIT to all four steps (RUNNER_TEMP
survives between jobs on the pool).

S3: record the exact final workflow sizes in .size-baseline.

Tests: triage vitest 203 (off-CI, RUNNER_TEMP unset), review vitest 271,
triage .mjs 131, ci-runner-routing 103 — all pass; prettier + ESLint clean;
size gate green.

* fix(ci): pin the no-repository-code invariant on every triage gate step

The triage gate meta-test applied the relative-path/interpreter denylist only to PAT-bearing steps, so a repository-code execution line added to the credential-free budget step survived the suite (witnessed by mutation). The no-repository-code invariant is a job-wide safety premise of the ECS routing: even a credential-free step that runs a planted file leaves a survivor on the persistent host. Pin it on every step's comment-stripped live lines, and align both design-doc versions with the widened coverage and the witnessed mutation set.

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

* docs(ci): correct the routing-matrix coverage claim for the triage gate

The validation plan said the triage gate is pinned by the triage meta-test AND the ci-runner-routing matrix, but the matrix never reads either authorize job (it pins the sibling pool jobs). State the actual coverage in both language versions.

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

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-11-07 13:47:42 +00:00
Shaojin WenandQwen-Coder 47d41dbf71 fix(channels): keep the cursor write alive when the temp file mode cannot be tightened (#16257)
* fix(channels): keep the cursor write alive when the temp file mode cannot be tightened

Mounts without Unix permissions (SMB/CIFS, exFAT, some FUSE) accept the
write but refuse chmod, so the unguarded temp-file chmod made every save
throw before the rename and the cursor was never persisted. Guard it like
the directory chmod and pin the behaviour with an EPERM test.

Also seed the scope directory at 0o755 in the permissions test so it
proves the tightening, and pin that an empty scoped cursor starts fresh
instead of falling back to the legacy global file.

* test(channels): assert the injected chmod fault fired in the cursor guard test

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

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-11-07 13:46:41 +00:00
Shaojin Wen 4c8ebeab4d feat(core): view one pull request by URL for the PR-state watch (#16530)
* feat(core): view one pull request by URL for the PR-state watch

Add fetchGitHubPullRequestView(url, cwd, env?), the single-PR view the
daemon's PR-state watch will use (step two of PR A in
docs/design/2026-11-04-daemon-pr-state-transitions.md, section 1). It
runs `gh pr view <url> --json url,title,headRefOid,mergeable,state,
statusCheckRollup` and returns the canonical URL, title, head SHA,
open/merged/closed, mergeable (absent while GitHub reports UNKNOWN), the
CI state and the failing check names (`<workflow> / <job>`, the check
name or the status context; once each, at most 20).

Only an http(s) /owner/repo/pull/<n> URL is accepted, and it is rebuilt
without credentials, query, fragment or tab path before it reaches gh,
so an issue URL, /PULL/7 or /pull/0 never becomes a branch lookup. The
working directory must exist, so a missing one is not reported as gh
being unavailable. Failures carry a reason (not_a_pr_url, cwd_missing,
gh_failed, timed_out, unparsable). There is no host check; the caller
restricts URLs to the allowed GitHub hosts.

aggregateChecks is exported with unchanged behaviour; its failing-entry
predicate is shared with the failing-name extraction. Every rollup entry
still counts: gh's JSON exports no event, app or workflow id, and a
dedupe by names and times would let a skipped or other-trigger run hide
a current failure.

The design doc's Files-affected row for core names the real signature
and fields.

* fix(core): report gh's diagnostic, not Node's argv, when gh fails

execFile's callback error has no stderr property (only the promisified
form attaches it), so ghErrorMessage fell back to Node's "Command failed:
<argv>" message: a failed PR list, view or create reported the gh command
line (for create, the PR title and body) ahead of gh's diagnostic, which
the 4,096-character raw cap and the route's 512-character display cap
could then cut off. The three callback wrappers now attach the
callback's stderr, as the PR issues wrapper already does.

When gh exits or is signalled with nothing on stderr, the message names
the exit code or the signal instead of falling back to the argv, and a
failure Node raises itself (a string code such as a spawn error or an
overflowing buffer) keeps Node's message even if gh wrote something.
`gh api graphql` failures of the PR issues fetch, which share the
helper, get the same treatment.

The test helper now fails the way execFile does: an exit 1 unless told
otherwise, with stderr only as the callback's third argument, so the
stderr tests exercise the wrappers and the stderr-over-exit-code order
rather than the mock. Tests cover a silent exit, a signal, a buffer
overflow with stderr, and a silent create that must not echo the title
and body; the view's failing-names test gains a failing check run with
an empty name and a status context with an empty context, which fail the
head but are not named.
2026-11-07 21:33:43 +08:00
Shaojin Wen c1aa266e82 perf(core): compare a grown local journal prefix in chunks (#16507)
- Re-read a file that grew during the read through one 64 KiB chunk
  instead of a second buffer of the whole size, so an unbounded read of a
  transcript being appended to holds one copy of it.
- Name the same-length rewrite within one timestamp tick as a cost
  decision in the comment.
- Inject the read races through one helper, and cover a same-length
  rewrite with moved timestamps, a bound below the file size, a shrink that
  stays above the bound, a short comparison read and a comparison over
  several chunks. Drop two cases that duplicated others.
2026-11-07 20:56:23 +08:00
Shaojin Wen 0807f5d391 refactor(channels): share the bound-session prompt contract via channel-base (#16523)
* refactor(channels): share the bound-session prompt contract via channel-base

Move the GitHub channel's operator instruction template contract (key
validation against a caller-supplied event vocabulary, template
validation, the role/event/lane resolution chain, the %var% renderer)
and the bound prompt envelope (platform header, untrusted-data and
not-published notices, labelled trusted-instruction block, sanitised
data lines, Role line, visible 6,000-code-point body cap) into
@qwen-code/channel-base, so the daemon's PR-state watch can reuse them
instead of keeping a copy (step one of PR A in
docs/design/2026-11-04-daemon-pr-state-transitions.md).

The adapter keeps its vocabulary, the deliverToBoundSessions
precondition, the comment-lane class, metadata parsing and provenance
line, and its output is unchanged except for two rendering fixes:
%constructor%-style names now stay literal like any other unknown name
(they rendered Object.prototype members), and every substituted value is
sanitised, as the templates design specified (only %title% and %sender%
were; those two are still also capped at 200 code points).

Tests pin the legacy, role-aware comment and role-aware watch prompts
byte for byte (strings captured from the previous adapter), %url% on a
GitHub Enterprise origin with a port, and the exact validation messages.
The design docs, the template editor design and the user guide are
updated to match.

* fix(channels): keep the trusted instruction label inside the tag peel window

The bound prompt labelled its trusted block `[Operator instruction —
trusted, from <source>]`. With the channel's source that label's content
is 65 characters, one over the 64 that sanitizePromptText peels from a
start-of-line tag, so an event body that began with a copy of the label
kept its brackets and read as a second trusted block. The label is now a
fixed `[Operator instruction — trusted]` with the source on a `Source:`
line under it, so it stays peelable whatever source a producer names.
The platform the header names becomes a closed list
(BOUND_EVENT_PLATFORMS) for the same reason: the header's content is the
name plus 36 characters. A test runs over that list and feeds a copy of
the header and of the label through the body and a data line; another
puts a copy at the start of a substituted value.

Also from review: pin the untyped-winner guard of the resolution chain,
empty values rendering as empty, and misspelt `watch:` types being
rejected, and align the templates design's validation plan with the cap
that applies only to %title% and %sender%.

The templates design, the daemon design and the user guide show the new
label; the templates design's prompt example gains the not-published
notice it was missing, and the daemon design's validation plan now
expects only the prompt without a template to stay byte-identical.
2026-11-07 12:33:10 +00:00
Shaojin Wen ced03aef5d feat(serve): reclaim idle channel sessions (#16532)
Channel workers keep an SSE event pump attached to every cached route,
so the idle reaper treated those sessions as actively watched and never
reclaimed them. A workspace could fill every maxSessions slot with
sessions nobody was using and then reject new creates with 503.

Let the reaper look past a channel-owned subscriber while preserving the
local-work, active-prompt, and child conditional-close guards.

Surface session_limit_exceeded as a banner with a release action. The
release dialog falls back to the workspace daemon client when it opens
from the empty new-session screen, where no session client exists.

Design: docs/design/channel-session-idle-reclaim.md
2026-11-07 19:42:38 +08:00
Shaojin WenandQwen-Coder 61aef512e7 feat(web-shell): offer a details side task from pending AskUser questions (#16458)
* feat(web-shell): offer a details side task from pending AskUser questions

While an AskUserQuestion is pending, the Web Shell hides the composer and
the btw shortcut ignores the pending-approval state, so a user who does not
understand the options can only answer blindly or ignore the question.

Add an "Ask for details" action to the pending panel. It opens a side task
whose prefilled prompt snapshots the question and options and asks for a
verified explanation — background, per-option meaning, differences, impact
and risks, and a recommendation — delivered as a self-contained single-file
HTML in controlled ASD-STE100-style sentences under .qwen/tmp/, previewable
in the right panel. The side task inherits the parent context but never
answers the question or touches the main session; the question stays
pending until the user answers or ignores it.

The action is main-view-only and gated on the session_side_task capability,
matching /btw side. No daemon, protocol, or settings changes.

* fix(web-shell): one details side task per question, reachable on narrow panels

Post-publication audit of the AskUser details action found three issues:

- Repeat clicks provisioned duplicate side tasks for the same pending
  question, each burning a model turn. Give the details tab a stable id
  derived from the parent session and the permission request id and upsert
  it, reusing the artifact panel's open-or-focus pattern; /btw side keeps
  its unique per-invocation draft id.
- The question snapshot dropped multiSelect, so the explanation prompt did
  not say options can combine. Append a [multiSelect] marker (strict true).
- On a 320px two-question panel the actions row overflowed to the left and
  pushed the details button out of the panel (left: -103px in a real-browser
  probe; scrollWidth misses left overflow on a flex-end row). Let the row
  wrap and pin a narrow-viewport regression test asserting every action
  button stays inside the panel and stays clickable.

Each fix carries a red/green witness: the repeat-click App test, the
multiSelect unit test, and a flex-wrap mutation of the narrow visuals test.

* fix(web-shell): keep the details tab id within the daemon clientId budget

The dedup fix derived the details tab id from the parent session id AND the
request id. SideTaskPanel derives the pane's daemon clientId as
side-task:<parentSessionId>:<tabId>, which with two uuids reached 138 chars —
over the daemon's MAX_CLIENT_ID_LENGTH (128). The child session load then
400s, the pane never connects, and the prefilled prompt is never sent.
Caught by re-running the real-daemon scenario on the post-fix head; mocked
unit tests could not see it.

Derive the stable id from the permission request id alone (a uuid; tabs are
already bucketed per parent session), bringing the clientId to 101 chars.
Add a SideTaskPanel regression test asserting the derived clientId stays
within 128 for uuid-length inputs; the old id shape fails it with 138.

* fix(web-shell): retain the details question-to-child association across tab close

Review found that deduplicating only against open tabs loses the existing
child when the details tab closes: clicking "Ask for details" again for the
same pending request provisioned a second child, and reopening the child
from the side-task catalog first did not deduplicate either (its tab id is
side-task:<sessionId>, not the details-request id).

Persist the pending-request-to-child association per parent session in
localStorage, written when the details child is created. On click, focus
whichever open tab shows the known child (details tab or catalog tab);
with no tab open, reopen one attached to the existing child session — no
draft, no initial prompt, so nothing is provisioned or sent again. One
entry per parent supersedes the resolved question's association, keeping
the store bounded; the association also survives reloads.

Regression tests cover both reported paths (close + re-click keeps exactly
one createSideTaskSession call and reopens with the child's sessionId and
no initialPrompt; catalog reopen + click focuses the single existing tab).
Red witness: dropping the association lookup fails the close + re-click
test with two creation calls.

* fix(web-shell): close the details association lifecycle gaps

Re-review of the association persistence found three lifecycle gaps, all
fixed here with regression tests and mutation witnesses:

- A child exists before its explanation prompt is admitted. Reopening a
  closed details tab now retains the prompt on the tab; the pane's existing
  empty-session guard delivers it exactly once (only while the child has no
  user prompt) and never re-sends it.
- Archiving a details child left its persisted association pointing at an
  unloadable session (the daemon's load route 409s session_archived).
  archiveSideTasks now invalidates associations pointing at archived
  children, so the next click provisions a fresh child.
- Closing the draft tab before creation resolved skipped the association
  write, orphaning the child and provisioning a duplicate on the next click.
  The parent identity is captured when creation starts, and the write no
  longer depends on the tab still existing.

Also pin two invariants the deep-verification report found unasserted:
the derived clientId budget with uuid-length inputs through the real tab-id
derivation (App test), and the formatter's array guard against non-array
objects (formatter test).

* fix(web-shell): supply the unsent details prompt to a catalog-reopened tab

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

* fix(web-shell): treat media-only user prompts as interaction in the side-task guard

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

* fix(web-shell): validate details child archive state at the read site and address review suggestions

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

* fix(web-shell): replace stale details tab on fresh draft and re-check pending after the liveness query

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

* fix(web-shell): replace a details tab bound to a different child than the validated binding

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

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-11-07 19:16:53 +08:00
Shaojin WenandQwen-Coder 5b4dcbad19 fix(channels): watch every open bound PR and keep polling during public turns (#16502)
* fix(channels): watch every open bound PR and keep polling during public turns

The PR state watch selected bound PRs purely by binding recency under a
hard cap of 30, so older open bindings were never polled once newer
bindings (including already-merged ones) filled the budget, and merged
bindings outside the cap never received the terminal observation that
would retire them. Selection now watches open PRs first and drains
terminal-pending bindings with the leftover budget; the cap becomes the
configurable prWatchLimit (default 30, range 1-500, fractional values
round up), exposed in the channel descriptor and management API.

Public-path tasks (review requests and assignments on unbound PRs) ran
their full agent turn inside the poll loop, freezing watch detection,
bound delivery, and every other notification for the turn's duration
(observed: 64 minutes on a production daemon). They now execute in a
background FIFO runner (concurrency 1); claims stay durable and
synchronous, the cursor gate and crash recovery keep their semantics,
and bound/watch deliveries stay inline. The first-contact body feed
gains a thread-level gate so an async body turn cannot overtake and
steer-cancel a still-retrying comment turn.

* fix(channels): close review gaps in watch coverage and the async task runner

Review round on the bound-delivery reliability fix. Three correctness
findings, each reproduced against the base:

- The first-contact body deferral gate could lose the body permanently:
  nothing reserves a deferred feed, and the end-of-poll cursor commit can
  mark the thread read once the blocking task settles. Remove the gate;
  the runner FIFO already serializes the body behind the comment turn.
  The absence gate now runs synchronously at dispatch so backpressure
  deferrals keep the false verdict the lane's body suppression needs.
- disconnect retired the runner generation but the winding-down runner's
  restart hook drained the queued tasks anyway. Guard the restart by
  generation and resolve idle waiters at disconnect.
- Recovery could re-resolve a backpressure-parked task whose public turn
  was running (the running transition merges patches) and deliver it
  bound a second time. Skip in-flight ids before any recovery branch
  mutates a task; crashed tasks without an in-flight entry still recover.

Also: gone-session public fallbacks requeue through the serial runner
instead of awaiting the turn in the poll lane (head-of-line block and
steer-cancel on shared sessions); a same-instance reconnect keeps the
orphaned runner's executing id in flight so recovery cannot double-run
it; held threads skip redundant comment re-fetches via a poll-start
snapshot; escaped runner failures are journaled once per task; the watch
budget reserves a terminal slice (20%, at least one) so the drain
progresses even when open PRs fill the limit; stale-open bindings tier
as terminal once the watch observed closed; the limit constants are
exported for the descriptor, the field is registered and translated in
the Web Shell editor, and validation enforces the bound-delivery
prerequisite; the rate arithmetic is documented at every surface.

* fix(channels): drain terminal bindings outside the open watch budget

The in-budget terminal reservation excluded up to ceil(limit*0.2) open bindings while a merged/closed backlog drained, permanently swallowing edge events on those bindings — the defect this branch fixes. Open PRs now take the full prWatchLimit; terminal-pending bindings drain in an additional slice outside it (20%, at least one, plus leftover budget), bounded at limit + ceil(limit*0.2) pulls.get per poll during the drain.

Pins the verification fixture (30 open + 5 newer terminal at limit 30: oldest open observed, its head change delivered) and keeps the removed in-budget variant as a mutation witness. Rate arithmetic updated on the descriptor, i18n (EN/ZH), user guide, and bilingual design docs.

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

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-11-07 19:16:20 +08:00
Shaojin WenandQwen-Coder a12d6946c2 fix(managed-agent): mask digit-led passwords and refuse an empty Harness user info in the upgrade command (#16352)
* fix(managed-agent): mask digit-led passwords and refuse an empty Harness user info

Follow-ups to the review items deferred on #16110 and #16175:

- A refusal hides everything up to a URL's last @, also when the URL
  parses: a password that starts with digits and holds a / ? or # reads
  as a port to both the URL parser and java.net.URI, so
  http://svc:2024#Secret@harness:4170 printed whole. The --server-url
  query refusal masks too.
- Without the server's verdict, start refuses an @ before the Harness
  host (http://@host, http://:@host), which java.net.URI reads as user
  information and the connector refuses, instead of restarting the
  Harness for it.
- The health row and start show a status of 0, false or "" instead of
  dropping it, and print the URLs they dial through the control-byte
  neutralizer.
- A Session id with a lone surrogate fails its row by name instead of
  "URI malformed".
- The Flyway reader test covers a path two modules both ship; the user
  guide names verify --tenant's --actor condition, and the design docs
  state the masking, status and id rules as applied.

* fix(managed-agent): neutralize the DEL and C1 bytes a quoted value keeps

JSON.stringify escapes C0 controls but not DEL or C1 bytes, so a refused
URL or a Session id the verify row echoes could carry U+009B, an 8-bit
CSI introducer, to the terminal, and the id was not bounded. The four
refusals that quote a URL and the id row now go through printable()
after JSON.stringify: C0 controls keep their escapes, DEL and C1 bytes
show as U+FFFD, and the quoted text stops at 200 code points.

* fix(managed-agent): print no password a --server-url carries, and stop predicting java.net.URI

Review round 2 on #16352.

- A --server-url with an @ left after the URL parser reads no user
  information is refused: it is the rest of a password that starts with
  digits, which the parser reads as a host and a port, so the phase would
  dial the wrong host and print the password. check judges --server-url
  the same way, since its plan repeats it.
- start's three outcomes and verify's health row print every URL with its
  user information masked.
- With the server's verdict false, the refusal names a reason only where
  the value shows one the server gives too: a user name or password, a
  query or a fragment, or an @ in an authority after //.
- Without a verdict, start takes only a plain http(s) URL, a rule of its
  own and narrower than the server's, instead of modelling how
  java.net.URI reads user information: http:/@host, a backslash, a tab,
  an underscore or a space no longer reach the Harness restart, and an @
  in a path still does.
- The design record and the deployment guide say so.

* fix(managed-agent): correct upgrade URL guards and plan output

* fix(managed-agent): keep refusing an @ in --server-url, and spell plan words

affe6c5de3 dropped the refusal of a --server-url with an @ left after
the URL parser read no user information, to let a path hold one. That
is the shape of a password that starts with digits: for
https://svc:12/[email protected] the parser reads host svc and port
12, so start and verify dialled svc with the password in the path. The
refusal is back; an @ a path holds is written %40, which the docs now
say, and a test pins a proxy prefix spelled that way.

The plan printed --server-url through shown(), which turns a control
character into U+FFFD and masks the text before an @, so the printed
command was not the one to run. check refuses such a value before the
plan, and shellWord now spells any control character in any plan word
as the escapes of its UTF-8 bytes inside $'...', which bash reads back
to the value, instead of leaving it raw inside quotes.

The Harness rule start applies without the server's verdict follows
java.net.URI's host grammar: no empty label, no label that ends in a
hyphen, a last label that starts with a letter, a dotted-quad IPv4
address, and an IPv6 address of the standard forms. A differential run
of 2,000,000 generated URLs found none the rule takes that
java.net.URI refuses.

The usage line says what check does with --server-url without
suggesting it dials it.

* fix(managed-agent): accept trailing-dot Harness hostnames

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

* fix(managed-agent): take the IPv6 Harness hosts that end in IPv4 when the configuration does not resolve

The plain-URL rule start falls back to refused an IPv6 address that ends in
an IPv4 one, such as http://[::ffff:127.0.0.1]:4170, which the server's
Harness connector accepts, so the same Harness URL was refused while the
configuration did not resolve and taken once it did; the previous commit
took the final dot of a fully qualified name for the same reason. The rule
stays narrower than java.net.URI's, never wider: it still refuses a one-label
name that starts with a digit and an octet with a leading zero, which the URL
parser fetch uses reads as another address, and an empty port.

The refusal of an @ in --server-url now names its remedy, %40, as its sibling
refusals do, and the comment that describes quoted() sits above it.

* fix(managed-agent): tighten upgrade URL and plan handling

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

* fix(managed-agent): harden upgrade rows and Harness URL checks

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

* fix(managed-agent): take the digit-led Harness hosts fetch reads alike, and refuse an empty --server-url

The no-verdict Harness rule refused a one-label name that starts with a
digit, such as a container's 4harness, which java.net.URI and fetch both
read as that host. It now refuses only what the URL parser fetch uses
reads as another address: a one-label number, in decimal or 0x hex, and
an octet with a leading zero. The last label of a longer name still
starts with a letter, as java.net.URI requires, so harness.1b stays
refused, and an empty port is taken. The refusal says what the rule
needs instead of "a host and port with a path".

check, start and verify test whether --server-url was given rather than
whether it is truthy, so the empty value an unset variable leaves is
refused instead of printed into the plan and then ignored.

A plan's control bytes are written as three-digit octal escapes inside
$'...', which bash, zsh and ksh all read back; ksh read a \x escape on
through a hex digit after it.

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-11-07 17:43:51 +08:00
Shaojin Wen f2e9a3fc66 feat(channels): add GitHub repository event filter with allowlist and blocklist (#16493)
* feat(channels): add GitHub repository event filter with allowlist and blocklist

The GitHub channel processed notifications from every repository the
authenticated account can see. The only repository-level control was the
group access allowlist, which cannot express "everything except these
repositories" and hides a plain event filter behind access-policy
machinery.

Add repoFilterMode (off / allowlist / blocklist) and repoFilter to the
GitHub plugin. The filter runs at notification intake, before the reason
filter, journals capped repo_filter skip rows plus one collapsed count
row, and never holds the poll cursor. One shared validator rejects an
active mode with an empty or malformed list at save time and at connect
time; while the mode is off a stored list is ignored, so it can be parked
without blocking unrelated saves. Notifications about bound PRs are
filtered too; only their PR state watch events bypass the filter.

The Web Shell editor gains a Repository Filter select and a Filtered
Repositories list shown only while a mode is active, with the visibility
and validation gated on the same predicate. Pasted repository or PR URLs
are shortened to owner/repo on save; non-repository GitHub URLs are
rejected instead of silently shortened. A parity table binds the editor
mirror to the plugin's own validator, and the visuals suite captures the
new editor in both themes.

* fix(channels): close review findings on the repository event filter

Address the verify-round findings and the triage review notes on #16493:

- F1 (Medium): the reserved-path guard missed sibling GitHub areas, so
  pasted URLs like github.com/apps/renovate or /features/copilot were still
  silently shortened to nonexistent repositories that both validators
  accepted — fatal under an allowlist. Extend the guard with the measured
  missing roots plus the other known two-segment areas, export the set, and
  replace the sampled fixture with an enumerated test over every member plus
  explicit regression pins for the reported shapes. Removing a listed root
  now fails the pins; the denylist's best-effort nature is documented instead
  of overclaimed.
- F2 (Low): pin the documented ordering — a notification excluded by both
  gates is journaled repo_filter, not reason_filter. The verify round's M7
  mutation (swapping the two blocks) survived the old suite and now fails
  this test.
- Triage boundary note: pin that a bound PR's state watch events bypass the
  repository filter, so a future refactor of the watch call site cannot
  silently silence bound sessions.
- Docs: soften the URL-rejection claim to "known non-repository areas" in the
  user guide and both design docs, and state the residual denylist risk.

* fix(channels): close the ten inline review findings on the repository filter

- R1-1: close the arbitrary-host class structurally. The paste-shortener now
  only shortens URLs on the channel's own GitHub host (github.com plus the
  GHES host derived from baseUrl, api. prefix stripped); api.github.com,
  gist.github.com, gitlab.com and any other host pass through for validation
  to reject. Add the missing advisories/copilot areas, a snapshot assertion
  over the whole reserved set (removing any member now fails, which the
  enumeration alone could not witness), and foreign-host fixtures.
- R1-2: seed paginate before connect() in the rejection helper so a
  successful connect no longer prints an unasserted poll-error backoff line.
- R1-3: dialog case opening on a stored active mode — the list shows the
  stored text and a re-save keeps both keys.
- R1-4: pin the notification side of case-insensitive matching (mixed-case
  subject URL vs lowercase entry) and the normalizer's trim/empty handling.
- R1-5: pin the complement of the watch bypass — a notification about a
  bound PR in a blocklisted repository is filtered, not delivered.
- R1-6: capitalized reserved-root variant pins the guard's own lowercasing.
- R1-7: vocabulary test binding the repoFilterMode descriptor options and
  default to the exported REPO_FILTER_MODES, mirroring prWatchEvents.
- R1-9: two-poll journal budget reset test (42 rows, two counted rows).
- R1-10: extend the Off-retirement case with the discriminating switch back
  to Allowlist, where a stale error would remount.
- R1-11: typing a valid entry (or correcting a malformed one) now retires
  the shown instruction through refreshErrors, with two no-save dialog
  cases; Submit remains the only error population site.

Mutation witnesses on this head: dropping chatId lowercasing, the host
gate, the list-typing retirement branch, and the refreshErrors guard each
fail exactly their new test; all restored byte-identical.

* fix(channels): exclude the channel's own API host and paths from URL shortening

Round-3 review findings on #16493, all closed in this commit:

- R1-1 (Critical, fix-induced): the host gate re-admitted the channel's own
  API host — with baseUrl set, a pasted REST URL shortened to `repos/octo`
  (api.github.com) or `api/v3` (GHES /api/v3), phantom repositories both
  validators accepted. Only the WEB host now qualifies: an api.-prefixed
  baseUrl host contributes only its stripped form, and paths under the
  baseUrl's own API prefix are excluded. Genuine GHES web URLs still
  shorten. Pinned with baseUrl actually passed through validation, for both
  REST shapes; re-admitting the raw API host reddens the test.
- R2-1: the baseUrl argument now has a call-site witness on both paths —
  a GHES draft validates clean and saves ['octo/repo'] through
  buildChannelUpsertRequest, mirrored in the parity table against the
  daemon authority. Dropping either argument reddens its half.
- Deferred notes (all three closed, none deferred onward): trailing
  sentence punctuation is stripped before matching (a pasted
  `…/octo/repo.` no longer becomes the phantom `octo/repo.`);
  `enterprises` joins the reserved set (snapshot updated); and a dialog
  case witnesses the parked-list round trip — hidden while Off, restored
  with its text on re-enable.

Mutation witnesses: re-admitting the raw API host, dropping the validator
baseUrl argument, and dropping the save-path baseUrl argument each fail
exactly their new test; all restored byte-identical.
2026-11-07 16:16:22 +08:00
Shaojin WenandQwen-Coder f387c3a75a fix(ci): recreate git metadata before the publish-triage checkout (#16494)
* docs(ci): state the real publish-triage safety argument

The publish-triage header comment claimed a 'clean runner', but the job routes to the persistent ECS pool like publish-tmux and runs without a container. The guarantee that actually holds is trusted-code-only: a sparse checkout pinned to github.sha, never agent-provided code, and a publisher that imports only node builtins plus one relative helper, so a reused workspace offers no bare-specifier node_modules to hijack. Reword the comment to say that, per the non-blocking note in the #16468 re-review. Comment-only change; no job, timeout, or behavior delta.

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

* fix(ci): recreate git metadata before the publish-triage checkout

The patrol closeout on the comment rewrite checked the claims against the file and found two gaps. First, publish-triage was the only job reaching actions/checkout on the shared pool without recreating the workspace .git: an earlier agent run owned that workspace and could have planted hooks/config that checkout's git commands would execute in the one job holding the write PAT. Add the same Recreate runner Git metadata step the triage/tmux/verify jobs run before their checkouts, and add publish-triage to the qwen-runner-checkout-workflow matrix that behaviorally executes it (removal witness: deleting the step turns the new entry red). Second, narrow the header: drop the 'it is safe there' certification in favor of naming the controls, and replace 'sole writer' — the triage job's reaction and lifecycle host steps also write with the PAT — with 'the only path from the agent's drafts to GitHub'. Design pair updated in both languages.

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

* docs(ci): keep the new step comment off the PAT-holder claim

The publish-triage step comment and the design pair said the checkout runs in
"the job that holds the write PAT", which is the same overstatement this PR
removes: the triage job's reaction, status comment, and finalize step also
write with that PAT. Name the publisher as what it is instead, so the header's
new parenthetical and the design prose agree.

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

* fix(ci): pin the publish-triage safety claims and derive the checkout guard

Addresses the remaining review findings in this PR. R1-2: publish-tmux's banner still claimed a clean GitHub-hosted runner while its runs-on selects the ECS pool; reword it to the controls that actually hold (checks out nothing, runs no repository code), routing untouched. R1-4: the publisher's bare-specifier-free import closure is load-bearing for the pool argument but nothing pinned it; add a meta-test that scans the closure and matches it against the sparse-checkout list (witness: adding import 'yaml' reddens exactly that test). R2-2: the runner-checkout guard's hand-typed job list is what missed publish-triage; derive the qwen-triage entries from pool-routed jobs that check out, so the next such job reddens with no test edit (witnesses: a new pool checkout job without the reset, and deleting publish-triage's reset, both redden). R2-3: pin publish-triage's ownership-restore fallback chain with assertUnconditional (witness: deleting the sudo fallback reddens). R2-1: the design pair's Files-affected list still said the runner-checkout test needs no change; correct both languages and narrow the pinned-by parenthetical to what each suite actually pins.

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

* test(ci): make the new pins layout-independent, floored, and complete

Round 3 found three holes in the pins added by the previous commit, all in the guards themselves. R3-1: specifiersOf used a single-line pattern, so Prettier-wrapped imports were invisible in both directions — a wrapped bare specifier kept the closure test green while the pool job would die ERR_MODULE_NOT_FOUND, and a wrapped relative one dropped a needed entry from the expected sparse list. Span newlines, hoist the scanner to module scope, and fixture-pin it (wrapped relative, wrapped bare, and the publisher's own multi-line node:fs import); those three fixtures were verified red against the old pattern and green after, and the sparse comparison stays exact equality. R3-2: the derived job set had no floor, so rewriting one pool job's runs-on into a vars lookup silently dropped its row; add an arrayContaining floor that names the lost job while still admitting new pool jobs with no test edit. R3-3: the ownership test's name promised the full fallback chain but assertUnconditional matches chmod unanchored, so deleting the sudo chmod arm stayed green; pin that arm in this test rather than the shared helper, which has five other call sites. Witnesses: wrapped bare import reddens the closure test, a vars-lookup runs-on reddens the floor naming publish-triage, and deleting the sudo chmod arm reddens the ownership pin — each with byte-level restore.

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

* test(ci): prove the sparse checkout by loading it, not by scanning syntax

The APPROVED review deferred three findings against the closure guard, all instances of one root cause: the guard modelled JS module syntax with a regex. Proven on the shipped code — a 1164-character wrapped bare import left it green (the 400-char bound), and an out-of-tree helper turned the byte-offset slice into the garbage entry /.github/scripts/elper.mjs. Replace the scanner with the condition the pool actually creates: materialize exactly the sparse-checkout list into a temp tree, assert no node_modules exists above it, and import the publisher there. Sufficiency is the load succeeding; necessity is each .mjs entry omitted making it fail; the owner policy is tied by resolving the publisher's own new URL literal and requiring that exact path in the list, instead of an unanchored substring plus a hard-coded entry. This removes the scanner and its layout fixtures rather than patching them again — wrapped imports of any length, prose that looks like an import, and out-of-tree helpers are all covered by the load itself. Witnesses: the long wrapped bare import that was previously green now fails, as do a short bare import, an out-of-tree helper, a helper dropped from the list, a stale extra entry, and a moved owner-policy path; each with byte-level restore.

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

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-11-07 15:21:36 +08:00
Shaojin Wen 930962ab51 fix(managed-agent): let a late snapshot publication wait for the claim instead of losing deadlocks (#16489)
* fix(managed-agent): let a late snapshot publication wait for the claim instead of losing deadlocks

Since #16143 resource_key is a secondary unique index, so a second
publication's manifest upsert queues for the first's uncommitted row with
a next-key lock. Every page the first Build inserts into that lock's gap
rolls the queued publication back, and on longer journals #16446's three
attempts run out: on a 31-page journal a late second publication failed
for 15 of 40 snapshot ids.

The claim now locks the manifest row by key first. At READ COMMITTED that
takes a record lock when the row exists, which does not block the inserts
into the gap, and only a missing row is upserted. Two publications that
both find no row can still meet in the upsert; #16446's retry handles the
one rolled back, whose locking read then waits for the winner.

The MySQL cases pin both paths and assert the key order they depend on.

* test(managed-agent): pin the snapshot claim's reads, upserts and rollback interleaving

- State in the claim's Javadoc that the wait on the manifest row lasts at
  most innodb_lock_wait_timeout and that a lock wait timeout (1205) is not
  retried, only deadlock victims (1213) are.
- Count the claim's locking reads and upserts in the test JdbcTemplate and
  assert them in the new concurrent cases, so a reworded claim statement
  fails those cases instead of turning them into plain races.
- Cover a publication queued on a claim that rolls back: it finds no row
  and publishes the snapshot itself.
- Check that publications run at READ COMMITTED, which the claim's comment
  relies on.
- Read the monitor fixture through one helper.
2026-11-07 09:58:05 +08:00
Shaojin Wen 80fd7c3353 fix(core): re-read a grown local journal prefix even when its timestamps did not move (#16483)
readLocalJournalBytes accepted a read when ctime and mtime were unchanged,
without checking the size. Linux takes file timestamps from a coarse clock,
so a growth that also rewrote the prefix within one tick of the previous
write was accepted, and "refuses a frozen prefix rewrite even when the file
also grows" failed in about half of its runs.

Equal timestamps now count only while the size is unchanged. A file that
grew is read again up to the size read the first time, bounded or not, and
accepted only when those bytes match. An unbounded read during a pure append
therefore returns the generation its first stat saw instead of failing,
which writer acquisition and the session list relied on.

The tests pin fstatSync's timestamps so the one-tick cases hold on every run.
2026-11-06 19:16:10 +00:00
Shaojin Wen e98e4db75e perf(managed-agent): cache parsed statements on the MySQL pools by default (#16414)
Connector/J parses a statement's SQL on every prepareStatement unless its
per-connection parse cache is on, and that parse was the largest share of
the control plane's Java CPU under load. The server and embedded MySQL
pools now add cachePrepStmts=true, prepStmtCacheSize=512 and
prepStmtCacheSqlLimit=8192 as connection properties.

A connection property overrides the URL in Connector/J, so each setting is
added only when neither the URL nor the Druid connection properties name
it, ignoring case, percent-encoding and surrounding spaces. Nothing is
added when either names useServerPrepStmts or useCursorFetch, since the
cache would then hold statements open on the server.

The server pool binds spring.datasource.druid inside its factory, before
the defaults are added: @ConfigurationProperties bound it only after the
factory returned, and the string form of connection-properties replaces
every connection property.
2026-11-06 18:45:34 +00:00
Shaojin Wen 24effd60a4 fix(managed-agent): lock storage rows through their alias key (#16376)
* fix(managed-agent): lock storage rows through their alias key

A storage row has a primary record and three secondary indexes, and a
locking read through one of them locks that record and then the primary
record. Storage admission read the row for its fence through
(tenant_key, storage_key), locking the primary record first, and then
asked for the alias record in requirePhysicalOpen. Runtime binding
creation, execution membership and journal commits lock the alias record
first, so they deadlocked with it (#16320).

- owner() and clearFence go through alias_key.
- lockCovered lists the physical storage's aliases on a fresh read and
  locks them through alias_key, in alias_key order, before its
  physical_key scan, which is forced onto the physical index. Its scan
  used to take each alias's primary record first; it would otherwise
  deadlock with alias-first storage admission, and two recoveries of
  aliases sharing a storage take the aliases in one order.
- StorageRowLockOrderMySqlIT pauses one transaction after a chosen
  statement until the other queues, for each pair, and pins the scan's
  plan.

* test(managed-agent): pin the alias pre-lock to a range read of the alias index

The pre-lock that orders lockCovered's alias locks carried no index hint
and no plan assertion. It now forces uk_ma_storage_business_key and reads
physical_key as well: a covering read of a list as long as the estimated
row count becomes a scan of the whole alias index, which locks every gap.
The plan case asserts a range read on that index, including a storage
whose aliases are the whole table; the covering form fails it.

The alias-list read on the separate connection now fails with its own
message, so a log tells it apart from the later authority read.

* fix(managed-agent): keep lockCovered's side reads off a single-connection caller

lockCovered reads the alias list and the other aliases' authorities on a
connection of its own, set to autocommit and read-only, so they see what
is committed. On the offline recovery entry the DataSource is a
SingleConnectionDataSource, whose every connection is the caller's own:
the read committed the caller's work and the next FOR UPDATE failed in a
read-only transaction. Both reads now go through committedRead, which
reads on the caller's connection there and on an independent one
otherwise.

StorageRowLockOrderMySqlIT records what each second transaction queues
on and requires the alias index, covers an alias committed after the
recovery's snapshot, and runs lockCovered on a single connection inside
a transaction that then rolls back.

* test(managed-agent): pin clearFence's alias index and the offline write

StorageRowLockOrderMySqlIT now drives a migration abort on MySQL and checks
that the UPDATE clearing the fence plans through uk_ma_storage_business_key,
the index owner() already locked the row through. The single-connection
case asserts the caller's write matched its row before relying on the
rollback discarding it. The alias-list comment in lockCovered says what the
single-connection entry reads.
2026-11-06 18:41:03 +00:00
Shaojin Wen acea5b1335 docs(design): correct the daemon PR state transitions design (#16487)
Fold the unposted findings of #16246's final docs-only review, and
what the audits of those fixes turned up, into both language versions:

- v0.26.8 (2026-11-05) ships the watch:* keys of #16258, so they follow
  the same deprecation window as prWatchEvents.
- The view identity check normalises both URLs as pullRequestKey does
  (any path after the number and the port ignored) and compares host
  and number only.
- Latency is stated as nominal (one interval, or ceil(n/30) for a view
  set of n), with the cases that make it longer.
- The shared template contract moves key and template validation,
  chain resolution (event vocabulary and comment lane as parameters)
  and rendering from variable values into channel-base; %sender% is
  GitHub, %repo%/%url% keep the winner's bound URL spelling.
- Every watch-file change reads and writes within one hold of the
  per-PR lock; a daemon claims the watch only when its refresh timer
  runs and its uid owns the watch directory and files, so writes stay
  temp-and-rename; a non-owner writes nothing.
- The alias guarantees, the untrusted-primary exposure and the
  validation plan are updated to match; the Chinese twin carries the
  technical templates verbatim.
2026-11-07 02:38:45 +08:00
Shaojin Wen 301b61ddcc fix(channels): deliver plain conversation comments to PR-bound sessions (#16416)
* fix(channels): deliver plain conversation comments to PR-bound sessions

With deliverToBoundSessions on, a plain comment on a bound PR still had
to pass the repository's mention requirement before it reached the
session. On threads GitHub lists as a mention, an assignment or a review
request, the comment lane dropped it whatever the setting. On subscribed
and similar threads the bound path's preflight rejected it, and the
workaround, turning requireMention off, also made the channel answer
plain comments on unbound PRs and issues there in public. Threads the bot
account opened or commented on were not affected.

The mention requirement keeps ambient chatter out of public replies; it
is no authorization boundary. The private path now waives it through a
preflight option that admits a message whose only group rejection is the
mention requirement. The waiver covers a comment, not the PR's own
first-contact description, from a sender the repository's allowlist
names, in a repository the group gate admits, unless the sender is in
operators: a bound session usually acts on GitHub as an operator's
account, and its own comments would come back to it as turns. Sender,
repository, pairing and message-route checks still run, and repositories
that admit any sender keep the requirement.

On the mention-type threads the lane admits such a comment only when the
private path would deliver it: the routes match and a target resolves
for it as a comment event, so templates keyed by the thread's reason do
not apply to it. Its task is marked private-only: it never takes the
channel path or posts a public error comment, and it is retired when no
bound session is left. Comments only the waiver admits are dispatched
after the rest of the lane and the first-contact body, so their outcome,
a deferral or a gone session evicted from the binding map, cannot change
how those are handled.

Two failure paths that predate this change no longer post a public error
comment for a bound event when the task store fails: the gone-session
deferral under a partial scan and the retirement at the deferral cap.

* test(channels): pin the default route, audit refusal and window hold of waived comments

- Admit an unprefixed plain comment through `defaultMessageRoute`.
- Keep a waived comment's key out of the cursor when an unreadable
  publication audit refuses its dispatch, and deliver it once the audit
  reads again.
- Feed the first-contact body on the re-read when a failed waived
  delivery holds the notification window.
2026-11-07 02:34:31 +08:00
Shaojin Wen 3ec010e490 fix(web-shell): place the session search right of the activity toggle (#16481)
Follow-up to #16467: the search magnifier is now the rightmost control in
the top row, immediately right of View activity, instead of sitting to its
left. The open field still expands to the left of the bell, so the bell
keeps its position while searching.
2026-11-07 02:28:48 +08:00
Shaojin WenandQwen-Coder dd4515ca27 feat(ci): isolate triage agent credentials and run all triage on ECS (#16468)
* feat(ci): isolate triage agent credentials and run all triage on ECS

The triage agent carried a write PAT, so comment reruns and external fork-PR triage had to stay on ephemeral hosted runners while hosted capacity is tight and the ECS pool has headroom. Wire the token-isolation follow-up the workflow already documented: the agent reads GitHub with the job's read-only GITHUB_TOKEN and expresses every write as a draft; a separate publish job validates and executes drafts with the bot PAT, using one publisher script shared with local /triage runs. Contain the agent job, launch the CLI directly behind the hardened loopback model proxy, and move the deterministic maintainer resolver into the publisher. With no write credential in the agent environment, route every triage trigger to ECS behind the existing kill switch, and retire author_can_write. Markers, bot identity, and the finalize workflow's approve-on-green protocol are unchanged.

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

* fix(ci): bump triage CI budget and address triage review findings

The new publish-triage job crossed the .ci-budget ratchet (7->8 jobs, 280->290 declared timeout-minutes), aborting Lint & Static before any lint or helper test ran. Bump the budget entry, mirror the artifact-name fallback expression on the upload side so an early-death run's drafts stay addressable, and enforce SKILL.md's process-label rule at the publisher chokepoint (welcome-pr, maintainer, help wanted, good first issue) with fail-closed tests, aligning the issue-workflow instruction that previously contradicted the rule.

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

* fix(ci): harden the triage draft pipeline after an adversarial audit

Six findings from a multi-round pre-push audit, each with a mutation witness: kill lingering agent-user processes before the drafts strip/upload so a detached child cannot re-plant symlinks in the race window (upload-artifact follows them and RUNNER_TEMP is host-shared; unkillable survivors drop the draft set deny-by-default); lstat the draft containers themselves in the publisher preflight, since a symlinked bodies/ redirects the realpath containment base; reject cross-kind actions (review/close-pr on issues, close-issue on PRs); install util-linux for runuser as the sibling lanes do; make the mktemp'd proxy-wrapper directory traversable by the agent user so the gh/git wrappers do not silently fall out of its PATH; pin the artifact-name symmetry, the kill-before-strip ordering, and the toolchain install line. Design pair updated in both languages.

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

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-11-06 18:17:45 +00:00
Shaojin Wen ad83860092 feat(web-shell): move session search beside the activity toggle (#16467)
* feat(web-shell): move session search beside the activity toggle

The sidebar search lived in the Workspaces header, where it appeared only
on hover and moved its input into a separate row. Put the toggle in the
top brand row next to View activity, expand the field inline when the
column is wide enough, and fall back to a full-width row under the brand
row for narrow drawers and columns. Hide the control while the activity
view replaces the session list, and keep the collapsed strip's search in
its session popover.

* test(web-shell): scope the drawer brand assertion to the logo

The always-visible search toggle adds a second SVG to the sidebar brand
row, so the drawer smoke test parent-level svg locator matched both the
logo and the search icon and failed in strict mode. Target the logo own
aria-hidden container instead.
2026-11-06 17:38:27 +00:00
Shaojin Wen 478c2f87ab feat(ci): make flaky-patrol rerun and no_action decisions visible (#16459)
* feat(ci): make flaky-patrol rerun and no_action decisions visible

The CI Failure Patrol posted only a hidden HTML marker when it re-ran a
flaky failure or could not classify one, so maintainers saw a red PR with
no explanation and clicked Update Branch — advancing the head, dismissing
approvals, and re-running the whole CI for a failure that was already
being handled on the same commit.

Post a short bilingual notice alongside the marker for both decisions:
the rerun notice states the transient cause and that no Update Branch is
needed; the no_action notice asks for manual review before rerunning or
updating the branch. The deterministic-failure comment is byte-identical,
and the post-success reset marker stays hidden.

Pin the visible notices, the marker round-trip through dedup and the
per-head action count, and reason escaping for all three actions.

* fix(ci): strip bidi overrides from flaky-patrol notice reasons

An adversarial audit of the visible patrol notices found that bidi
control characters (U+200E/U+200F, U+202A-U+202E, U+2066-U+2069) passed
through the reason escaping untouched. The reason is classifier output
derived from untrusted CI logs, and a right-to-left override can visually
rewrite the very sentence maintainers trust when deciding whether to
update a branch.

Drop those characters in the shared escaping helper so all three visible
decisions (and the deflake issue signature) are covered, and pin the
stripping for each action. Removing the strip line fails the new test.

* fix(ci): cover the Arabic letter mark in the patrol bidi strip

An escalated audit round compared the strip class against the full
Unicode Bidi_Control set and found U+061C (Arabic letter mark) missing;
it can reorder adjacent Arabic-script text in a visible notice. Add it
to the class and extend the per-action pin payload, so the class now
matches the complete Bidi_Control set. Removing just U+061C fails the
pin test.

* fix(ci): collapse newlines in patrol reasons and pin the audit gaps

The verification round found that a newline plus a line-start fence in a
classifier reason opens a block-level code fence in the visible notice:
the folded Chinese block and the tracking marker render as visible code.
Collapse newlines and their surrounding whitespace to a single space in
the shared escaping helper, keeping every reason single-line.

Close the coverage gaps the same round measured: feed all twelve
Bidi_Control code points individually, pin the English-before-fold and
Chinese-inside-fold placement of the deterministic comment, and assert
the fold's closing tag in all three notices.

Witnesses: removing the collapse, narrowing the bidi class, dropping
U+200F alone, swapping EN/ZH, or dropping the closing tag each fails the
suite; a marked rendering probe shows the fence swallowing the fold
before the fix and a clean render after; benign comment bodies remain
byte-identical to base.
2026-11-06 23:10:16 +08: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
Shaojin WenandQwen-Coder 3663209c3d chore(ci): shorten stale PR window to 30 + 14 days (#16435)
* chore(ci): shorten stale PR window to 30 + 14 days

The open PR backlog keeps growing, and the 60 + 30 day window only reaches PRs inactive for two months, leaving the 30-60 day band untouched. Lower the stale threshold to 30 days and the close window to 14 days, and raise operations-per-run to 300 so the first sweep can label the backlog in one or two runs. The stale/close messages and the fleet-shepherd design docs are updated to match.

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

* docs: keep the stale-window reference in the design current

Avoid pinning the note to a merge-date guess: name the policy change instead, and reflow the paragraph to the file width.

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

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-11-06 14:43:07 +08:00
Shaojin Wenandverify-round e9a20d6ed7 fix(core): keep session list titles when a transcript is rebuilt (#16299)
* fix(core): keep session list titles when a transcript is rebuilt

The sidebar label came only from the transcript: a custom_title record or
the first user prompt. A transcript recreated without its head (missing
file recreated on the next append, crash truncation, external cleanup) lost
the prompt, so displayName became empty and the Web Shell rendered the
session id prefix.

Persist the list label to <sessionId>.title.json next to the transcript:

- the first user prompt (source "prompt");
- custom titles from ChatRecordingService and SessionService.renameSession
  (source "manual"/"auto").

Reads keep the transcript authoritative and fall back to the sidecar.
Archive, unarchive, delete, and storage relocation move or remove the
sidecar with the session. Web Shell rows now render "Untitled session" /
"未命名会话" instead of an id prefix.

Design: docs/design/session-title-sidecar.md

* fix(core): keep the list-label sidecar best-effort

Write it after the transcript append and swallow its errors, so a partial
Config or an unwritable home can never drop a user message. Update the
deletion-cleanup ownership assertion for the extra title-sidecar check.

* fix(core): align the title sidecar with the list reader

- Seed the sidecar when a transcript is resumed (custom title first, then the
  recorded first-prompt display text) so sessions created before the sidecar
  existed gain one without another prompt.
- Write the sidecar when forkSession commits a titled fork.
- Store the prompt's displayText when present and share the reader's
  200-code-point-plus-ellipsis truncation instead of a UTF-16 slice.
- Pin the intended behaviour with tests for a text-less first prompt, resume
  seeding, and a titled fork; update both design documents.

* fix(core): close the review findings on the title sidecar

- Seed a resumed session with its actual first prompt: track that text outside
  the 20-entry title window and fall back to the record's text part when no
  display projection exists.
- Never replace a persisted label from a later prompt. This also lets an
  image-only first prompt leave the slot open for the next text prompt.
- Truncate and validate the sidecar name by code point so an astral prompt
  cannot store a lone surrogate or a discarded ellipsis.
- Reconcile the title sidecar in both archive/unarchive conflict-resolution
  branches, where the hand-rolled sidecar set previously left it orphaned.
- Update the workflow-mode e2e locator for the untitled row label, and cover the
  primary and fanout rows, archive moves, and storage relocation.
- Correct both design documents: the list reads the sidecar when either the
  title or the prompt is missing, and a wholesale chats-directory clear also
  removes the sidecar.

* fix(core): keep sidecar-backed session names consistent

Review round follow-ups for the session title sidecar:

- wrap the archive/unarchive conflict reconcile so a sidecar IO error no
  longer reports an already-completed transcript move as failed;
- also fall back to a manual/auto sidecar in findSessionsByTitle, the
  picker prefix scan, and the unarchive collision guard, so a name the
  sidebar shows can be found with --resume and cannot collide unnoticed;
- converge the live write and the resume seed on one label policy that
  mirrors the list reader (explicitly empty projection, Mod prompts);
- stop a projector-restored session from being labelled with its first
  post-resume prompt, which is not the session's first prompt;
- copy the source sidecar into a fork made without an explicit title;
- return no name instead of another project's prompt text when the
  transcript is refused for project membership;
- keep the id in the fan-out tooltip and the read-only row's accessible
  name, where it is not the visible text.

Tests cover each fix, including the archived half of the lifecycle and a
writer/reader pair on one filesystem; design docs updated in both
languages.

---------

Co-authored-by: verify-round <verify-round@local>
2026-11-06 02:24:26 +08:00
Shaojin WenandQwen-Coder 36c8b34222 perf(core): load the MCP client's Google auth providers on demand (#16242)
* perf(core): load the MCP client's Google auth providers on demand

mcp-client imported GoogleCredentialProvider and
ServiceAccountImpersonationProvider statically, so every process that
loads the MCP client, including each resident Managed Runtime worker,
also loaded google-auth-library whether or not any server uses Google
authentication. createTransport is already async; import each provider
only for a server configured with it.

Add transport tests for the service-account impersonation provider,
which had none in this file. On Linux x86_64 an idle worker loads
0.24 MiB less JavaScript and holds 0.9 MiB less private memory.

* fix(core): ask for a restart when a Google auth provider chunk is missing

Now that the MCP client imports its Google auth providers on demand, the
bundle puts them in separate chunks. A background update that replaces
dist under a long-lived process made the next connection fail with
"Cannot find module", wrapped as a connection error for the server.

Move the restart guidance that contentGenerator already gives for its lazy
provider chunks into utils/errors (getModuleNotFoundError,
wrapLazyModuleLoadError) and apply it to both provider imports. The
original error stays as the cause.

Add google-auth-library to the ACP static closure guard, so a static
import of either provider fails check-serve-fast-path-bundle again.

* test(core): isolate the lazy Google auth chunk tests in their own file

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

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-11-05 18:08:58 +00:00
Shaojin Wen a32646e640 docs(design): propose PR state transitions from the daemon PR refresh (#16246)
* docs(design): propose PR state transitions from the daemon PR refresh

The daemon's PR refresh already sweeps every bound PR with the host's
gh credential; the GitHub channel polls the same PRs again to detect CI
failures, merge conflicts, head changes and merged/closed transitions
for bound sessions, with its own credential, schedule, CI definition
and state machinery, and only where such a channel is configured.

Propose observing head, CI and mergeability in the refresh, keeping the
snapshot and pending transitions in the PR sidecar, delivering each
transition into the bound session through the existing prompt admission
and resume paths, and removing the channel watch in a second PR.

* docs(design): keep per-head CI history and daemon-wide recipients for PR state events

Two inconsistencies with the channel watch the proposal replaces:

- A single ciFiredSha lost earlier heads, so a force-push back to a head
  whose CI failure had already fired would fire it again. The sidecar
  now keeps a bounded ciFired ring (16 heads per PR), the daemon's
  equivalent of the channel's durable fired ring, and the acceptance
  criteria pin the revisit case.
- Recipient selection was per workspace, so two registered workspaces
  for one repository would each deliver to their own bound session. The
  sweep that detects a transition now selects one recipient across all
  trusted workspaces, dispatches through that session's runtime, and
  writes the snapshot and fired record to every sidecar bound to the
  PR so sibling sweeps observe no transition.

* docs(design): give prWatchEvents a deprecation window now that v0.26.7 ships it

The proposal justified rejecting prWatchEvents at connect() with "no
tagged release contains the channel watch". That premise is stale:
#16117 merged on 2026-11-03 and v0.26.7 (2026-11-04) ships and
release-notes it. PR B now keeps accepting the field for one minor
release (one warning naming sessionPrWatch, no watch pass), and the
following minor release rejects it; acceptance criterion 3 and the files
table follow. The refresh-interval note no longer claims a one-minute
floor: sub-minute values fall back to the default and 0 disables the
sweep.

* docs(design): reconcile sibling PR snapshots before detection

Propagating a snapshot to every sidecar bound to a PR is a sequence of
separate file writes, so a crash between the recipient's write and the
sibling writes left a sibling one observation behind; its next sweep
re-detected the same transition under a fresh key and the recipient was
admitted twice. Detection now takes the newest sibling snapshot (by a
new observedAt field) as its baseline, so a lagging sibling catches up
silently and the original pending key is the only one for the episode.
The durability section lists this fourth crash window, and the
validation plan and acceptance criterion 5 pin it.

* docs(design): version slim-listing state observations for baseline recovery

The newest-sibling baseline only worked for observations made by
gh pr view: merged and closed states come from the slim listing and
left observedAt unchanged, so after a crash between the recipient's
write and the sibling writes the two sidecars carried the same version
and recovery depended on enumeration order. Every observation that
changes the watch block now stamps observedAt, the block carries the
state seen at that version, and the baseline is the whole block of one
observation. The validation plan covers the terminal-state crash case.

* docs(design): PR-scoped pending, provenance-bound admission and sweep budgets

Folds in the findings of the docs-only review on 8032f568bb:

- pending is PR-scoped: written to every active sidecar bound to the PR
  and drained once per key to a recipient resolved at delivery time, so a
  recipient archived or outranked between detection and drain loses
  nothing; a lagging sibling adopts the newer block with its pending
  entries.
- the daemon-origin classification is a trusted in-process parameter of
  the admission call, never a string read from the request body; the
  marker name only enters the strip lists.
- the GitHub view loop reports its own rotation offset, runs under the
  Aone aggregate view budget, isolates per-PR failures, dedupes views
  across workspaces by freshness, and does nothing when the setting is
  unset; the watch block gets its own mutation helper because
  updateSessionPrStates returns early without a state/issues change.
- over-length keys hash like the channel's; a partial scan skips
  detection; siblings are re-resolved for trust and generation before a
  write; the A→B window skips repositories owned by a configured channel
  watch; wakeDormant: false keeps entries until the next live turn; the
  API budget is stated per workspace; the "identical rules" claim carries
  the CI-predicate difference; validation and acceptance cover each.

* docs(design): never re-target an admitted PR event key

With the recipient reselected on every drain, a key admitted to one
session could be admitted again to another when a replica clear failed
and the most recently active binder changed before the retry: the
prompt route's admission ring is keyed by session and key, so it cannot
suppress that. The drain now records admitted keys per PR for the
daemon's lifetime and only retries the cleanup for them; lazy recipient
selection applies to keys not yet admitted. Validation and acceptance
criterion 2 cover the partial-clear/recipient-change case.

* docs(design): one daemon-owned watch file per PR, switch-off rollout, channel prompt hygiene

Fold in the docs-only review of d93c3e70d8, whose findings were only in
the workflow log (1 Critical, 8 Suggestions), and the audit rounds run
on the revision.

- The event prompt applies the channel's bound-prompt hygiene:
  sanitizePromptText on every GitHub-derived line and the 6,000-code-point
  cap with a visible marker; no display text is set.
- PR state lives once per PR in a daemon-owned watch file instead of a
  replica in every binder's sidecar, so no copy can lag, be adopted or
  re-detect; the sidecars are unchanged. One daemon per global qwen dir
  owns the directory through an owner record with the channel service
  pidfile's semantics; non-ENOENT read errors fail closed for the tick.
- PR A ships the machinery behind an internal switch that stays off; PR B
  turns it on and removes the adapter watch in the same change, so no
  build runs both pollers. prWatchEvents and watch:* actions are read as
  aliases for one minor release.
- Views go by URL from a daemon-private directory, only to GitHub hosts,
  with the workspace's environment; closed PRs stay in the rotation where
  the listing does not observe them; a renamed repository is accepted.
- The rotation wraps as the Aone one does; one tick-level view deadline;
  restores run off the tick; the drain runs under the per-PR lock with
  in-flight marks; admission counts as delivery.
- Role-aware actions (#16258) are carried over; the restore-failure
  split matches the channel's Gone set; partial scans make no negative
  decision; the trust chain reuses context.channelPrompt and the admission
  and restore are extracted into shared functions.
- Stated boundaries: non-owner daemons, daemons with different global
  qwen dirs over one runtime base, a second-writer restore of a session
  another process writes live (as with the channel today), the 7-day
  deletion horizon, once-per-name delivery after a rename.
2026-11-06 01:41:35 +08:00
Shaojin Wen ad487ffedb feat(autofix-pr): give helper refusals a code and a next step (#16238)
* feat(autofix-pr): give helper refusals a code and a next step

A helper that refuses now exits 1 with one JSON line last on stderr —
{"refused":"<code>","next":"<step>","message":"…"} — instead of throwing
to the CLI's critical-error handler, which printed the error's stack. Each
yargs handler catches the error and prints the line, as review's
extract-step does. Anything that is not a coded refusal is `failed`.

The step depends on the helper as well as the code: `watched_elsewhere` is
`take_over` from `prepare` and `end_task` from a round's helper,
`worktree_dirty` is `discard` from `sync` and `needs_human` from `push`,
and a `reply` that failed is `retry`, as its second run skips what the
first one posted.

The skill acts on `next` through a new "Refusals" section instead of
matching the wording of messages, and the autofix_pr tool returns the coded
line first, verbatim, then what the helper printed before it. Design
section 6.16 lists the codes.

A clone with no directory for the watch's lock (its watch files removed)
is refused as `not_prepared` instead of with ENOENT.

* fix(autofix-pr): code the refusals whose step the caller needs

- A reply plan that is not JSON or not shaped as a plan is `plan_invalid`
  (`fix_plan`), not `failed` (`retry`): retrying it unchanged fails again.
- A switch back that `sync --discard` asked for and git refused is
  `failed`: as `worktree_dirty` it answered `discard`, which asks again.
- The skill's Resume step keys on the code `pr_closed`, since `prepare`
  gives it `report`; `retry` holds wherever a step says a refusal ends it;
  refusals with no coded line are the calling step's to handle.
- The tool takes the last coded line of stderr, not the last line, and a
  refusal's message keeps at most 8,000 characters, its head and tail.
- Each helper's code is pinned through the command in an empty clone.

* fix(autofix-pr): stop a watch git cannot switch back, and fit the coded line

- `sync --discard` that git cannot switch back to the watch's branch
  answers `synced: false`, as a worktree still dirty after discarding
  does, so the round stops instead of reporting the same refusal at every
  fire.
- A reply plan that cannot be read is `plan_invalid` as well.
- A refusal's message drops terminal controls and is cut to 8,000
  characters as JSON, not as text, so the line fits the stderr the tool
  keeps.
- The tool reads a coded line only from a helper that exited 1, and only
  when nothing but Node's warnings follows it.
- The skill's `discard` step tells Round step 1, where it clears what an
  earlier round left, from the push of step 6, where the round's own fix
  left the worktree unclean.

* fix(autofix-pr): answer the first review of the refusal codes

- A switch back git refuses because the branch is checked out in another
  worktree names that worktree and how to free it: `cleanup` cannot
  delete the branch either while it is held.
- A message drops escape sequences whole and the daemon's log-unsafe
  class (C1 controls, bidi overrides, invisible formatters), keeping tabs
  and newlines.
- A defect, not a refusal, prints its stack frames before the coded line.
- The coded line survives the frames `--trace-warnings` adds after it.
- `fix-start` refuses a fix in flight with the message that says how it
  ends.
- The tool's description states the result's prefix, and `partial` is
  described by the deadline that raises it.
- Tests: the whole step matrix as a specification, `cleanup` and `status`
  through the command, the order of the watch checks, prepare's `failed`
  arm, the line within the stderr tail the tool keeps, and the skill's
  steps read from the helpers' source with what each does.

* test(autofix-pr): pin a C1 control a refusal's message drops

* fix(autofix-pr): say how to free a branch a stale or locked worktree holds

When `sync --discard` cannot switch back because another worktree has the
watch's branch, the reason now fits what holds it:

- a worktree whose directory or `.git` was deleted by hand: drop its
  record with `git worktree prune`, since `git worktree remove` refuses
  one whose `.git` is gone;
- a locked worktree: unlock it first, since neither `remove` nor `prune`
  touches a locked one.

The test runs each case's git commands as the reason gives them and
checks that the next round gets through.

The every-helper refusal test now joins its fake `gh` to PATH with
`path.delimiter`, and its `prepare` row refuses before calling gh. The
fake records its calls and the test asserts there are none, because on
Windows `execFile` would never run the script.

* fix(autofix-pr): quote the holder path in the recovery commands

The `git worktree remove` and `git worktree unlock` commands that a
switch-back refusal prints interpolated the holder's path bare. A path
with a space split into two arguments (git exits 129), and a quote broke
the shell, so the branch stayed held. Both commands now quote the path
with `shellQuotePath`, the form review's printed repair commands use.

The holder test now runs every case at a plain path and at one with
spaces and both quotes. It runs the printed commands through `sh -c`
instead of splitting them on spaces.

* fix(autofix-pr): answer the second review of the refusal codes

- The tool runs its helpers with NODE_NO_WARNINGS=1. Node flushes a
  warning raised while a helper refuses after the coded line, and a
  warning's text can run over several lines, which hid the line from
  `codedRefusal`, so the code and its `next` were lost. A new test runs a
  helper that raises such a warning: run directly, the helper prints the
  warning after the line; run through the tool, its line is read.
- The switch-back reason's git commands carry `-C` with the watch's
  worktree, so they run as printed from any directory. A branch held by
  the clone's own checkout gets only "switch that checkout", because git
  never removes the main worktree.
- `worktreeHolding` reads `git worktree list --porcelain -z` (git 2.36+)
  and falls back to the line form on older gits, so a path with a
  newline in it is no longer cut short.
- New tests: the holder matrix also runs at a path with a newline and
  runs its commands from outside the clone; the main-checkout holder;
  and the no-holder arm (an index.lock stops the switch), whose `cleanup
  --force` advice nothing reached before.

* fix(autofix-pr): keep a watch whose branch another checkout has

When `sync --discard` cannot switch back because another checkout has
the watch's branch, it answers `synced: false`, and the skill stops the
watch with `cleanup`. `cleanup` removed the worktree first and then ran
`git branch -D`, which git refuses while that checkout has the branch.
The watch was left with its state file and branch and no worktree, and
every later `cleanup` and `prepare` failed at the same delete.

`cleanup` now checks `git worktree list` before it removes anything,
`--force` or not. When another checkout has the branch, it keeps all
three and ends the watch, as it does for unsaved work. It answers
`removed: false` with `heldBy`, and a reason that says how to free the
branch and to run `cleanup` again. The holder lookup is shared with
`sync` (`branchHolder`) and anchors its commands at the clone root.

The skill's Stop step and both design docs say what `heldBy` means.
2026-11-05 16:53:30 +00:00
Shaojin Wen a7d0193991 fix(core): keep a writer grant an in-flight renewal refreshed (#16287)
* fix(core): keep a writer grant an in-flight renewal refreshed

reacquireWriter() waits for an in-flight writer renewal before it
acquires, then always dropped the grant and posted the acquire. When
that renewal had answered with a live lease, the fresh grant was thrown
away; until the acquire answered, authorizeLifecycle and any caller
reading the grant without waiting in ensureWriter refused locally with
"the HTTP Managed Session writer is not active." A Store holding the
Session's lifecycle can hold that acquire, so a Hosted Harness refused
lifecycle phases during Spring recovery. HostedShellLifecycleRecoveryIT
[spring-kill] failed this way in 17 of 239 CI runs since #15813 made
renewActivation reacquire a lapsed writer.

Keep the grant when the awaited renewal moved the lease past the lapsed
one and the new lease is live. A refused renewal, or a commit that only
copied the lapsed lease, still reacquires.

* test(core): pin the lease check and renewal timer a kept grant relies on

Two cases join the reacquireWriter tests:

- A refused renewal is held while, after a wall-clock step back, a
  journal read copies the lapsed lease into a new grant object. The
  writer must still be re-acquired. Only the check that the lease moved
  forward guarantees this; a check on grant identity would keep the
  copy.
- The grant is kept, then the activation renewal meets the lifecycle
  claim fence. The timer that the kept grant's renewal re-armed must
  still send the next ordinary renewal before that lease ends.

The reverse case now reaches the lapse before the wait, so the live
check has to read the clock after it. The comment in reacquireWriter
names the journal read among the paths that copy a lapsed lease.
2026-11-06 00:51:40 +08:00
Shaojin Wen b3ad01e875 perf(managed-agent): index the activation lease lookup (#16336)
* perf(managed-agent): index the activation lease lookup

Each activation renewal reads the Session's current activation row,
three times a minute for every resident Session. Renewals update that row
in place, so it is as old as the activation, and the lookup walked back
over every journal transaction committed since: 22 rows after one Turn,
717 after 40.

Add a generated is_activation_record flag for the two activation
operations and an index that leads with the Session, its activation
epoch and the flag, and look the row up by the flag. A simple CASE keeps
H2 from compiling the expression into an IN set bound to the session that
created the table. The CSI application image builds its expected journal
row column by column, so it sets the flag the way the database does.

A MySQL integration test pins the lookup's plan and result with 1,000
and 10,000 later transactions.

* test(managed-agent): pin the CSI image's mirror of the activation flag

The CSI application image rebuilds the journal row a cut adds column by
column, so it computes is_activation_record in Java as the generated
column does. Nothing held the two together.

- WorkspaceCsiApplicationImage.ACTIVATION_OPERATIONS names the
  operations the CASE marks, and V1 points back to it.
- ActivationRecordFlagTest parses the journal table's CASE and requires
  exactly those names and ELSE 0, then checks on H2 that the generated
  value matches the mirror for every name the CASE mentions and other
  operations the Session Store commits.
- The CUT-image test asserts the added row carries "false" for the
  cut's releaseActivation.
2026-11-05 16:16:55 +00:00
Shaojin Wen 76c692815f perf(core): read each Managed message body once per process (#16337)
* perf(core): read each Managed message body once per process

A Hosted Turn rebuilds the Session's history from its message bodies
several times; every rebuild read every body through the Session Store
again, so Turn t made about 8·t resource reads, each an HTTP call and
about five SQL statements on the control plane, serially.

Keep the bodies a projection has read in one byte-bounded LRU shared by
the process (64 MiB). Bodies are immutable and keyed by their store and
full durable ref, so a hit is exactly what the store returned; only reads
fill it, so a body never read still fails the projection when it is
missing. The bound keeps resident Sessions from each holding their whole
history.

* perf(core): keep a Session's own message bodies when it outgrows the cache

A projection reads every body of its Session in commit order, so with one
LRU over all stores a Session whose bodies exceeded the bound evicted each
body just before its next projection reached it and never hit, and flushed
the other Sessions' bodies on every pass.

The cache is now kept per resource store, with the stores ordered by when
they were last read. A store never evicts its own bodies to keep a new one:
an oversized Session keeps the bodies it kept first and reads the rest from
the store. Room for a new body comes from the other stores, least recently
read first, so contending Sessions re-read only what the others displaced.

Adds cases for that, for the shared default cache, for two concurrent misses
of one ref, and for counting a chunked body by its assembled bytes.

* perf(core): bound the message body cache by the memory it holds

- Charge every entry its body plus a fixed overhead (768 bytes, measured
  at 520 to 740 on Node 22), so many small bodies cannot hold several
  times the bound.
- Keep a body that is a view of a larger buffer in an exact-size,
  unpooled copy, so a small body from the HTTP store does not pin an
  8 KiB pool slab.
- Release a store's entries once the store object is collected.
- Once keeping another of a store's own bodies would take them past the
  bound, the store only uses free room: taking room back gained it
  nothing and made the other Sessions re-read their whole history.
- Pin each key field, the production wiring, concurrent misses,
  assembled-byte accounting, the boundaries and the policy in tests, and
  document what a cache hit no longer checks.

* test(core): pin that a cached message body outlives its store copy

A cache hit does not read through the store, so a body already read is
still served after the store loses it, while a projection with a fresh
cache fails. The behaviour is deliberate and documented on the cache;
the case keeps a future change from restoring the store read silently.

* docs(core): say what a cached message body means for readers with their own store

The resource store's read contract does not change: readers that open
their own store read it on every call and fail on a missing referenced
body, while a projection whose cache kept that body keeps serving it
until the entry is evicted or its store is collected. Losing such a
body is damage; nothing removes it while the Session can be read.
2026-11-06 00:14:22 +08:00
Shaojin Wen 5d45b42940 feat(verify-pr): give the sandboxed round the PR's review threads (#16300)
* feat(verify-pr): give the sandboxed round the PR's review threads

The sandboxed /verify round had no token and no review context. Its only
inputs were the metadata snapshot and, on a follow-up round, the previous
report. On #13652, Critical R1-26 was confirmed and then marked fixed
three days before a round that published merge-ready without mentioning
it. Eight hours later the review bot, at the head that round had
certified, still could not rule out its residual.

The verify job's trusted resolve step now writes review-threads.json
beside pr.json. It holds every review thread, resolved ones included,
since resolving is the author's claim, together with each reviewer's
standing CHANGES_REQUESTED review. It comes from one paginated GraphQL
query reshaped with jq. The snapshot is best-effort: on failure no file
is written and the step warns.

verify-pr-prepare.mjs sends the same query and writes the same shape for
the local path. It removes a previous copy first, so a failed fetch
cannot leave a stale file. A test runs the workflow's block verbatim and
prepare's transform over the same pages and requires equal output.

The skill adds report item 4, Existing review findings. Each term the
verdict turns on is computed from snapshot fields:

- a thread is blocking when its first comment is tagged Critical,
  Blocker, P0 or P1 by an owner, member or collaborator;
- a blocking thread is open when it is unresolved, or when that comment
  belongs to a standing change request, resolved or not. That is the rule
  GitHub's review decision applies.

An open blocking thread left unmeasured rules out merge-ready, and with
nothing failed the verdict is inconclusive. Findings at fail 0 would
publish as "passed with non-blocking findings". Other blocking threads are
re-measured as budget allows, and the verdict line counts those left
unmeasured. Change request bodies are listed but do not hold the verdict,
because their severities are prose. The duty is also a line in the scope
budget. A missing file reads as unknown, never as "nothing open".

The file adds 2396 bytes, so the size baseline records the exact 378242.

* test(verify-pr): pin the review snapshot's jq guard, copy order and odd answers

- The CI/local parity test runs the step's real jq program. Like the
  file's four other jq cases, it now skips when jq is missing, instead of
  failing one assertion later with a message that names neither jq nor
  the missing tool.
- The local recipe must clear the cached review-threads.json before it
  copies prepare's file. The test pinned the three lines but not their
  order. With rm after cp, every local round would read the review state
  as unknown.
- prepare's two other failure arms are now driven through the CLI: an
  answer that is not JSON, and pages without a pull request. Each must
  leave no file and say why. An empty state there would read as "nothing
  open".

* test(verify-pr): drive the review snapshot through the shared gh harness

The review-snapshot case had its own copy of the executed-step harness:
a temp dir, a stub gh on PATH and GitHub's bash flags. makeGhHarness
exists so that fidelity contract lives in one place. The factory now
records every call's argv, returns its temp dir, and has an
empty-stdout arm for paginated reads, GH_STUB_EMPTY_LIST. The snapshot
case runs through it. Its failure arms are the factory's
GH_STUB_FAIL_LIST and GH_STUB_EMPTY_LIST.

Deleting the shared stub's fail arm, its empty-stdout arm or its argv
capture turns the case red, and so does recording only the first
argument.
2026-11-05 23:50:44 +08:00
Shaojin Wen 5239d69cf1 fix(channels): keep GitHub inbound recovery armed after a failed read (#16402)
A corrupt or unreadable inbound task store at connect and at the first
poll's recovery latches the in-memory recoverable-task count at 0. The
recovery flag was cleared in a finally, so a later poll never re-ran
recovery once the store became readable: the persisted task never ran,
and its cursor-blocking state kept the notification window held.

Clear the flag only after a successful recovery pass, matching the
watch-reconcile guard, so a failed read keeps the retry the log already
promises.
2026-11-05 23:39:12 +08:00
Shaojin Wen cc78e35d5f perf(serve): keep the ACP spawn channel out of Managed Runtime workers (#16235)
The worker sizes media responses with DAEMON_ACP_NDJSON_LIMITS, which
managed-tool-media imported from acp-bridge's spawnChannel. That module
pulls in the ACP SDK schemas, the acp-bridge status module and, through
it, core's package root, so every resident worker loaded about 1.5 MiB
of modules for one constant.

Define the constant in the dependency-free ndJsonStream module,
re-export it from spawnChannel, and import it from there. On Linux
x86_64 an idle worker drops from 91.0 to 79.6 MiB of private memory and
from 215 to 175 loaded modules.
2026-11-05 14:42:05 +00:00
Shaojin Wen ef6914c1d9 fix(channels): keep GitHub replies hit by a secondary rate limit (#16334)
* fix(channels): keep GitHub replies hit by a secondary rate limit

A final reply that GitHub rejected with a secondary rate limit was
classified as an ambiguous delivery failure: it was not retried, not kept
as a pending delivery, and no error comment was posted, so the answer was
lost while the notification was marked read. GitHub sends secondary
limits with quota left (and not always with retry-after), so only the
exhausted-budget shape counted as a definite no-write.

Treat a 403/429 that carries GitHub's rate-limit headers and names a
secondary limit as a definite no-write too. The reply is retried in
process and, if the limit persists, kept as a pending delivery for the
next channel start. Responses without GitHub's rate-limit headers (for
example a 429 from a proxy) and permission 403s stay single-shot.

* fix(channels): wait out GitHub secondary limits before retrying

A secondary rate limit without retry-after fell through to the generic
1 s / 2 s backoff, so the final reply's three attempts landed within about
three seconds while GitHub was still throttling the account; GitHub asks
for at least a minute in that case, longer on each repeat, and warns that
continued requests can get an integration banned. Wait 60 s and then 120 s
for such a limit; retry-after and the budget reset still take precedence.
Note in the classifier why a retry-after header alone is not proof of a
rejected write there.

* fix(channels): stop the pending GitHub retry pass on a rate limit

Now that a secondary limit counts as a definite no-write, the retry pass
on channel start spent three attempts on every pending reply while the
same account-wide limit answered each of them; stop the pass at the first
rate-limited record and keep the rest for the next start. Pin the retry
count and waits per secondary-limit shape, the older abuse-detection
wording, and that a 502 carrying an exhausted-budget header stays
single-shot. Reword the classifier comment: the header shows a rate
limiter answered, and GitHub's wording tells which one.

* fix(channels): end GitHub rate-limit waits on stop, keep cleaning pending replies

- disconnect() used to cut a rate-limit wait short and let the remaining
  attempts go out back to back into the same limit. A stopped wait now gives
  up with GitHub's last answer, so a final reply stays a definite no-write
  and is kept as a pending delivery.
- The pending pass stops sending at the first record GitHub still
  rate-limits (either limit) but keeps dropping later records the audit
  already shows as posted, which needs no request.
- The PR watch pass treats a 403 carrying GitHub's secondary-limit wording
  as shared, like the publication and notification paths.
- A retry-after that is not a positive number of seconds (an HTTP date,
  "0") no longer skips the secondary-limit minimum.

* fix(channels): treat retry-after on a GitHub-answered 403/429 as a rate limit

The final reply's definite no-write required an exhausted budget or GitHub's
secondary-limit wording, so a rate-limit rejection that carried GitHub's
x-ratelimit-remaining and retry-after but worded its body differently was
audited ambiguous and the reply was dropped. retry-after is GitHub's own
documented signal for that limit; with the x-ratelimit-remaining header still
required, a proxy's bare 429 stays single-shot. A 403/429 whose retry-after is
unusable now waits the secondary-limit minute as well.

Also pin the shape where GitHub rejects the reply once and accepts the retry.

* fix(channels): stop GitHub retries by the error's shape, not the wait branch

- The stop guard keyed on which wait was chosen, so an exhausted budget
  without a reset header took the generic 1 s / 2 s branch and, after
  disconnect(), still fired its remaining attempts into the spent budget.
  Rate-limit answers are now recognised by their shape (403/429 with an
  exhausted budget, retry-after or the secondary wording) for the wait, the
  stop guard and the definite no-write check alike; such an answer without a
  usable retry-after or reset waits the one-minute minimum.
- x-ratelimit-remaining must be a count: an empty value no longer reads as
  an exhausted budget and turns a permission 403 into a stored retry.
- The pending pass starts no further record once a stop is requested, even
  while another write in flight defers its abort.
- Tests pin the stop guard for every wait, reconnecting, a kept record
  between the stop and a posted one, and an infinite retry-after.

* fix(channels): let an answer that lands after a stop decide the pending reply

- The start-up pass returned on an aborted signal before looking at the
  error. A stop defers the abort until the in-flight write ends, so a 502 or
  network failure from that write (GitHub may have posted the comment) left
  the record and its reply_pending task for the next start, which posted the
  reply again. The pass now classifies first; only an attempt that was never
  sent leaves its record, and githubApi marks that abort as not sent.
- A rate limit without GitHub's header (a 429 with only retry-after) is
  still dropped as ambiguous, but it now stops the pass like a definite one
  instead of spending every later record against the same limit.
- x-ratelimit-remaining as a number must be a non-negative integer, and the
  watch pass judges a 403 with the same rate-limit rule as the reply.
- A call that starts after the stop gives up before logging or sleeping.

* fix(channels): treat a past GitHub budget reset as clock skew

GitHub answering with an exhausted budget means its window has not reset, so
a reset time that already looks past says the local clock is ahead; the wait
fell to a 1 s retry and spent all three attempts in about two seconds. It now
waits the one-minute rate-limit minimum instead. Tests give the secondary
limit GitHub's usual reset header and pin the start-up pass keeping its
records when a stop, deferred by another write, lets the wait run out.

* test(channels): pin the ambiguous audit, the watch 429 abort and the quiet stop

Lock three behaviours the suite let drift: a header-less rate limit on the
final reply is audited failed_ambiguous (the key stays consumed), a bare 429
aborts the whole PR watch pass, and a reply that starts after the stop logs
no retry it will not make.

* docs(channels): name the header a GitHub no-write answer needs

The no-write rule needs a numeric x-ratelimit-remaining on the 403 or
429, so a rate-limit answer without it (a 429 with only retry-after,
say) is ambiguous and its reply is not kept; the guide now says so. The
publication test helper's comment also said the opposite of what
stopping the poll loop does to retry waits.

* docs(channels): bring the GitHub publication contract up to the retry rules

The contract still said a definite no-write goes straight to the pending
file. It now names what counts as one, the two in-process retries and
their waits, what an ambiguous answer to a retry does, the stop that
keeps the reply pending, and how the start-up pass stops at the first
reply GitHub still rate-limits. The first bullet now speaks of one final
response rather than one delivery attempt.

* docs(channels): add the Chinese version of the GitHub publication contract

docs/design/README.md asks a change to a single-language design to add
the missing version. The publication contract gains its zh-CN twin,
reciprocal links under both titles, and a Verification line for the
retry tests.
2026-11-05 22:37:41 +08: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
Shaojin WenandQwen-Coder edb094fea3 test(managed-agent): gate lifecycle-v2 close with a stopped worker (#16123)
Co-authored-by: Qwen-Coder <[email protected]>
2026-11-05 22:22:26 +08:00
Shaojin Wen 481bd948de feat(web-shell): link channel event rows to their sessions (#16375)
* feat(web-shell): link channel event rows to their sessions

Operators investigating a bound-session delivery or a published reply had
to copy the session id out of the journal and find the session by hand.
Journal rows that name a session now offer an "Open session" button on the
Channel events page, reusing the existing in-app session navigation path.

GitHub task rows previously journaled no session at all, even though the
bound delivery target is known at every transition. They now record the
target session and, from the binding snapshot, its owner workspace, so
cross-workspace targets open correctly; outbound rows additionally stamp
the channel's own workspace. The new journal/SDK field is optional: older
records fall back to the journal's workspace, which is correct for the
channel's reply session, the only session kind older records name.

* docs: correct the locked-workspace claim in the session-link design

The open-session handler's locked-workspace guard only applies to
browser-notification clicks dispatched on a dedicated EventTarget;
window-dispatched in-page links (markdown session links, and the new
events-page button) are not subject to it. The design said the jump
"honors locked-workspace mode" and that locked shells "ignore
cross-workspace jumps" — both inaccurate. State the observed behavior:
in a locked shell the button behaves exactly like an existing markdown
session link.
2026-11-05 18:09:28 +08:00
Shaojin WenandQwen-Coder 2feb29c856 fix(cli): stop Agent Host disconnects being undone and report unsaved joins (#16156)
* fix(cli): stop Agent Host disconnects being undone and report unsaved joins

`DELETE hosts/connect` stopped the connection before removing its saved
record. When the removal failed, for example on a held lock, the route
answered 400 after aborting the running turns, and the restore loop
reconnected the still-saved record a few seconds later. It now removes
the record first and stops the connection only after that. A failed
removal stops nothing: a held lock answers 503 for the caller to retry,
and any other failure answers 400, unless the connection has no record
at all (joined at boot, or a join that was not saved), which has nothing
to come back from and is stopped anyway.

`POST hosts/connect` swallowed a failed save of the connection record and
answered as if the join would survive a restart. It still answers
success, because the enrollment token is spent by then, but adds
`saved: false` and `saveError`, unless an earlier record for the same
connection still stands. `qwen agents join` and the web shell's
add-runtime dialog warn about it instead of saying the daemon reconnects
after restarts.

The dialog's two join forms now have their own keys. React reused one
form's inputs for the other, so an enrollment token typed on one tab
showed up in a visible field of the other.

Refs #16136

* fix(cli): tell an unreadable connections file from an empty one on disconnect

Address review R1-1 (Critical) and R1-2/4/5/6/7/13:
- R1-1: readAgentHostConnections mapped every non-ENOENT failure to [], so a
  disconnect whose removal failed (EMFILE/EIO/mode change) read as 'no record',
  stopped the turns, and let the restore loop reconnect the record still on
  disk. Split a strict reader (ENOENT is empty; unreadable/corrupt throws) from
  the resilient one the restore loop keeps, add findAgentHostConnectionChecked
  (found/absent/unreadable), and make DELETE stop only on a confirmed absent
  record, refusing on unreadable.
- R1-2: widen the connections lock retry budget (~0.65s -> a few seconds) so
  transient contention is not reported as a permanent lost join.
- R1-4: the restore loop's keep() now uses findAgentHostConnection instead of
  duplicating the still-saved predicate.
- R1-5/R1-6: the add-runtime dialog clears the unsaved notice before join
  validation and on tab switch, so a stale notice cannot sit beside another
  form's error.
- R1-7: pin a saved remote-connect control, the empty-saveError notice ('.'
  fallback), and the tab-switch clear.
- R1-13: session-multi-agent design §3.5 now states reconnect is conditional on
  the record being saved (both languages).

Tests: cli agent-host 30 pass, web-shell dialog 12 pass; typecheck, Prettier,
ESLint clean. Critical and tab-clear mutation-witnessed (module lookup swallow
and tab-handler revert each redden their pin).

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

* test(cli): pin the disconnect Critical with a real route + real store

Add a real-scenario integration test: the real registerAgentHostRuntimeRoutes
wired to the real agent-host-connections store over a real temporary
filesystem (only the Agent Host client and program probe are substituted).
A readable record is forgotten and stopped; a file made unreadable (a
directory standing in for EMFILE/EIO) makes DELETE refuse with 400 and never
stop, so the restore loop cannot undo the disconnect. Mutation-witnessed end
to end: swallowing unreadable as absent reddens the real-route test.

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

* fix(cli,web-shell): keep Agent Host joins through a slow save

Found by running two real daemons (a coordinator and a Host) on one
machine against faults on the Host's connections file, and in the second
review round.

Connections file:
- The lock used proper-lockfile's own retries, about 3.75 s in all, for
  every error. A holder killed while it held the lock (SIGKILL, OOM)
  leaves it until it goes stale, 10 s later, so a join that met it
  answered `saved: false` and a disconnect answered 503. Writes now retry
  only while the lock is held or changing hands (ELOCKED; ENOENT; EPERM
  and EBUSY on Windows), for up to 12 s, and fail at once otherwise.
  `stale` stays at the default, now spelled out, so daemons of other
  versions agree on it.
- Writers in one daemon take turns, so two of them cannot both take over
  the same stale lock; one still queued 1.5 s past its deadline gives up.
  A writer whose lock was reported lost, or that held it for 8 s, writes
  nothing, and a lost lock is logged instead of taking the daemon down.
- The file is read strictly inside the lock: a read error (EACCES,
  EMFILE, EIO) or a file a newer version wrote fails the write instead of
  replacing every other record. A corrupt file is still replaced.
- DELETE `hosts/connect` also answers 503 for a lock lost before it
  wrote, and 400 with the reason for a file a newer version wrote,
  saying that the connection ends when this daemon stops.

Deadlines: the save can now wait about 13.5 s, so the coordinator gives a
remote's `/connect` 90 s instead of 20 s, the web shell gives
remote-connect 120 s and its own join 90 s instead of the SDK's 30 s, and
`qwen agents join` waits 90 s instead of 60 s. A CLI join that timed out,
or whose connection dropped after it was sent, now says the join may
still complete instead of saying that no daemon answered.

Add-runtime dialog and Agents page:
- Each machine's "not saved" notice has its own slot and shows only with
  the form or result it is about, so a tab switch neither clears it nor
  moves it under the other machine's form. A join's error likewise stays
  with the join form; one a sent join came back with also shows, labelled
  as this computer's join, over another machine's connected result. A
  close drops only the notices and errors on screen.
- A join or remote connect in flight keeps the dialog busy across a close
  and a reopen. Meanwhile the page keeps New agent, Role templates,
  Mention in chat and creating an agent on a runtime unavailable, since
  leaving it would drop the result. Adding and replacing a runtime use
  separate dialogs, so a replacement does not remount a dialog whose join
  is in flight.
- A connected result waits while a request is in flight instead of
  replacing the form that sent it, and a close keeps one that a joined
  result covered.
- A sent join clears its token, so a spent token cannot be sent again,
  and a join that timed out says it may still be joining. The form that
  sent a request is read-only until it settles. A plain-HTTP
  link off loopback without "Allow plain HTTP" is refused before it is
  sent, as the daemon would refuse it, so its token stays.

The design doc now defines `saved` by outcome.

Refs #16136

* fix(cli,web-shell): say why a disconnect stops nothing, and what helps

From the third review round.

Connections file:
- Reading it now says why there are no records: a read error, a file a
  newer version wrote (ENEWERSCHEMA), or a corrupt file (ECORRUPT).
- The restore loop reads a newer version's file as none and says so once
  per file, instead of logging it as unreadable every 5 s.
- A disconnect refused because the file cannot be used now says why
  and what helps: the read error itself instead of "Retry the
  request.", which does not help a permission problem; for a corrupt
  file, "Fix or remove that file, then retry." and why forgetting the
  connection failed; and for a newer version's file, that the
  connection ends when this daemon stops. A corrupt file is still not
  taken for "no record": a real run showed that a file read through a
  mount being replaced comes back empty, and the restore loop
  reconnected from it once the mount was gone, undoing the disconnect.

`qwen agents join`: the daemon answers 2xx only once the join went
through, with `connected: true`, so any other 2xx (cut off, not JSON,
JSON `null`, no `connected`) now says the join may still complete,
worded by whether the answer was cut off or did not confirm the join.
A JSON `null` answer no longer crashes it.

Agents page: while a join or remote connect from the runtime dialog is
in flight, a status line under the title says why the page's actions
are disabled.

Tests: the remote-connect double answers each leg as its handler does,
and the real-store disconnect case checks that the record left the file.

* fix(web-shell): keep a remote's notice, and the join command made meanwhile

From the fourth review round.

Add runtime dialog: creating a join command while a remote connected
from "I have its address and token" had not shown up yet replaced that
wait. Once the remote showed up, it had lost its "could not save the
connection" notice, and closing its result threw the join command away.

- The wait keeps the remote by the workspace it answered from
  (`workspaceCwd`, which the roster also reports for its runtime), and a
  join command created before the remote shows up keeps it too. Only a
  runtime serving that workspace is the remote and carries its notice.
  While the dialog waits for the remote alone, no other computer is the
  one it waits for. With a join command pending too, results come in
  roster order.
- Closing a result keeps waiting for what is left: the remote, after
  another computer's result; the join command's computer, after the
  remote's, unless the command expired and nothing else came.
- An external runtime online before the connect that serves the same
  workspace may be the remote, back under its saved id. Then nothing
  waits on for it.
- An answer without a workspace (an older daemon) is read as before.
- A remote connect drops a join command shown before it: the
  coordinator keeps one enrollment token, and the connect issues its
  own, so that command no longer works.

Tests: the real-store disconnect case checks, through the strict
lookup, that the record left a file that still reads and that another
workspace's record stayed. The lenient lookup reads a corrupt file as
no record.

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-11-05 01:42:49 -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
Shaojin WenandQwen-Coder e4c570a387 feat(channels): edit bound-session action templates on demand (#16349)
* feat(channels): edit bound-session action templates on demand

The Web Shell channel editor rendered every boundSessionActions key as its own single-line input — 80 rows for GitHub's closed vocabulary — with one dense paragraph as the only explanation and no way to enlarge the dialog. Operators typically configure one or two keys.

Record fields now show only configured entries and add keys through a grouped picker whose options carry per-key descriptions; values edit in multi-line text areas with explicit delete buttons. GitHub's descriptor supplies the descriptions and role groups, mirrored through new optional option metadata on the channel-base and SDK descriptor types. The channel editor dialog gains a fullscreen toggle, and the templates field moves into the bound-sessions panel with the same delivery gating as watch events. Empty values stay editable in the draft and are filtered by the existing save serializer, so the draft/save contract is unchanged.

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

* fix(web-shell): generic record copy and smoke-tag the template editor E2E

Review follow-up on #16349. The shared record renderer's empty state and add-picker placeholder were template-worded, which mislabels the options-less messageRoutes field; the defaults are now entry-worded and the GitHub/GitLab template fields override them through their field label keys. A new unit case pins the options-less path (generic copy, no picker, stored rows still editable). The new channels E2E case gains the @smoke tag so the CI smoke job actually runs it.

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

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-11-05 16:48:27 +08:00
Shaojin Wen ef9c891b8f fix(ci): clear the pinned corepack cache before every install (#16351)
* fix(ci): clear the pinned corepack cache before every install

The Linux test shards have been dying seconds into Install dependencies
with MODULE_NOT_FOUND on the cached pnpm's bin/pnpm.mjs since #16328
pinned COREPACK_HOME to runner.temp (main runs 41876940141, 41889761157,
41927887452; six different self-hosted hosts). The pin assumed runner.temp
is per-run, but self-hosted runners keep _work/_temp across jobs — the
lint job's isolated-HOME step already documents this. corepack trusts an
existing cache entry without re-verifying its files, so a partial pnpm
tree left by an earlier job fails every later job on that host.

Clear the pinned cache at the start of the shared install body in all
three jobs that copy it, enforcing the per-run contract the pin
documents. The guard keeps the reset a no-op where COREPACK_HOME is
unset (test_build) or already run-unique (lint). The test step still
reuses the cache install warms in the same job, and poisoned hosts
self-heal on their next job without manual cleanup.

Pin the reset and the install/test COREPACK_HOME match in the workflow
meta-test, and bump the size baseline for the added lines.

* fix(ci): scope the corepack cache reset to the shard-owned path

Review found two over-deletion shapes in the nonempty guard: a runner
that exports COREPACK_HOME would lose its entire shared cache in the
build job, and hosted lint would lose the cache its pnpm-store step had
just warmed, re-exposing the install to a registry outage. Match only
the exact shard-owned path instead; the build and lint copies become
unconditional no-ops and the three install bodies stay byte-identical.

Add an execution test that runs the shipped guard fragment under a
scrubbed environment across five cache shapes: it kills the dead-guard
mutant the string pins survived, and a widened-guard mutant dies on the
shared-cache survival cell. State the incident as observations with the
corruption source undetermined, and correct the stale per-run rationale
beside the pin.
2026-11-05 09:27:53 +08:00
Shaojin Wen 8a7c0d17d8 fix(managed-agent): find the Flyway scripts in the module jar the server ships them in (#16175)
* fix(managed-agent): find the Flyway scripts in the module jar the server ships them in

Since #15871 split the server into Maven modules, its Flyway scripts ship
in BOOT-INF/lib/qwen-managed-agent-persistence-*.jar rather than in
BOOT-INF/classes/db/. The upgrade command's check read only the classes,
so on every archive since then its database row warned "cannot compare the
Flyway scripts (no Flyway script found in ...)", and a release that changed
or reordered a script the installed database ran went unflagged.

migrationScripts() now also reads db/**/ at the root of each nested jar in
BOOT-INF/lib/. Spring Boot stores those uncompressed, so their directories
are read in place; a compressed one, which Spring Boot would not load, is
skipped. A script keeps its path as its name wherever it ships, so one moved
from the classes into a module compares as unchanged.

The reference distribution workflow now reads the scripts out of the exact
archive it built, so a later change of layout fails there rather than
turning check's row into a standing WARN.

* fix(managed-agent): read a Flyway script shipped twice with different contents as unreadable

The reader keys the scripts on their classpath path, and the nested jars
give that key a second source. Two copies of one path with different
contents left whichever the central directory listed last in the map, so
the database row compared one arbitrary copy, while the class loader would
hand Flyway the other. Matching copies are still one script; differing
ones now make the jar unreadable, which the row reports as "cannot compare
the Flyway scripts".

* fix(managed-agent): name both places that ship a differing Flyway script, and pin the local extra field

- When one script path ships twice with different contents, the error now
  names the two places, BOOT-INF/classes or the nested jar, so the
  operator need not unpack the jar to find the second copy.
- The test zips can carry a local-header extra field, as archivers add for
  timestamps or alignment, and the nested-jar case reads entries past one,
  so dropping that term from dataStart fails the suite.
2026-11-05 08:10:28 +08:00
Shaojin Wen b9e20830f7 fix(verify-pr): post flaky demotions on closed PRs and pin the blank class (#16135)
* fix(verify-pr): post flaky demotions on closed PRs and pin the blank class

The closed-PR skip from #16050 also caught the flaky variants of the
cancelled, failed-before-report and results-unavailable notices, and the
report body of a round that wrote no report.md. Those render ❌ and, on
a merged PR, name non-deterministic tests now on main, so they post
unless this run's own report already carries the demotion.

- publisher: the closed-PR skip leaves FLAKE_VERDICT=flaky alone,
  except beside this run's own report
- publisher: the gates line says its tests are "not counted
  individually above", since each suite run now counts as one
  assertion (zh: 不逐条计入上面的断言)
- checker tests: pin the blank class on their own (whitespace refused,
  U+0085 and U+FEFF accepted); before, only the shared parity table in
  the workflow suite caught a revert to trim()
- SKILL: rewrap the over-long local-path line

* fix(verify-pr): scope re-run advice to open PRs and pin the gate condition

The results-unavailable notice can now land on a merged PR, where the
verify lane refuses `@qwen-code /verify`: both variants now offer the
re-run only while the PR is open.

The flaky exemption keys on the gate verdict, which holds only because
the gate step runs on a prepared round; a test now pins that condition.

* fix(verify-pr): skip a flaky notice only beside a report that carries it

On a closed PR, a flaky notice with no live status of its own to
finalize is skipped beside this run's own report, as a duplicate of the
demotion that report carries. That report can come from an earlier
attempt whose gate did not diverge. A re-run attempt whose own live
status is missing (that post is best-effort) then lost its ❌ to the
run log, beside a report that never showed it.

A flaky verdict now adds a verify-flaky marker to the header, and the
closed-PR skip drops a flaky notice only when this run's own report
carries that marker in its header.

* test(verify-pr): pin the own-comment lookup's initializers

The publish step runs under `set -u`, and a failed comment listing or bot
identity lookup leaves OWN_ID, OWN_SUBSTANTIVE and OWN_FLAKY at their
initial values. Dropping any of those three lines kept the suite green:
no case made either lookup fail. On such a run the step would instead abort
with "unbound variable" and post nothing.

The upsert stub can now fail the listing or the identity lookup. Under
either failure, a report posts fresh rather than patching a status it cannot
see. A flaky notice on a closed PR also posts, because the lookup cannot
tell whether this run's report already carries the demotion. Deleting any
one initializer now fails the test on its own unbound variable, which the
harness now prints with the failed exit status.
2026-11-05 02:42:14 +08:00
Shaojin WenandQwen-Coder 9123d5ee04 fix(managed-agent): refuse every URL fetch refuses and say why a health probe failed (#16110)
* fix(managed-agent): refuse every URL fetch refuses and say why a health probe failed

start and verify refused a listener URL only when URL.canParse rejected
it, so a --server-url without a scheme (proxy.test:8443), with a user
name or password, or on a port the Fetch standard blocks (6000, 10080)
passed the check and then failed every probe until --timeout. They now
refuse each of those before anything runs, and a test checks that the
runtime's fetch rejects every URL the check refuses.

A probe that is sent and still fails now says what it got: start's
timeout names the last probe's answer and verify's health row names its
own, such as "HTTP 503, status DOWN", "self-signed certificate" or
ECONNREFUSED, where both said only "not UP".

The listeners() comment names the reachesHarness option verify passes.

* fix(managed-agent): refuse a Harness URL that is not http or https in upgrade start

start checked the Harness URL with new URL() alone, so an opaque value
such as proxy.test:8443 parsed with no host and no port: start then
waited out --timeout for the Harness to listen on port 80 of an empty
host. It now refuses any Harness URL that is not http or https before
it starts a unit, as the preflight would fail it later anyway.

The probe-answer test also runs the runtime's own fetch against a port
nothing listens on, so the ECONNREFUSED wording comes from a real error
rather than a constructed one.

* fix(managed-agent): keep the endpoint's answer when the deadline cuts start's last probe short

start's last health probe gets the 1 ms the deadline leaves it, so against a
real endpoint it times out before any answer arrives, and the timeout message
reported that abort instead of what the endpoint had been answering, such as
HTTP 503, status DOWN. A probe that times out on a budget the deadline cut
below the per-probe cap no longer replaces an earlier answer; one that runs
out of its own 5 seconds still does, since the endpoint stopped answering.

The test rig's fake fetch now awaits a response function, so a test can model
an endpoint that answers late and honours the abort signal. Also pins the
IPv6-zone diagnosis to a percent inside the brackets.

* fix(managed-agent): treat a health body the deadline stalls as a cut-short probe too

A health endpoint that sends its headers and then stalls the body made
response.json() reject with the signal's TimeoutError. That branch did not
report timedOut, so start's guard took the probe for an answer, and a probe
the deadline cut short replaced an earlier HTTP 503, status DOWN with
"HTTP 200, The operation was aborted due to timeout". The body-read branch
now reports timedOut as well.

The last-answer test gains two real-socket cases: a body that stalls after
its headers keeps the earlier answer, and a refused connection after an
answer replaces it, which pins the timedOut half of the guard. The design
doc says when the 5-second limit counts as the probe's own, and that any
other failure replaces an earlier answer.

* fix(managed-agent): sanitize health text and align upgrade refusals

Address review R1-1..R1-5 on the upgrade URL/health change:
- R1-1: neutralize terminal control bytes (C0/DEL/C1 -> U+FFFD) in the
  endpoint-derived health answer before it is interpolated into start's
  timeout refusal and verify's health row, so a queryable endpoint cannot
  move the cursor, split a row, or write the clipboard (OSC-52).
- R1-2: the Harness URL refusal reuses fetchRefusal when the value does not
  parse, naming an IPv6 zone exactly as --server-url does.
- R1-3: start's timeout labels the retained value 'the last answer', matching
  the design doc, since a deadline-cut probe keeps an earlier answer.
- R1-4: pin verify's URL refusal (listeners runs it with reachesHarness:false)
  with a test asserting it refuses before any probe.
- R1-5: tear down the 'closes' test server via t.after so a failing assertion
  cannot hang node --test to the job ceiling.

Tests: 63 pass; R1-1/R1-2/R1-4 mutation-witnessed (printable no-op, refusal
gated on reachesHarness, zone reason reverted each redden their pin).
Prettier and ESLint clean.

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

* test(managed-agent): exercise the health sanitization over a real socket

Add a real-scenario pin for R1-1: a local HTTP server serves a status field
carrying cursor moves, a newline and an OSC-52 clipboard write, and verify
runs against it through the runtime's own fetch — the real
health() -> printable() -> row() -> out() path, not the rig's fake. Asserts
the printed health row keeps no control byte while the readable text survives.
64 tests pass.

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

* fix(managed-agent): mask a refused URL's password and name why verify's API reads failed

Found on a real systemd deployment (real control plane, Harness, MySQL,
TLS and nginx):

- A refused --server-url was printed as typed, so a URL with a password,
  which fetch refuses, put that password in the output. The refusal now
  prints it as ***.
- verify's session and turn rows still said only "fetch failed" where the
  health row above them named the cause, such as "self-signed certificate
  in certificate chain". API reads now take the same cause.
- Text the API hands back (an error reason, a body that fails to parse,
  a Session ID, an event type) reached the session and turn rows with
  its control bytes; it is now neutralized as the health answer is.

The design doc says the probe answer's control characters show as U+FFFD.

* fix(managed-agent): show a health status that is no string as its JSON

printable() ran String() on what the endpoint returned, and String() throws
on a valid JSON object whose own toString is no function, such as
{"status":{"toString":1}}. health() is not inside a try, so on a real
deployment verify exited with "Cannot convert object to primitive value"
before it printed its health row, and start's probe loop stopped at the
first probe. A value that is no string is now shown as its JSON, which is
also what the endpoint actually said; an API error reason gets the same.

* fix(managed-agent): make the upgrade reports total and bounded, and refuse only what a phase dials

Review round 5 traced its findings to two shared causes, fixed at the
source rather than one site at a time.

Endpoint text in the reports (printable, withoutCredentials):
- printable() no longer throws on any parsed JSON value: a status nested
  deeper than JSON.stringify() recurses showed a bare "Maximum call stack
  size exceeded" from verify and left start's server running without its
  guidance; it is now shown as [unprintable].
- It also stops at 200 code points, cut after the control bytes are gone
  and never inside a surrogate pair, so a long status cannot scroll the
  report away. The item count on the session row goes through it too.
- withoutCredentials() masks the whole user information, so a token in the
  user-name position is masked, and masks it textually when the URL does
  not parse. The Harness refusal masks its URL as well.

URL checks (listeners, the Harness guard):
- A phase is refused only over a URL it dials: start and verify probe the
  health endpoint, and only verify --tenant reads the API. An API on a port
  fetch refuses behind a separate management port no longer stops start.
- The Harness URL is checked as the preflight checks it, an http(s) origin
  without user information, a query or a fragment, so start refuses it
  before it restarts a running Harness rather than after.

* fix(managed-agent): take the Harness verdict from the preflight and check the API URL only when verify reads it

- verify decides whether its session row reads the API before it checks
  the listeners, so a signed deployment, or --actor without a trusted
  actor header, is no longer refused over an API URL it never dials.
- The configuration resolver now prints the preflight's own verdict on the
  Harness URL, read untrimmed as java.net.URI reads it, and start refuses
  on it before restarting the Harness: a host with an underscore, an empty
  user name or a padded value no longer restart the Harness only to fail
  the preflight.
- withoutCredentials masks everything up to the last @ of a value the
  parser rejects, so a password holding / ? or # stays hidden.
- printable counts code points in one pass and keeps only the head, so a
  huge endpoint answer costs no copy of itself.
- The request an API error names is bounded like the rest of the row.

* fix(managed-agent): print the Harness URL untrimmed, as the server parses it

A padded qwen.managed-agent.harness.base-url fails the preflight, and the
start refusal now shows the padding instead of a value that looks valid.

* fix(managed-agent): judge the Harness URL only where the server reads it

The resolver passed on the preflight's verdict on the Harness base URL
with no regard to the Harness's state, so start refused a value that a
disabled or pooled Harness leaves unread: the preflight skips its
harness row before reading it, and the server never dials it. The
verdict is now "skip" there. start refuses on the preflight's grounds
(user information, a query, a fragment, or anything java.net.URI reads
differently) only on a "false" verdict, and its own reasons only word
that verdict. It still refuses a URL that is not http or https, since it
waits for the Harness there. Without a verdict, as when the
configuration does not resolve, the preflight judges the URL once the
Harness is up.

verify now checks the type of the values the API chose before using
them. Building a route from a Session id, matching an event type, or
reading an event sequence coerced the value first, and that throws on
an object such as {"toString":1} or on an array nested deeper than the
stack, so printable() never got to show what came back. A Session
without a string id fails its row with that value, an event type that
is not a string fails the turn row, and a sequence counts only as a
number or a numeric string.

The start tests now check every run's output for the password, not
only the last run's.

* fix(managed-agent): judge a pooled Harness URL, and read only lists from the API

The previous commit gave "skip" for a pooled Harness as well as a
disabled one, since the preflight skips its harness row in both before
reading the base URL. A pooled server still reads that URL, though:
each member's QwenHostedHarnessConnector is built from it and refuses it
by the same rule as the preflight. The pool's refresh drops a member
whose connector refuses it, so such a pool accepts no Session while the
server reports UP. Only a disabled Harness gets "skip" now. A test pins
the verdict to whether the connector builds, for a single Harness and
for a pool. Since the verdict is now the server's rather than only the
preflight's, the refusal says "the server does not take".

verify reads Sessions, items and events only as lists. Data that is not
a list fails its row and says what came back: iterating it would throw,
or walk a string, and its length could be any value. A listed entry of
0, "", false or null fails the session row instead of being reported as
no Session. An event sequence moves the cursor only as a whole number,
and only forward: Number() reads "Infinity", "0x10" or " 4 " as numbers.

The user guide now says that, when the configuration does not resolve,
the preflight judges the Harness URL after the Harness starts.

* fix(managed-agent): keep start's own Harness URL rules without a verdict

When the configuration does not resolve there is no verdict. The previous
commits then let start accept any http(s) Harness URL and left the URL to
the preflight. A pooled preflight never reads it, though, and every pool
member's connector refuses user information, a query or a fragment, so
the pool would start and accept no Session. Without a verdict, start
refuses those itself again, since the server refuses them wherever it
reads the value. A "true" or "skip" verdict still asks nothing more, and
the generic wording is kept for a "false" verdict.

HarnessConfigurationTest now builds a pool through the real member
factory and shows that the single base URL decides whether a member is
accepted. The pooled half of the agreement test checked nothing the
single half did not, so it is gone.

verify now requires every page to carry its data as a list: a body that
is no object, or one whose data is null or missing, fails the row instead
of reading as no Session. A Session id of "", "." or ".." fails, since
fetch resolves dot segments and an empty id reads the list. Only
terminal: true ends a turn.

The docs no longer say the server takes only an origin, since a path is
fine. They say which component refuses the URL, and they word the
sequence rule as the code applies it.

* fix(managed-agent): sort the test's static imports, and refuse a --server-url query

The Mockito static import sat between two JUnit ones, so the verification
module's ImportOrder check failed. The static block is now sorted.

start and verify append /actuator/health and the API paths to
--server-url, so a query or a fragment there took the paths in. With
http://proxy.test/?a the command probed /?a/actuator/health, and fetch
dropped a fragment along with the path after it. Such a URL is now
refused up front.

A listed Session entry with no id is now shown as itself rather than as
"undefined". A health answer whose JSON parse error quotes control bytes
is pinned to come out sanitized.

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-11-05 01:42:38 +08:00
Shaojin WenandQwen-Coder 4d4e4fc9ca feat(channels): role-aware action templates for GitHub bound-session delivery (#16258)
* feat(channels): role-aware action templates for GitHub bound-session delivery

Bound-session delivery injected a hardcoded generic prompt and routed each event to a single winner, so a PR followed by review, verify, and fix sessions in different workspaces could only ever reach one of them, with no declarative way to say what each should do.

Bindings gain an optional closed-vocabulary role (review/verify/fix): the command-position backfill mints review/verify from /review and /verify-pr, fix binds explicitly through the metadata route (null clears), and a re-offer never overwrites an existing role. A new channel-level boundSessionActions record maps event classes (watch:<type> or notification reason, optionally role-qualified) to operator instruction templates rendered with GitLab-style %var% substitution into a labeled trusted block; substituted GitHub values stay sanitized, capped data.

With the map configured, routing generalizes the base design's single-winner decision to one winner per role with fan-out across roles: per-(event, target) durable tasks, independent backpressure, role-scoped gone fallback, and public fallback only at zero live followers. With the map absent, every behavior and the generic prompt stay byte-identical (pinned by tests).

Design: docs/design/2026-11-04-github-channel-bound-session-action-templates.md (EN/ZH); the base design's Key decision 4 carries a forward pointer.

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

* test(cli): expect the command-stamped role in backfill binding assertions

The /review command now stamps role: 'review' on every binding form it mints (role-aware bound delivery). Four strict-equality assertions in the backfill suite predated the field: the Aone URL-form and unplanned-repair cases, the fork-url state-pairing case, and the capped-plan live-bridge sync projection. CI's sharded Test lane surfaced only the first before fail-fast skipped the remaining shards; the full local suites (backfill 177, server 1442, bridge 1043, transport 431, daemon-worker 182, session-list/pr-refresh 87) now pass.

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

* fix(web-shell): delete a record key when its editor row is cleared

Addresses the triage review's record-renderer observation. The save serializer already filtered empty values (so no unsavable state existed), but the form state kept a touched-then-cleared key as "" until save; the editor now deletes the key on clear, matching what the upsert payload will contain. A new dialog-level test pins the end-to-end outcome for the 80-key boundSessionActions surface (and the GitLab record fields sharing the renderer). Also documents at roleByNumber that per-number uniqueness is guaranteed upstream by noteMention, so the Map constructor's last-wins semantics cannot override the first-wins command rule.

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

* fix(channels): close the role-aware delivery review findings (C1-C5, D1-D3)

C1: the first task of a multi-target comment dispatch now carries the full fan-out intent; recovery reconstructs missing siblings BEFORE applying the survivor's event-wide dedupe, so a crash between claims no longer loses the unclaimed roles permanently.
C2: a durable bounded delivery ring (github-bound-delivered.json) records every private acceptance; no gone-fallback or untargeted recovery may route an already-delivered event publicly, even after every follower disappears and the daemon restarts. An unreadable ring fails closed for the public fallback.
C3: delivery-time re-validation retires a role-aware comment whose role lost its match on a complete snapshot instead of injecting the obsolete role's instruction into the frozen session.
C4: split-sidecar merge lets the newer half win on role, absence included, so an explicit role:null clear survives an archive/restore merge.
C5: FIELD_LABEL_KEYS.github registers boundSessionActions, making the zh-CN label and description reachable.
D1: the map-skip retire now requires a complete fresh snapshot; under a partial or failing scan the event defers as backpressure instead of being consumed on incomplete evidence.
D2: comment-lane events resolve a lane class (comment) after the thread reason, so fix:comment catches comments delivered under mention/subscribed/author while reason-specific keys still win.
D3: the worker accessor validates roles against the shared PR_BINDING_ROLES constant instead of hardcoded literals.

Each correctness fix has a dedicated regression; C1/C2/C3 regressions were mutation-witnessed (removing the fix turns them red). Design docs (EN/ZH) and the user guide updated to the new semantics.

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

* fix(channels): close the role-aware delivery re-review findings (C1 residual, R2-1..R2-4)

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

* fix(channels): complete fanout slots durably and keep private retirement terminal (R2-2/R2-4 residuals)

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

* fix(channels): make the fanout completion mark fail-closed with durable acceptance evidence (R2-2 write-failure gap)

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

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-11-05 01:36:38 +08:00
Shaojin Wen 8e9b429ff8 perf(managed-agent): index the Runtime slot lookup by isolation key (#16325)
The CSI marker probe runs on every ordinary Session write, including each
activation renewal, and looks up the Session's Runtime slots by tenant and
isolation key. No index covered that lookup, so every call scanned the whole
slot table, and the fence's locking variant locked every Session's slots.

Index ma_runtime_slot.isolation_key, as ma_runtime_binding already does. A
MySQL integration test pins the probe's plans at 1,000 and 10,000 slots and
checks that the fence leaves other Sessions' slots writable while still
holding off a second slot for the fenced Session.
2026-11-05 00:51:22 +08:00
Shaojin Wen f217a8e5d9 fix(managed-agent): restore JournalCommit under its class-size ratchet (#16323)
#16284 pinned JournalCommit at 880 lines and #16275 grew it to 886, so every
Java lane fails ClassSizeRatchetTest on main.

The hosted-cleanup admission probe that #16275 added belongs with the cleanup
bookkeeping it guards, so move it behind
HostedCleanupReferences.containsCleanup and restore the class to its pin.

Fixes #16321
2026-11-04 23:54:05 +08:00
Shaojin Wen d4083f4c82 fix(serve): read PR sidecars directly for the bindings snapshot (#16241)
* fix(serve): read PR sidecars directly for the bindings snapshot

GET /sessions/pr-bindings paginated the activity-ordered session list
and had to flag every multi-page scan partial, so a workspace with more
than one page of active sessions never produced a complete snapshot and
the GitHub channel deferred every unmatched PR event to the dead-letter
horizon instead of answering through the channel.

List the workspace's *.pr.json sidecars once (the enumeration the PR
refresh timer already uses), read each sidecar, and take attribution
and activity from the session's persisted record, refined by the live
session when it is live. partial now means an I/O failure only. Scan
draining and transitioning workspaces too: their files stay readable
and they still serve sessions. Exclude machine-owned agent task
sessions explicitly. Update both design docs, the user guide and the
adapter's partial-snapshot diagnostics accordingly.

* test(serve): compare aggregated PR bindings as a set

Directory listing order and the random temp directory names made the
cross-workspace assertion order-dependent; CI sorted the uppercase
suffix first. Compare the (workspace, session) pairs as a set.

* fix(serve): harden the PR bindings sidecar scan

Append bindings the bridge holds in memory but that have not reached a
sidecar yet, so the window between the bridge update and the sidecar
write (or a failed write) cannot read as an authoritative absence.
Treat a transcript that exists but yields no records as unreadable
(partial) instead of listing the session unattributed, skip an active
sidecar left behind by an archived session, decide non-targets before
counting failures so a machine-owned session's corrupt sidecar cannot
make the answer partial, emit tombstone rows without the transcript
projection, validate live timestamps before the recency comparison, and
log per-session failures as one line per workspace scan.

Remove the write-only prsReadFailed flag now that the snapshot reads
sidecars itself, retire the last draining-workspace wording from the
adapter's diagnostics, and state the real per-row cost and the reason
for having no page cap in the design doc.

* fix(serve): treat a failed live session list as a partial bindings answer

The live list is the only source for a binding that has not reached a
sidecar, so a failed read must make the answer partial instead of
reporting it complete without those bindings. Merge bridge-held PR
numbers into a listed non-empty row (a tombstone stays empty), decide
hidden-catalog and archived ownership for empty sidecars too, compare
activity timestamps with the shared chronological rule, and log a
registry entry without a runtime like the other partial causes.

Reword the adapter's partial diagnostics to name every cause and count
listed rows, update the shared snapshot contract in channel-base, pin
the corrupt-sidecar row's presence in the session-list test, and make
the design docs' cost model consistent.

* fix(serve): decide live-only binding ownership from the persisted record

A restored agent task session carries no sourceType on its bridge entry,
so the live-only branch read ownership from a field that is not there
and emitted a machine-owned session as a delivery target in a complete
answer. Read the persisted record first, as the sidecar loop does, and
drop the isArchived guard the bridge never populates. Cap the bridge
merge the way the session list does (append only while the persisted
list is below SESSION_PR_LIST_LIMIT, then keep the newest) and log the
no-runtime cause under test.

Keep the adapter's partial hint firing while retention holds bindings
an empty partial no longer lists, and stop blaming the binding lookup
when the cold-empty cap was reached by successful empty partials. Name
the live-list cause in the design docs' partial definition and state
the two cost legs separately.

* fix(serve): keep unknown sessions unlisted when merging bridge bindings

A session whose sidecar or transcript could not be read is counted as
unknown and left out of the answer, but the bridge merge re-listed it
from its live PR subset, which made the adapter treat it as enumerated
and drop the row it was retaining with the persisted numbers. Track the
unknown sessions and skip them in the merge. Attribute live-only rows
through the same resolver as sidecar rows so an unreadable transcript
makes them unknown too, count live-only sessions in the diagnostic's
denominator, and pin that transitioning and blocked workspaces with a
runtime are scanned.

Make the adapter's partial hint say the lane is deferred while the
cold-empty gate holds instead of claiming issue events flow, and name
the binding lookup when failed lookups seeded the cold-empty cap and a
single empty partial lands it. State the live-merge rule and the real
filesystem-leg ceiling (sessions ever bound) in both design docs.

* fix(serve): decide binding ownership before any read that can fail

A machine-owned session whose transcript or sidecar could not be read
was counted as a scan failure before its ownership was consulted, which
made every answer partial on behalf of a session that is never a
delivery target. Resolve ownership from whichever record states it as
soon as the id is known, and skip non-targets before the sidecar read.
Key the bridge top-up by PR number and repository so same-numbered PRs
from different repositories both survive.

Log once per streak when the channel worker itself downgrades an answer
by dropping malformed rows, and name that cause in the shared contract,
the user guide and the adapter's hint. Make the failure-path cold-empty
cap message say the lane stays closed while no snapshot has ever
succeeded, and align the design docs' risk summary with the ever-bound
cost ceiling.

* fix(channels): attribute the cold-empty cap by counted failed lookups

Follow-ups to the PR bindings sidecar scan review (#16241, round 6).

- The GitHub channel's cold-empty cap line attributes the stall from the
  failed lookups counted within the streak. It used to read
  boundSnapshotFailureStreak, which any successful answer resets, so a run
  of failed lookups broken by a single empty partial was blamed on the
  daemon's storage. The majority cause leads and the other count is kept.
  The same line now says the worker reports how many rows it dropped
  rather than naming one.
- The bindings route falls through to the live createdAt when updatedAt
  is present but unparseable, instead of discarding both.
- Docs: the hidden-catalog "excluded without partial" promise is scoped to
  sessions whose ownership can be read; the retention rationale describes
  the sidecar projection topped up from the bridge and trimmed to the cap;
  the user guide's list of partial causes includes a chats directory the
  daemon could not list.

* fix(serve): key the bridge top-up by PR number and ignore restore time

- The bindings route tops a listed row up from the bridge by PR number
  alone, as the session list's merge does: the sidecar writers replace an
  entry by number when it is re-bound to another repository while the
  bridge keeps the replaced entry, so the repository-keyed merge returned
  both and kept delivering the replaced PR's events privately.
- For a persisted session the live createdAt no longer counts as
  activity: the bridge stamps a fresh one on restore, so merely restoring
  an idle follower could change which session receives a PR's events.
  The createdAt fallback is reserved for a session with no persisted
  record yet.
2026-11-04 23:52:24 +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
Shaojin Wenandverify-round e2d3243863 fix(web-shell): express the PR watch events dependency in the channel editor (#16236)
* fix(web-shell): express the PR watch events dependency in the channel editor

The GitHub adapter rejects a non-empty prWatchEvents unless
deliverToBoundSessions is enabled, but the editor never expressed that
dependency: operators could tick watch events, press Save, and receive a
raw English daemon refusal that read as "the selection cannot be saved".

Group the bound-delivery switch and the watch-event checkboxes into one
framed panel, disable the checkboxes with an inline hint until the switch
is on, and mirror the runtime rule as a client-side validation backstop
for hand-written stored configs that carry events without delivery.

* fix(web-shell): keep stored watch selections clearable while delivery is off

Review follow-ups on the bound-session panel:

- A stored selection stays uncheckable-while-gated no more: with delivery
  off, selected events (including unknown stored values) can be cleared
  directly, while new selections remain disabled, so turning delivery off
  never strands the operator between a dependency error and disabled boxes.
- Turning the delivery switch on clears the stale requiresBoundDelivery
  error instead of leaving the instruction under the now-on switch.
- The disabled hint moves into FieldShell's existing hint slot, restoring
  the "description is the first paragraph" invariant; the hint gets an id,
  aria-describedby on the checkbox group and a persistent polite live
  region so screen readers hear why the boxes are gated.
- The render gate now carries the same github scope as the validation
  mirror, and checked-but-disabled rows keep the selected border.
- Tests: shutdown path, stale-error retirement, aria association, and a
  stored delivery-on mount; visuals scenario seeds the bound-session
  fields and captures the gated and enabled states.

* fix(web-shell): scope dependency-error retirement to the dependency message

The switch-on clear deleted the whole prWatchEvents error slot, wiping the
higher-precedence invalidOption message while an unknown stored value was
still ticked; and unticking the last selected event — the other remedy —
left the dependency alert over an empty, valid selection. Retire only the
dependency message, from both remedies, and pin the live region's
persistence (empty region mounted before the hint, same node across the
transition) plus the hint-less rendering of the pre-existing hint slots.

* fix(web-shell): retire watch-events errors by re-deriving validity

Round-4 review follow-ups, all in this PR:

- R3-1: replace the string-matched dependency clear with a per-field
  re-validation on edit, so unticking an unknown stored value retires the
  invalidOption instruction it satisfies (and a remaining valid event swaps
  the slot to the still-unsatisfied dependency message).
- R3-3: submit now scrolls the first erroring field into view even when it
  is not an advanced field; a stored watch-events refusal below the fold is
  no longer invisible.
- R3-2: pin the empty-panel guard with EN/zh absence assertions on a
  non-GitHub descriptor.
- R3-4: table-driven parity test feeds the plugin's own descriptor and
  config through both the editor mirror and the daemon validator
  (test-only devDependency, never bundled); a lone change on either side
  now fails the table.

Each fix carries a mutation witness; 286 channel tests green.

* fix(web-shell): re-derive only shown errors, in both switch directions

Round-5 review follow-ups, all in this PR:

- R4-1: refreshErrors re-derives only the error keys already on screen, so
  a remedy retires exactly the instruction it satisfies without surfacing
  validation the operator never submitted (submit stays the only population
  site), and the delivery switch re-derives in both directions so a shown
  instruction is never silently dropped. New tests pin pre-submit silence
  and shown-message survival across switch toggles.
- R4-2: narrow plugin.management once at module scope in the parity test so
  the optional contract member stays TS18048-clean if test files ever join
  the package typecheck, with a clear error instead of a load-time TypeError.
- Deferred note from the same round: pin the data-disabled styling hook on
  gated rows (dimmed) versus clearable stored rows (selected styling).

Each fix carries a mutation witness; 288 channel tests green.

---------

Co-authored-by: verify-round <verify-round@local>
2026-11-04 14:42:07 +00:00
Shaojin Wen 815c1d3768 feat(web-shell): collapse channel editor tuning behind advanced settings (#16239)
* feat(web-shell): collapse channel editor tuning behind advanced settings

The shared channel editor renders every descriptor field flat, so a fresh GitHub instance shows roughly sixteen controls and the fields needed for first setup are buried under rarely changed tuning. Move the tuning fields into one collapsible advanced panel and hide the default message route until a route exists, keeping the default view to identity, credentials, conversation isolation, and access control.

Collapsing never hides state: the panel opens automatically when any advanced field is configured, shows a count badge while collapsed, and forces itself open when a collapsed field fails validation. Save and validation semantics are unchanged because both already iterate every descriptor field regardless of rendering. The watch-events dependency UX is left to #16236 so the two changes stay complementary.

* fix(web-shell): forward refs through the collapsible wrappers

Review of #16239 showed the three new wrappers accept Radix props including ref, but plain function components drop external refs on React 18, and the repository supports both React 18 and 19. Use React.forwardRef for the root, trigger, and content, pass each ref to its Radix primitive, and pin the contract in react18-ref-compat.test.tsx with both forward-ref type and DOM-ref assertions; a plain-function mutation turns exactly the three new type assertions red. Record the contract in both design documents as well.

* fix(web-shell): keep endpoints and operators visible, make collapsed errors reachable

Review round 1 on #16239 found the disclosure went too far in three places and left dead scaffolding behind. Primary endpoints (baseUrl for GitHub Enterprise / self-hosted GitLab, wsUrl for WeCom) decide where a token is sent, so they stay in Connection instead of collapsing; a fresh enterprise channel could otherwise save without its endpoint and send the token to the public API. Operators is a permission grant and stays in Access control, a deliberate product decision rather than a consequence of the key set.

The panel title becomes a real h3 wrapping the trigger (ARIA makes button descendants presentational, so the previous span-in-button was invisible to heading navigation), and a rejected submit now scrolls the first erroring advanced field into view instead of only opening the panel below the fold. The record rule is unified into one exported predicate shared by validation, the save path, and the visibility/auto-open readers; the unreachable routing section scaffolding is removed; a comment records why enum keys must stay out of the advanced set.

Each new mechanism carries a mutation witness (endpoint placement, heading, boolean arm, record arm, scroll), and the visuals scenarios now anchor the heading, the configured-instance auto-open, and the count badge in a real browser. Both design documents are revised in lockstep.
2026-11-04 16:46:45 +08:00
Shaojin Wenandverify-round 30079d6c3d test(managed-agent): ratchet persistence and runtime-broker class sizes (#16284)
The store decomposition (#16090, #16142) and the broker extraction cluster
turned the god classes into facades, but nothing stops them regrowing. Add
ClassSizeRatchetTest, the class-size gate the structure review left as an open
question, introduced now that the splits have landed. It pins every current
class >= 800 lines in persistence/runtime-broker at its present size
(ratchet-down-only), caps all classes at a global ceiling seeded at the current
maximum, forces any class reaching 800 lines to be pinned, and rejects stale
pins. Verification-only: no production code change. Each rule is mutation
verified; the bilingual design and the structure-review ledger (open question
resolved, acceptance criterion concretized) are updated in the same change.

Co-authored-by: verify-round <verify-round@local>
2026-11-04 16:46:05 +08:00
Shaojin Wen 5f0474abc4 test(integration): compare the ACP parity workspace home to its pre-run listing (#16266)
The guard hardcoded the two names the shared harness seeds into the workspace
home, so the next artifact that harness seeds would red every parity scenario
with a message about a runtime leak that is not there. Snapshot the listing
before the agent starts and compare against that instead, which states the
invariant without copying the fixture contents of another module.

Carries forward 1923e184 from #16254, which was pushed after that PR merged.
2026-11-04 13:41:50 +08:00
Shaojin Wen 9b1fcb8756 fix(channels): scope the poll cursor file to the workspace (#16231)
* fix(channels): scope the poll cursor file to the workspace

Polling channels (GitHub, GitLab, DWS) wrote their cursor directly under
~/.qwen/channels/ keyed by channel name, while every other per-channel
state file lives under the workspace scope directory. Channel names are
per-workspace configuration keys, so two workspaces running a channel of
the same name shared one cursor and, for GitHub, one set of dedupe rings.

Store the cursor under ~/.qwen/channels/<workspace-scope>/ with private
permissions. A channel without a scoped cursor reads the legacy global
file once so an upgrade keeps its position; the next save lands in the
scoped path and the legacy file is left for other workspaces and older
versions to read.

* fix(channels): tighten the temp cursor file before renaming it

writeFileSync's mode only applies when the file is created, so a
pre-existing temp file with looser permissions would carry them onto the
cursor. Chmod the temp file before the rename, matching the package's
other state writers, and pin it with a test.
2026-11-04 08:41:47 +08:00
Shaojin Wen 684c75b50d docs(managed-agent): reconcile structure-review ledger with the landed store decomposition (#16230)
The store physical aggregate split landed in #16090 (split Agent and Session
stores into immutable internal collaborators, retaining transaction facades)
and #16142 (trim delegation boilerplate), but the structure-review ledger still
listed ManagedAgentStore at 7,657 lines and ManagedSessionStore at 3,931 and
called the physical split "the remaining work". Reconcile both language
versions with origin/main 8d8217aeee so nobody plans a redundant split off
stale numbers: refresh the god-class table (ManagedAgentStore 1,170 /
ManagedSessionStore 357 facades, re-sorted), mark the P1 store split done with
credit and a link to the dedicated decomposition design, and note
ManagedSessionStore is decomposed while QwenHostedHarnessConnector remains.
2026-11-04 02:46:29 +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 8d8217aeee refactor(managed-agent): narrow EmbeddedRuntimeBroker to SessionQueryStore (#16216)
* refactor(managed-agent): narrow EmbeddedRuntimeBroker to SessionQueryStore

The broker holds the store only to forward it into WorkspaceRuntimeResolver
and ManagedRuntimeCoordinator (resolver static + constructor), which both
take SessionQueryStore, and makes no direct store call. Narrow the field and
all eight constructor overloads from the AgentStateStore composite to
SessionQueryStore; the sole caller RuntimeBrokerConfiguration still passes an
AgentStateStore, assignable to the role, so it is unchanged. Type-only.

Update the structure-review ledger in both languages: the broker moves from
candidate into the single-role audit (query group), tally 22 -> 23.

Full real Spring context boot (ManagedAgentServerIntegrationTest, 57 tests)
resolves the narrowed bean with no ambiguity; ModuleArchitectureTest and
Checkstyle clean; all modules compile.

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

* refactor(managed-agent): drop EmbeddedRuntimeBroker's write-only store field

Address review R1-1: the store field was never read — this.store = store was
its only occurrence besides the declaration, and the three forwarding sites
(WorkspaceRuntimeResolver, ManagedRuntimeCoordinator.resolver and constructor)
read the constructor parameter, which shadows the field. Remove the field and
the assignment; every constructor stays narrowed to SessionQueryStore and the
forwarding is unchanged. The broker still depends on SessionQueryStore through
its constructor, so the ledger entry stands.

Full real Spring context boot (57 tests) + ModuleArchitectureTest + 0
Checkstyle; all modules compile, confirming no read of the removed field.

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

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-11-04 01:38:18 +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
Shaojin WenandQwen-Coder 68c9fa27fb feat(verify-pr): add local Docker recipe with a host-side model proxy (#16077)
* feat(verify-pr): add local Docker recipe with a host-side model proxy

A local /verify-pr round needs the same isolation the CI lane provides,
but the skill only documented the boundary check, not how to run the
model credential through it. Add scripts/verify-pr-model-proxy.mjs, a
host-side relay that holds the real key and lets the container call the
model with a per-run bearer token, mirroring the CI lane's inline proxy
(POST /chat/completions only, nonce health check, per-chunk idle
watchdog). The listen address is a flag so the proxy can bind the Docker
bridge, and readiness is printed as one JSON line.

The SKILL recipe pairs it with a docker run template: the token travels
as QWEN_VERIFY_PROXY_TOKEN (not OPENAI_API_KEY, which the isolation gate
refuses), and the container's provider entry reads it via envKey. The CI
lane keeps its inline copy — the proxy holds the real key, so it must
not execute a script from the PR-head checkout.

Verified: 11 new pin tests plus all existing verify-pr suites (120
passed), and a real docker smoke — the gate passes inside
node:22-bookworm with the proxy token, refuses OPENAI_API_KEY with exit
4, and a container POST reaches a mock upstream with the real key
injected and the /v1 prefix preserved.

* fix(verify-pr): harden the model proxy after adversarial audit

Audit round 1 found the relay forwarded client hop-by-hop headers
(connection, transfer-encoding, expect, proxy-authorization, ...) to the
upstream, which RFC 7230 forbids and which fights undici's own framing
for the relayed stream. Strip them, with a chunked-body test that pins
the behaviour. Round 1 also found the CLI entry had no coverage: add
tests for the missing-config exit code and the printed JSON coordinates
(token override, base-path prefix, no real-key leak, health nonce), and
document that the printed baseUrl uses the listen address, so binding
0.0.0.0 yields a URL clients cannot dial.

Rounds 2 and 3 (open-ended re-read plus adversarial passes over the
updated diff) found nothing further. All verify-pr suites: 123 passed.

* fix(verify-pr): address review findings in the model proxy and checker

Round-1 review (12 inline findings at 70aed891bf). Proxy: abort the
upstream when the client disconnects before headers instead of paying
for a completion nobody reads (R1-1); make the idle watchdog pause-aware,
re-arm on drain, and match the client's own 240 s stream tolerance
(R1-3); bracket IPv6 listen hosts via an exported formatter and normalize
base paths once (R1-12). Checker: add --context so a host-held snapshot
pins the expected head instead of the writable artifact copy, and refuse
an unusable snapshot rather than silently falling back (R1-9).

Each guard is pinned by a test that was verified to go red when the guard
is removed (six mutations: pre-header abort, pause guard, per-chunk
re-arm, body error handler, IPv6 bracket, --context).

* docs(verify-pr): harden the local container recipe and record the design

Recipe: run the key-holding proxy and the checker from a trusted
checkout, never the PR tree (R1-6, R1-9); background the proxy with a pid
file and stop it at the end (R1-4); read the key instead of inlining it
(R1-5); use a named detached container driven by docker exec (R1-7);
require a standalone clone, the host uid and a fresh HOME (R1-8); mount
the full host-held metadata snapshot rather than the four-key artifact
copy (R1-10); and attest only inside, after rejecting credential-shaped
ignored files (R1-11). Add the bilingual design record (R1-2), cross-link
it from the local-round design, and pin the recipe's trust lines in tests
so reverting them turns the suite red.

* fix(verify-pr): address round-2 verification findings

Round 2 at 3509c2d1aa reported one Low and four Suggestions.

- Relay the exact <basePath>/chat/completions path. A suffix match also
  forwarded /admin/chat/completions to the upstream.
- Pin the response content-encoding strip with a gzip upstream: undici
  hands the proxy decoded bytes, so forwarding the header makes the client
  gunzip plain text.
- Reject credential-shaped ignored entries at any depth (a nested
  secrets/.env included) instead of only a root .env/.env.*.
- Name the round's engine as the released CLI and offer the tree build for
  a round that must exercise the PR's own CLI.

The CI inline copies keep their existing two-header strip: that lane is
untouched by design and a change there needs its own workflow review.

Tests: 141 passed. Three mutations verified red (exact path, gzip header,
nested credential pathspec). The review-pr check at this PR is red because
the bot lane exhausted ten retries on "429 rpm exceeded", not because of
this diff.

* fix(verify-pr): close round-3 trust-boundary findings

Round 3 at 3ccf38697d reported two Criticals against the recipe, one
fix-induced Critical, and four Suggestions.

- prepare, the credential scan, the isolation gate and the checker now all
  come from <trusted-clone>; prepare runs with the PR clone as its cwd so
  the HEAD check still applies (R2-1).
- The base commit's .qwen is extracted and mounted read-only over the
  tree, so the round's instructions and project settings cannot come from
  the PR (R2-2).
- The attestation is passed to the shell that starts the round, because
  every docker exec is a fresh shell (R1-11, fix-induced).
- The proxy token is exported from proxy.json and passed with a bare -e;
  the file is chmod 600 and removed at cleanup (R2-3).
- Connection-nominated hop-by-hop headers are stripped (R2-6).
- Recipe pins now enumerate every checker call site, require a non-empty
  docker-run slice, and seed the writable checker snapshot so a fallback
  regression is caught (R2-4, R2-5).
- New verify-pr-credential-scan.mjs lists ignored files one by one,
  refuses credential-shaped names at any depth, and leaves node_modules
  and dist alone — replacing the pathspec check that refused every
  installed or built tree (round-3 Low).

Tests: 148 passed; ten mutations verified red.

* fix(verify-pr): address round-4 review findings

Four P2 findings at 441a523927:

- The credential scan now limits git enumeration with credential pathspecs
  and depth-aware node_modules/dist excludes, so an installed tree's
  multi-megabyte ignored listing never overflows the output buffer.
- Those excludes apply inside workspace packages too, so packages/*/dist
  and packages/*/node_modules are not mistaken for project credentials.
- The recipe waits for proxy.json to carry a token and validates the
  exported value, so a truncated background write cannot reach Docker.
- The pre-header disconnect test waits until the mock upstream has
  recorded the request before destroying the downstream socket, instead
  of racing the proxy's own (correct) early abort.

Tests: 150 passed; three mutations verified red (enumeration pathspecs,
excludes, readiness loop).

* fix(verify-pr): refuse nested repositories in the credential scan

Round-4 verification at 19b14b1a8f left three Suggestions.

- A nested git repository is now refused instead of silently skipped: git
  does not descend into it, so a .env under tmp/pr-clone/ would pass. A
  vendored .git under node_modules/dist stays excluded like the files there.
- isExcluded is removed: the pathspecs already exclude those directories,
  and the surviving JS filter was unpinnable.
- The spaced Connection-nomination list is pinned; dropping trim made the
  proxy answer 502.
- The node_modules/dist credential exclusions are documented as deliberate.

Tests: 152 passed; two mutations verified red (nested refusal, trim).

* fix(verify-pr): address round-5 review findings

Four Criticals:
- The key entry works without a TTY: an interactive shell prompts, an
  agent's shell reads REVIEW_OPENAI_KEY_FILE and fails with a named error.
- prepare disables fsmonitor, hooks and credential helpers on the
  PR-controlled clone, and the scan refuses credential-bearing remote URLs.
- The proxy token is re-derived from proxy.json in the docker run step,
  because each step is its own shell.
- The credential scan covers .env~, *.p12, id_rsa*, gha-creds-*, and the
  other credential families the repo's own .gitignore names.

Suggestions:
- Guard call site moved to <trusted-clone>, <clone> defined for watch mode.
- The newest verify comment is snapshotted as previous-report.md.
- The real key is unset once the proxy owns it; the token is unset at cleanup.
- `{1..100}` replaces seq for macOS; --user and the proxy token's
  non-credential status are pinned; the scan abort is structural (`|| exit 1`).
- Step 6 wording matches the base-pin; the docs narrow the base-pin scope for
  root AGENTS.md/QWEN.md, list the real CI divergence, explain content-encoding
  in English, fix the zh token wording, and record the checker --context
  contract and the ownership attribution.
- The readiness gate compares the health nonce; --port is removed.

Test gaps closed: gzip header assertion, mid-body disconnect, reset event,
PUT, bare-base exact path, positive header passthrough, 502 transport
failure, full paused-stream equality, token shape, publish --context pin,
checker second positional, scan path fixtures, headers-only idle window.

Tests: 159 passed; twelve mutations verified red.

* fix(verify-pr): close round-5 verification findings

Two Lows reproduced at b5c717a5d5:
- prepare's hardening named clean filters but only disabled fsmonitor,
  hooks and credential helpers. It now enumerates every local
  filter.*.clean/smudge/process and empties it with a -c override before
  any git read; a planted-filter fixture proves the override with a
  positive control that fires without it.
- The nested-repository refusal over-refused installed trees whose
  node_modules holds a real vendored git repo (git reports the directory
  entry, which the exclude pathspec cannot match). The enumeration is now
  segment-filtered in JS, so vendored dependencies stay excluded while a
  nested project repository is still refused.

Suggestions and nits:
- The remote-URL guard reads url.*.insteadOf as well as remote.*.url and
  refuses any http(s) userinfo, including a token used as the username;
  ssh://git@ stays benign.
- Public keys (.pub) are no longer treated as credentials, and a
  backup.env~ fixture pins the trailing-tilde family.

Tests: 163 passed; six mutations verified red (filter overrides, vendored
segment filter, userinfo breadth, insteadOf enumeration, public-key
exclusion, backup.env~ pathspec).

* fix(verify-pr): close round-6 verification findings

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

* fix(verify-pr): close round-7 verification findings

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

* fix(verify-pr): address round-8 review findings

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

* fix(verify-pr): close round-9 review findings

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

* fix(verify-pr): close round-10 review findings

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

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-11-03 14:13:59 +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
Shaojin Wen 95ef544479 test(managed-agent): reserve Hosted gate ports below the ephemeral range (#16174)
Hosted ITs reserved each port they hand to Spring, the Harness, a Spring
fixture or the Runtime Broker by binding port 0 and closing the socket.
The number stayed in the kernel's ephemeral range, so anything binding
port 0 or opening a connection could take it before its owner bound it;
9 of 240 recent CI runs lost a cell that way.

HostedGateTestSupport.reservePort() now hands out numbers from
[20000, 28000) with one counter per fork, probing each on 127.0.0.1,
and falls back to kernel-chosen numbers (never repeated in the fork)
on a host whose ephemeral range starts below 28000. Every Hosted
reserve-and-close site uses it, and HostedMonitorRunFaultGatesIT's
private copy of the same allocator is removed.

HostedTeamLeadMySqlIT's boot retry looked for the Harness's old move
message, which --profile hosted-harness no longer prints; it now treats
the exit message as a port conflict, and its teardown deletes the
attempt's rows even when settling the tenant's work fails.
2026-11-03 12:51:19 +08:00
Shaojin WenandQwen-Coder 1301ec30b1 refactor(managed-agent): narrow the four deferred store consumers to their role interfaces (#16121)
* refactor(managed-agent): narrow the four deferred store consumers to their roles

Complete the deferred migrations from the AgentStateStore role split
(#15962). ChildWorkspaceService, WorkspaceRuntimeResolver and
ManagedAutomationService now inject SessionQueryStore; TaskCancelCoordinator
injects SessionLifecycleStore. Each consumer's calls were verified to be a
subset of the target role. They were left on the composite only because the
automated detector misreads their this(...) constructor delegation as
forwarding. Type-only; the ledger note is updated in both languages.

Focused build green: 84 tests (incl. the real-Spring-context
ChildWorkspaceServiceTest), ModuleArchitectureTest, 0 Checkstyle violations,
all 7 modules compile.

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

* docs(managed-agent): broker wiring is no longer a multi-role composite consumer

Address the non-blocking review note: EmbeddedRuntimeBroker forwards the
store only into WorkspaceRuntimeResolver and ManagedRuntimeCoordinator,
which both take SessionQueryStore, so the ledger no longer lists broker
wiring among the multi-role dependencies that keep the composite, and names
EmbeddedRuntimeBroker as a candidate for the same narrowing. Both languages.

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

* docs(managed-agent): fold the four narrowed consumers into the audit tally

Address review R1-1: the consumer audit still read 18 single-role consumers
while a appended sentence recorded four more narrowed since, so the tally and
per-role groups understated the set by four. Fold ChildWorkspaceService,
WorkspaceRuntimeResolver and ManagedAutomationService into the query group
(4 -> 7) and TaskCancelCoordinator into the lifecycle group (8 -> 9), raise the
total to 22, and reword the appended sentence as a deferral-history note rather
than a separate migration list. Both languages; mechanical count verifies 22
with the four in their role groups, matching the code's injected types.

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

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-11-03 11:41:33 +08:00
Shaojin Wen f724e203ec fix(web-shell): bind config sync to the attachment, not the SSE stream (#16170)
* fix(web-shell): bind config sync to the attachment, not the SSE stream

The session config-ordering domain was recreated when the event stream was
opened, which happens after the load effect awaits its metadata. Because the
attachment is deliberately published before that await so the transcript paints
one round-trip earlier, a first-prompt config write could still be in flight when
the recreation landed. Recreating the domain invalidated the write's ownership
token, so a value the daemon had already confirmed was silently discarded, and
the reconciliation gated on the same stale token was skipped with it. A reasoning
effort chosen before the first prompt was therefore lost for good, and the
composer kept advertising the pre-choice value.

Establish the domain once, when the session becomes the current attachment, and
let stream setup read it instead of recreating it. The window closes without
moving when the attachment is published, so first-prompt latency and the early
transcript paint are unchanged, and every staleness guard is left intact.

Split the live-daemon settings inclusion/exclusion coverage into one test per
scenario sharing a single daemon, so each scenario owns a full timeout budget
and a failure names the scenario rather than reporting a bare timeout inside a
twelve-navigation loop.

One existing test asserted the current mode was undefined after an approval-mode
write had been confirmed; that held only because the same recreation discarded
the confirmed value. Its mock now reports the confirmed mode on later reads and
the assertion requires that value to survive, which is strictly stronger.

Fixes #16065

* test(web-shell): make the config-sync regression test discriminating

Review follow-up. The reasoning regression test could not tell "the
confirmed write was applied" from "a later context read republished it":
its mock answered every read from the already-updated state, so disabling
the write's apply while keeping the reconciliation left it green. Hold
every post-write read on a deferred and assert the confirmed value while
that read is still pending, so only the apply can satisfy it.

Share the live-daemon settings fixture with the existing spawnDaemon
harness rather than an inline spawn, poll and kill sequence. Boot failures
now carry the daemon's stdout and stderr instead of a bare Playwright
timeout, and the helper clears its boot timer and listeners on settle.
The helper appends an initialize-timeout flag, so this is not a pure
refactor; cliBin stays explicit so TEST_CLI_PATH cannot redirect it.

Record why the bind-site domain reset cannot be pinned in isolation:
read-sequence monotonicity and the outgoing stream's own teardown already
invalidate anything captured before a re-attach.
2026-11-03 11:14:40 +08:00
Shaojin Wen c26a32006a fix(serve): keep record_artifact guidance out of Managed Runtime worker results (#16148)
* fix(serve): keep record_artifact guidance out of Managed Runtime worker results

The worker builds its tools from a configuration that left artifact
recording at the core default. A Hosted Session declares no
record_artifact and the worker forwards only response parts, so every
Shell result for a command not proven read-only told the model to call a
tool it does not have, and write_file claimed artifact-like files were
recorded when nothing records them. The reminder's read-only check also loaded the Bash
AST parser in every sandbox for each successful Shell call.

Turn artifact recording off for the worker's tools regardless of env
flags, as the shared execution worker already does. The local Managed
engine's provider worker, which hands artifacts to its client, is
unchanged.

Fixes #16147

* fix(serve): keep artifact guidance out of the CSI files worker too

Add a Config option, artifactRecordingSupported, for hosts that expose
no record_artifact tool and forward no artifact metadata. Unlike
artifactEnabled, the artifact env flags cannot turn recording back on.
The Managed Runtime worker, the CSI files worker and the shared execution
worker now pass it instead of overriding isRecordArtifactEnabled on the
instance.

The CSI files worker's write_file and edit no longer tell the model that
a file was recorded as a workspace artifact. The tests now derive that
claim from formatRecordArtifactReminder() instead of a literal, cover
edit, and assert that a worker Shell call makes no call into the Bash
parser.
2026-11-03 10:26:15 +08:00
Shaojin WenandQwen-Coder 47fcd5131d fix(acp-bridge): compact live tool-result displays for session replay (#16172)
* fix(acp-bridge): compact live tool-result displays for session replay

Opening a Web Shell session whose live turn edited large files could take
tens of seconds: the session/load response embeds the daemon replay
journal, and each edit result event carried the whole old and new file
contents twice (diff content plus rawOutput). One observed load response
reached 39.6 MB although the on-disk transcript was only 4.3 MB, because
the recording path truncates file-diff displays while the live ACP path
emitted them raw; adaptive journal growth then retained tens of MB per
session and every open re-downloaded and replayed all of it (#16171).

Compact the display where it becomes wire content and rawOutput, reusing
the recording-purpose compaction so warm replay shows exactly what cold
replay from the transcript already shows. Small diffs stay byte-for-byte
intact; oversized edits degrade to the saved-session preview.

Transcript records can carry leaner displays than the live producer emits
(blocked spawns have no task prompt), so the shared compaction utility is
hardened to pass non-string fields and malformed list entries through
instead of throwing; well-formed displays compact exactly as before.

* docs: record real-daemon A/B verification for live replay compaction

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

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-11-03 10:19:36 +08:00
Shaojin Wen a6f1885833 test(autofix-pr): re-run a timed-out job and lose its answer in the chain test (#16138)
The fake gh only re-ran FAILURE jobs and applied loseNextAnswer to comment
POSTs only, so the helpers' handling of a timed-out job's re-run and of a
re-run whose answer is lost could not be reached from the chain test.

- `run rerun` re-runs FAILURE and TIMED_OUT jobs, refuses a run with no
  failed job, and with loseNextAnswer queues the jobs then fails the way gh
  words a non-403 error.
- A job conclusion the fake does not list is refused as unsupported, on
  every path that reads a run.
- FAKE_GH_WORLD, and the names derived from it, are read after the call is
  logged and inside the guard, so a missing or malformed world shows as
  unsupported instead of a bare crash.
- New case: a timed-out job whose re-run's answer is lost is left for a
  person; when the re-run times out again, rerun refuses a second one, and
  status lists both. The re-run's attempt key is pinned, here and in the
  re-run case.
- Design §12 (both languages) lists the conflict case and the new case.
2026-11-03 09:17:45 +08:00
Shaojin Wen 14de8a035e feat(verify-pr): inherit --publish consent on same-session re-verification (#16112)
* feat(verify-pr): inherit --publish consent on same-session re-verification

A maintainer who ran `/verify-pr <n> --publish` and then typed the
"有更新了,重新验证" follow-up trigger had to confirm publishing again:
the skill inherited the previous round's coordinates but deliberately
excluded publish consent, so every re-verification of an already
published PR repeated a confirmation the maintainer had just given.

Let a follow-up round inherit publish consent from the most recent
round for the same PR in the same session. The round must announce the
inheritance in one line before posting, `--no-publish` on the trigger
opts out and breaks the chain, and the most-recent-round semantics keep
a quiet round from reviving an older consent. Cross-session rounds
still require an explicit request: the phrase alone posts nothing, and
a published comment found on the PR is evidence of a past round, not
consent for a new one. The CI lane and watch mode are unchanged.

Update the two pin tests that fixed the old per-invocation-only rule
and pin the inheritance, announcement, opt-out, and cross-session
boundaries.

* fix(verify-pr): carry inherited publish consent through follow-up chains

Review of the first round found the consent chain broke after a single
inheritance: R1 invoked with --publish, R2 a bare trigger that inherited
and published, R3 a bare trigger whose most recent round (R2) was not
itself invoked with --publish — so R3 fell back to asking again, the
exact friction this change exists to remove.

Model consent as a per-PR state of the session instead of keying it to
the latest invocation: a --publish invocation grants it, a fresh
/verify-pr round without --publish or a trigger carrying --no-publish
withdraws it, and a bare trigger keeps it unchanged, so consecutive
bare triggers publish under the original grant until a round withdraws
it. Watch rounds keep their own standing consent and never change this
state. Also close the two review suggestions: the Hard rules bullet now
recognizes a request inherited within the same session, and the
argument hint lists --no-publish so the opt-out is discoverable.

Pins cover the grant/withdraw/keep mechanics, the three-round chain,
the watch boundary, the Hard rules clause, and the updated hint.

* fix(verify-pr): grant session consent on any explicit publish request

Second review round found two gaps. The design docs for the assets
coordinates still stated that publishing consent is per invocation,
which reads as an invariant and could silently revert the feature;
both language versions now defer to the skill's session-consent rule
while keeping "coordinates are not consent". And the grant keyed on
the literal --publish flag, while the publish rule also accepts an
explicit request in words — a words-request round published but seeded
no consent, splitting the friction this PR removes by request form.
The grant now rides on the explicit request, flag or words alike, and
the withdrawal matches it.

Also close the deep verification's coverage survivors: the withdrawal
is pinned as one contiguous sentence (a fresh round keeping consent
now fails), the chain's upper bound, the announcement's naming duty,
and the evidence-of-a-past-round clause each have pins, and an
inherited publish now carries its one-line announcement into the
comment body immediately before the attribution footer, so a PR
reader can tell it from a per-round request.
2026-11-02 22:22:00 +08:00
Shaojin Wen 9336986835 feat(web-shell): make GitHub channel configuration usable (#16096)
* feat(web-shell): make GitHub channel configuration usable

The channel editor rendered every platform through one generic form, which
left the GitHub channel hard to configure and persisted settings its adapter
never reads.

- Render string-list fields that declare options as a checkbox list, so the
  fifteen notification reasons are pickable and explained instead of typed
  from memory. Unknown values written by hand stay visible and removable
  rather than disappearing on the next save.
- Lock the token input while local gh authentication is selected, clear a
  stored token when that switch is turned on, and drop a token that an
  existing useLocalGh selection has already overridden.
- Hide and stop persisting the direct-message policy for repository-scoped
  channels: the registry injects privatePolicy into every manageable channel,
  but the GitHub adapter only produces group envelopes, so the value never
  gates anything and only misleads.
- Name allowlisted groups repositories, validate owner/repo, and show the
  GitHub Enterprise API base URL shape.
- Split the catch-all credentials section into credentials, connection, and
  notification routing.

* test(web-shell): pin the GitHub editor capture to the reason checklist

The screenshot scenario now fails if the filter falls back to a free-text
input, so the published picture cannot drift away from the behaviour the
pull request claims.

* test(web-shell): pin the section split on a non-GitHub channel

The connection and routing sections key on field names, so they regroup gitlab and wecom fields too. Assert that split on a gitlab descriptor instead of leaving it implied by the GitHub assertions.

* fix(web-shell): match GitHub channel values the way the adapter reads them

Review round 1 on the editor found three places where the new controls
disagreed with the runtime or left dead configuration behind.

- The reason checklist compared values case-sensitively while the adapter
  lowercases both sides, so a stored COMMENT rendered as a stray sixteenth
  box beside an unchecked canonical one; ticking only mention then left
  comment notifications flowing. Compare case-insensitively and keep unknown
  entries in their original spelling.
- The pairing guard had no test witness: deleting it left the whole suite
  green while a legacy privatePolicy reopened the direct-message panel for a
  repository channel. Pin both directions — legacy private pairing must not
  open the panel, group pairing still must.
- Only privatePolicy was cleaned up; the equally unread allowedUsers survived
  every save and was still presented as an authoritative allowlist in the
  pairing panel. Drop the whole private-access set and stop showing it there.

* fix(channels): match GitHub repository groups regardless of case

The editor started validating repository allowlists as owner/repo, but a
repository path is case-insensitive while the runtime matched the group key
verbatim. An operator who typed qwenlm/qwen-code passed validation, saved, and
got a channel that connected and then dropped every notification as
not_allowlisted — the exact failure the validation was added to prevent.

Normalize on both sides so neither can drift:

- The editor writes the canonical lower-case key and shows stored allowlists
  in that form, so re-saving an older mixed-case entry migrates it. The
  per-repository settings are looked up under the normalized key, so retyping
  the case no longer discards that repository's sender policy.
- GroupGate and ChannelBase accept a platform group-ID normalizer, used for
  the allowlist, the sender policy and the per-group overrides alike.
  Normalizing only admission would have let a case-mismatched repository fall
  back to the open sender default, which is worse than dropping it.
- The GitHub adapter supplies the lower-casing normalizer; every other
  platform keeps matching group IDs verbatim.

Pairing approvals are unaffected: they are keyed by the chat ID the platform
reports, so they were already self-consistent.

* fix(channels): resolve the group history limit through the same lookup

Self-audit of the repository-casing fix found a third direct read of
config.groups: the per-group history limit still matched the key verbatim, so
a platform with case-insensitive IDs silently lost that override while the
allowlist and sender policy honoured it. Route it through groupConfigFor and
pin it with a case-insensitive channel test.

* fix(web-shell): keep the gh switch reversible and refuse ambiguous repositories

Review round 2 plus the independent verification round found three defects.

- Turning local gh authentication on queues the stored token for removal,
  because a token outranks gh. Turning it back off left that intent in place,
  so Save demanded a credential the operator never chose to delete and the
  switch could not be undone without retyping a PAT. Flipping it back now
  restores the stored token.
- Collapsing repository aliases could widen access. A stored allowlist may
  hold Owner/repo and owner/repo with different settings; the runtime resolves
  an exact key first, so keeping only one of them on an unrelated save dropped
  the effective sender restriction. The editor now refuses to save until the
  operator resolves the duplicate, and shows the allowlist without repeating
  the same repository twice.
- The reason descriptions described event types instead of the subscription
  reasons GitHub actually reports: comment means the account commented on the
  thread, state_change that the account changed its state, ci_activity that a
  workflow it triggered completed, and manual that it subscribed by hand.
  Rewritten in both languages against the REST notification contract.

The visual scenario also captured the dialog without scrolling, so the
checklist sat below the fold and the published picture did not show the
feature. It now scrolls the filter into view, asserts it is fully on screen,
and captures it separately.
2026-11-02 13:11:33 +00:00
Shaojin WenandQwen-Coder 60f5c1ed95 docs(managed-agent): record the completed store constructor injection in the ledger (#16118)
#16061 replaced the seven ManagedAgentStore public setters with a single
@Autowired constructor holding final references, but the parent
structure-review ledger still described the setters as current state and
framed the conversion as future work. Update the D1 row note, the prose and
the P1 store narrative to the implemented state in both languages, keep the
setter investigation as pre-conversion context, and leave the physical
aggregate split as the remaining work.

Figures re-measured against origin/main (0 public setters, 1 @Autowired
constructor, final fields, 7657 lines); prettier and bilingual heading/link
checks pass. Documentation-only.

Co-authored-by: Qwen-Coder <[email protected]>
2026-11-02 17:38:19 +08:00
Shaojin WenandQwen-Coder 64651cde11 docs(managed-agent): reconcile the structure-review ledger with the broker extraction (#16109)
* docs(managed-agent): reconcile the structure-review ledger with the broker extraction

The binding registry and execution workflow slices (#16029, #16084)
extracted the reconciliation/recovery/cancellation cluster, but the parent
structure-review ledger still described a 6,329-line broker with only the
cooldown and hold inventory out, and still called the cluster entangled and
waiting for a registry. Update the D1 god-class rows, the surrounding prose,
the mirrored test size and the P1 extraction narrative to the measured
current state (4,270-line facade, nine collaborators), in both languages.

Every stated figure was re-measured against the source; prettier and the
bilingual heading/link checks pass. Documentation-only.

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

* docs(managed-agent): put the met Registry prerequisite in past tense

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

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-11-02 13:41:55 +08:00
Shaojin Wen 0a7f260704 fix(managed-agent): reach the listeners the server resolves in upgrade start and verify (#16051)
* fix(managed-agent): reach the listeners the server resolves in upgrade start and verify

start and verify derived the control-plane URL from server.env's
QWEN_MANAGED_AGENT_SERVER_ADDRESS and SERVER_PORT, and the Harness URL from
its QWEN_MANAGED_AGENT_HARNESS_BASE_URL. A port or address set anywhere else
(config/application.yml, an argument, SPRING_APPLICATION_JSON, a relaxed
SERVER_ADDRESS) or a management.server.port of its own made start wait out
--timeout on a port nothing served.

ResolvedConfiguration now also prints serverUrl, healthUrl and
harnessBaseUrl as Spring binds them: server.port, server.address and
server.ssl, with a wildcard reached on loopback, and the health endpoint by
Spring Boot's management-port rules. start resolves them before it changes
any unit, verify before its rows. --server-url still names the control
plane; a port of 0 or below refuses until it does; a configuration that
does not resolve falls back to the server.env URLs with a notice.

* fix(managed-agent): keep the health name for a blank path mapping

A blank management.endpoints.web.path-mapping.health, such as an unset
${HEALTH_PATH:}, is ignored by Spring Boot's MappingWebEndpointPathMapper,
so the endpoint stays at /actuator/health. The resolver turned it into an
empty segment and sent start and verify to /actuator, where start waited
out its timeout on a healthy server. Fall back to the name when the
mapping is blank, as Spring does.

Also from review:
- the fallback notice says only the Harness URL comes from server.env
  when --server-url names the control plane;
- start's success line names the API URL next to the health endpoint;
- verify with --server-url resolves the configuration only for --tenant,
  as it did before, since it does not reach the Harness.

* fix(managed-agent): refuse listener URLs fetch cannot dial in upgrade start and verify

A link-local server or management address keeps its IPv6 zone in the
resolved URL (http://[fe80:0:0:0:0:0:0:1%1]:8080), and the WHATWG URL
parser behind fetch rejects every zone spelling. The health probe caught
the TypeError as "not UP", so start waited out --timeout on a URL it could
never send. The same held for an out-of-range port, a server.env address
with a zone on the fallback path, and a --server-url that does not parse.

listeners() now checks both URLs with URL.canParse after the no-fixed-port
refusal, before any unit changes, and names the zone when that is why. The
resolver test pins that the zone is kept, so the script can say so. The
verify test lists the health probe with the session reads instead of
slicing it off.
2026-11-02 12:57:52 +08:00
Shaojin Wen d36fdbdfe5 test(managed-agent): gate detach while a tool result is unknown (#16098)
The MCP detach must refuse before it releases anything when an
operation is recovery_blocked/outcome_unknown. Start an ext://hold
read, kill the stdio fixture, and call the Harness detach route; the
case requires the documented refusal, no release control, and the
operation still unknown.

The Hosted fork moves by the case's measured 27.4 s, booked at 55 s.
2026-11-02 11:44:00 +08:00
Shaojin Wen 7b4b353750 test(managed-agent): gate the two-server first release refusal (#16089)
Extend HostedMcpRestartFaultGatesIT with the FG12d cell #15750 still
carried: a Session configured with two MCP servers, the first release
refused before forwarding, and the same-id retry releasing both.

The case adds fixture2 only for its own boot, waits for both
configurations to settle, and requires three release controls: 503,
the same operation id answered 200, then the other configuration's
release answered 200. Both fixture processes must exit.

The Hosted fork moves by the case's measured 16.2 s, booked at 32 s.
2026-11-02 02:46:13 +08:00
Shaojin Wenandverify 4a5c487681 test(managed-agent): gate the expired-cursor resync answer (#16076)
* test(managed-agent): gate the expired-cursor resync answer

FG9e now advances the Session stream's replay floor past a lagging
client and requires one `agent.session.resync_required` frame on both
the public and WebShell streams, with no event id and the floor and
snapshot cursors it must reload through. The same cursor on the plain
events route answers `409 cursor_expired`, and the floor itself stays
readable.

This closes the expired-cursor cell the FG8 tracker (#14163) deferred
and #15750 carried. The case measures 21.3 s locally; the Hosted fork
budget and Verify step move with it.

* test(managed-agent): wait for projection coverage before advancing the floor

Awaitility treats a lambda that returns without failing as satisfied,
so the coverage guard must assert rather than return. Without this a
poll that lands before the lead's Turn is projected would skip the
advance and dereference a null window.

* docs(managed-agent): correct the resync case's tracker references

FG9e now carries the third case, so the relay-hold sentence must name
only the two resume cases. The FG8 record no longer predicts a slice-2
landing and points at the FG9e delivery instead.

* ci(managed-agent): record the resync case's measured range

The 60 s fork-budget entry was written from one 21.3 s run; the final
audit runs measured 21–23 s, so state the range the budget covers.

* docs(managed-agent): fix the stale step-ceiling header and pin note

The Hosted job's opening parenthetical still summed 291 while the tail,
the Verify step and the job ceiling had moved to 292/202/325. Also say
the replay-floor flag is a default pin, not something currently off.

---------

Co-authored-by: verify <verify@local>
2026-11-02 00:28:04 +08:00
Shaojin Wen cea333133c fix(managed-agent): correct the child result mirror on consumption (#16074)
* fix(managed-agent): correct the child result mirror on consumption

classify() mirrors the child_run delivery into ma_delivery at
classification time. A parent's consuming revision can land after that
snapshot, leaving a CHILD_RESULT row at `delivered/accepted/pending`
forever while the record itself reads `consumed`; FG10b's
awaitConsumed then times out even though the walk completed.

The parent's own consumption commit now corrects that row in the same
transaction, and classification reads the record row with FOR UPDATE
so a consumption cannot slip between its read and the mirror write. A
row not written yet is left to classification, which reads the
record's current state.

Adds focused H2 coverage for the mirror correction and the commit-path
wiring. Issue: #15744.

* fix(managed-agent): lock only the child run's record row

The classification's locking read matched the session scope without the
record's primary key. MySQL therefore scanned the Session's records and
took next-key locks over the rows that scan touched, so an unrelated
child run's commit in the same Session could wait behind a
classification that had nothing to do with it.

Name the record key in the lookup. A real MySQL IT holds the
classification open and proves the unrelated record still commits; the
same IT fails with the session-scope lookup under a lock wait timeout.
Issue: #15744.
2026-11-01 22:39:10 +08:00
2aa908dc70 fix(managed-agent): block ledger-less agent gap fills and pin the A6 refusal arms (#16015)
* docs(managed-agent): mark the B2 identities and live SSE arm packaged

The G3 recovery-matrix status paragraph still listed B2's attempt/stream
identities and live SSE view as remaining focused/integration evidence after
PR #15993 packaged them into the model-round arm, and its admission-count
clause predated the arm's existing admission audit. Both languages now name
the packaged B2 observations and link the B2 design doc; the remaining list
keeps only A3's rewritten-input/Hook half, A6 and R2.

* fix(managed-agent): block ledger-less agent gap fills and pin the A6 refusal arms

A6's first clause is an evidence-semantics rule: unknown producer evidence
must block, never be laundered into proven absence. Two gaps violated its
evidence surface. The Hook/MCP saved-batch ownership refusal — the only
backstop for the extensions-enabled continuation entry — had zero tests:
deleting its clause kept every existing suite green (demonstrated before
pinning). And the parked-round gap fill answered a stranded agent call with
"the call never ran" when the child ledger was unavailable, although only a
present ledger's missing record is positive never-started proof (admission
commits before dispatch); child-lane config/version drift between generations
makes that arm reachable with a live child.

The fill now throws the family's retriable recovery error for an owed agent
fold with no ledger; journaled answers keep their silent replay and non-agent
calls keep their honest fold. Three ownership cases (hooks, mcp,
hooks+extensions) and four gap-fill cases pin the surface; each deletion
control reds exactly its named case. Focused regression 894/894, typecheck,
ESLint and Prettier clean. Bilingual design doc records the A6 evidence map
and scope boundaries; the parent matrix status notes the landed focused
evidence.

* fix(managed-agent): widen the A6 ledger guard and exempt the terminating routes

R1 review fixes, each verified against the code before fixing:

- The guard covers the whole child-run ledger family (agent || workflow),
  matching the module's own family readers; a stranded workflow call with
  an unavailable ledger no longer receives the fabricated never-ran fold.
- An abort exemption keeps the pre-aborted cancel and interrupted-settle
  fills terminating with their pre-A6 abandonment folds; only the
  live-signal continue route blocks. Without it the guard wedged cancel in
  a deterministic failure loop (regression witness demonstrated at route
  level).
- Two route-level cases build the child-lane drift into the replacement
  load (suppressChildAgents on the version-0 orphan parked shape):
  continue blocks retriably (409) with no fabricated fold, cancel still
  settles.
- The owner-gate matrix drops the vacuous hooks+extensions row (the
  harness entry's extensions clause is recorded as a follow-up) and gains
  both gate directions: a stale-revision commandStep refusal and a
  matching-revision acceptance that keeps the A3 commandStep follow-up
  working.

Nine delete-controls red exactly their named cases on a quiet tree with
known-good baselines; focused regression 8 suites 1583/1583, typecheck,
ESLint and Prettier green. Bilingual design docs name all three fill
callers, the residual trade-off, and the narrowed G2 scope.

* fix(managed-agent): extend the A6 ledger rule to every never-ran writer

R2 review fixes, each verified against the code before fixing:

- The resumed-turn answer (hosted mode) and the command-wait gap fill now
  refuse a ledger-less family call on a live signal, mirroring the agent
  fill; their terminating callers are pre-aborted so cancel and settle
  keep terminating.
- Every ledger-less family fold on the terminating paths — the pre-aborted
  fills and the 'all' settlement — writes a dedicated unknown-outcome
  answer (executionStatus unknown, record retained) that claims nothing,
  never "never ran" and never "cancelled".
- CHILD_LEDGER_TOOL_NAMES becomes the single family-membership fact; the
  six inline two-name literals across recovery and the tool turn now read
  it, so a third ledger-backed tool joins in one place.
- The owner-gate matrix gains the extensions row its comment claimed: a
  Hook owner with extensionConfig and no commandStep is refused, and the
  extensions-exemption mutant reds exactly that row.

Five delete-controls red exactly their named cases on a quiet tree with
failure-list adjudication; focused regression 8 suites 1587/1587, ESLint,
Prettier and typecheck green. Bilingual design doc records the round.

* fix(cli): preserve cancel escape for A6 recovery blocks

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

* test(cli): pin journal-only workflow ledger refusal

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

---------

Co-authored-by: 易良 <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
2026-11-01 22:21:47 +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
Shaojin WenandQwen-Coder 417e81ec4d fix(review): keep prose re-review requests aimed at the PR (#15998)
* fix(review): keep prose re-review requests aimed at the PR

A re-review request typed as prose ("有更新了,重新评审") carried no
target the parser could classify, and the parser did not refuse it:
every non-flag, non-number, non-URL token classifies as a file target,
so the sentence became a file path, and prose typed beside a PR number
stole the target while the number was demoted to extraTokens.

- parse-args: PR-shaped tokens now outrank file-shaped ones regardless
  of arrival order; an explicit ./<number> spelling stays a file target.
- review skill: a Step 1 intent-routing clause resolves a prose request
  to the PR the conversation names (non-empty inputs only, flags kept,
  the CLI args file preserved), and the description plus the resume
  verdict bullet exclude the updated-PR case from --resume.
- fetch-pr: the head-moved resume refusal now says the fresh run IS the
  re-review and names the plain invocation for next time; the
  restart-bound semantics are unchanged.
- docs: both --resume surfaces in the user guide carry the exclusion.

Pinned by 12 new parse-args cases (the key one red before the fix), a
SKILL.md pin test, and a fetch-pr stderr assertion; DESIGN.md records
the incident as "The prose re-review request".

* fix(review): fence the prose routing's local and posting boundaries

The review round on #15998 caught two defects in the new routing clause
and one regression behind its non-blocking nit:

- Option-only invocations (--fix, --effort low) matched the routing
  condition and would have been retargeted into the conversation's last
  PR, disarming --fix. The clause now names the bare and option-only
  shapes as non-intent and leaves them to the parser verbatim; two new
  parser cases pin that contract.
- The clause promised posting authority for a --comment carried through
  the resolution, but the write gate re-parses the CLI's verbatim record
  on purpose, so a prose record ends at submit with `unbound`. Posting
  is now the resolution's hard boundary: present the resolved invocation
  and ask for an explicit re-run instead of completing a review whose
  submission is already refused. A new submit-suite test pins the
  refusal beside the explicit re-invocation's acceptance.
- The PR-shape reorder demoted a malformed URL behind the assigned
  target and lost its specific diagnostic to the generic extra-argument
  line. The invalid-url refusal is hoisted ahead of the target-assigned
  branch, position-independent, pinned by a dedicated test.

DESIGN.md records the review round; the PR body carries the same
boundaries in both languages.

* fix(review): guard prose routing against local-write intent, qualify scoping

The deep-verification round on ef86b03a99 measured one more hole in the
option-only class and one overclaim:

- F1: "review my local changes --fix" is prose with no classifiable
  target, so it still matched the routing trigger and resolution would
  disarm --fix. The clause now parses the verbatim input first and never
  resolves a PR into a verdict with fix.effective true — a mechanical
  guard, not a wording guess; prose asking for a local review is
  excluded even without --fix. The hash form's copy ambiguity is closed
  too: a # prefix is reference decoration, not part of the target. Four
  new parser cells pin the guard input, the PR-target contrast, the
  hash form, and the ./ escape-hatch boundary.
- F2: delta scoping on a plain re-review is high-only; the docs (both
  surfaces), the resume bullet, the routing clause, the --resume help,
  and the head-moved stderr now all carry the high-effort qualification.
- The ./ escape-hatch wording gains the measured precision: it holds
  when no PR-shaped token is present, not unconditionally.

DESIGN.md records the verification round; the PR body carries the same
boundaries in both languages.

* fix(review): honest resume guidance, promoted-number warning, pre-fix framing

The /review lane's round on 6a5372d6e4 filed five suggestions; all five
verified against the code and addressed:

- R1-1: the routing rationale described the prose-steals-target behavior
  in the present tense — behavior this PR's own parser preference closes.
  It now marks that half as pre-fix and keeps the live half (prose alone
  dies at capture) as the rationale; pinned.
- R1-2/R1-3: the head-moved guidance was gated on a refusal a re-review
  after a completed round never reaches (assessResume checks no-report
  before comparing heads, and cleanup deleted the report), and "gained
  commits" misdescribed a rebase. The guidance now covers the re-review
  family (head-moved, no-report, worktree-gone) with cause-appropriate
  wording; the restart-cost sentence stays with head-moved alone, the
  only reason recordRestart runs for. Docs and the resume bullet carry
  the same completed-round reality.
- R1-4: positive AND negative pins — guidance present on head-moved and
  no-report (without the restart sentence), absent on diff-hash-mismatch
  and worktree-dirty, so dropping the reason gate goes red. R1-3 and
  R1-4 contradicted each other on no-report; the family split resolves
  both (recorded in DESIGN.md).
- R1-5: a bare count inside a sentence is indistinguishable from a PR id
  and the promotion flips --comment to effective. The parser now warns
  when a bare number is promoted with non-PR tokens after it in the
  original order (the canonical number-at-end rescue stays silent), and
  the clause treats the warning as ask-before-running, never posting
  silently. Four-shape test pins presence and absence.

* fix(review): withdraw posting authority from promoted numbers, honest guidance

The sandboxed CI round and the second verification round proved the
promoted-number warning was disclosure, not a fix, and measured four
more gaps:

- R2-1 (Critical): a promoted count carried --comment posting authority
  to a PR the sentence never named ("--comment the 2 follow-ups to
  15998" authorised posting to PR #2). comment.effective is now false
  whenever the PR target is a promoted bare number (promotedNumberTarget
  marks the verdict); the write gate refuses with a promotion-specific
  cause. An explicitly typed number or a PR URL keeps its authority.
- R2-2: the warning moved after the URL preference, decided from the
  actual target, fired exactly when a requested posting authority was
  withheld — demoted numbers and courtesy particles stay silent.
- R2-3: the routing guard keys on what the user spelled, not the
  verdict's target type (prose parses to a file target — the old guard
  excluded the only input it existed for); local-intent moves are
  executable; the hash form has an operational resolution.
- R2-4: trailing sentence punctuation no longer demotes a PR number;
  classification normalises the mark for the integer test only.
- R1-2/R1-3 (fix-induced): worktree-gone gets its own cause-neutral
  sentence (it requires a surviving report); no-report asserts no
  cause; every arm carries the anchor condition; the restart-cost
  sentence prints where recordRestart runs, in the past tense; the
  ruling promise is scoped to high/medium.

New pins: the Critical's exact witness at the gate, both word orders,
the setting-driven arm, worktree-gone, and the reverse-order
malformed-URL cell that reds under a full revert.

* fix(review): name the source of withheld posting authority

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

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-11-01 16:50:22 +08:00
Shaojin Wen 859fd0b39c fix(core): keep the whole Kimi K3 generation at a 1M context window (#16048)
* fix(core): recognize the whole Kimi K3 generation at a 1M window

The K3 generation is served as `kimi-k3`, `kimi-k3.1-pro` and, from Kimi
Code, as the bare `k3.1-pro` / `k3.1-pro-highspeed` aliases. Two gaps left
the K3.1 tiers with the wrong window:

- the curated table only matched ids starting with `kimi-k3`, so the bare
  aliases fell through to the 256K Kimi fallback;
- the models.dev catalog is consulted before that table, rounds the 1M
  window up to 1,048,576, and lists each K3.1 sub-version under both its
  dotted and dashed spellings, so the catalog value overrode the declared
  window and compaction thresholds fired too late.

Match the generation once and share that pattern between the curated table
and the catalog correction, replacing the single-id catalog entry that the
next sub-version would have slipped past.

* fix(core): keep Kimi's k3-256k out of the K3 1M window rule

Kimi Code serves `k3-256k` at 262,144 tokens alongside the 1M `k3` and
`k3.1` tiers, and the generation pattern swept it up: with no catalog entry
it resolved to 1M instead of the 256K family fallback, and the
generation-wide catalog correction overwrote a correct cached 262,144.

Name the variant ahead of the generation row, exclude it from the shared
pattern so the catalog correction cannot raise it, and pin its window in the
id-keyed corrections for a catalog that omits or overstates the entry.

Fixes the P1 on #16048.

* fix(core): give Kimi's bare K3 aliases the same modalities as the prefixed ids

Kimi Code serves the K3 generation without the `kimi-` prefix, but the
modality table still matched only `kimi-k3`, so `k3.1-pro`,
`k3.1-pro-highspeed` and a bare `k3` fell through to the text-only Kimi
fallback while their `kimi-` spellings advertised image and video input.

Match the prefix optionally, and name the 256K variant ahead of that row:
Kimi Code reports image input only for `k3-256k`, no video.

Addresses the R1-1 suggestion on #16048.
2026-11-01 08:47:39 +00:00
Shaojin Wenandverify 7ab044d608 docs(readme): add daemon mode quickstart (#16053)
Explain how qwen serve supports shared sessions and browser/API clients.
Document lifecycle commands and loopback authentication risks.

Co-authored-by: verify <verify@local>
2026-11-01 08:22:29 +00:00
Shaojin Wen 0f91cdedae fix(verify-pr): count suite runs and skip notices on closed PRs (#16050)
Follow-ups deferred on #16013:

- A round whose only checks were existing suites had a total of 0
  once suites moved to gates, and the checker refuses that. The skill
  now counts each suite run as one assertion, as it already did for a
  mutant run.
- Gates that record no results (an empty list, or entries counting
  nothing) no longer silence the size warning. When no suite ran, the
  round says in the report what its count holds.
- The blank-suite rule is JSON whitespace on both sides. jq's \S and
  JS's trim() disagreed on U+0085 and U+FEFF, so the checker accepted
  a list the publisher dropped.

Also, a round can queue for hours: the publisher no longer posts a
notice on a PR that closed meanwhile, unless it is finalizing its own
live status. Four such notices landed on #16013 after it merged.
2026-11-01 07:55:09 +00:00
Shaojin Wen 835b080f85 test(autofix-pr): run a watch's helpers in a chain against a fake gh (#16017)
* test(autofix-pr): run a watch's helpers in a chain against a fake gh

Add the integration test the design listed as not written yet. The
helpers run one after another as the skill runs them, through the CLI's
parser, against real git (a bare remote, the user's clone, the watch's
worktree) and a fake gh first on PATH, which they spawn as they would the
real one. The gate runs as `schedule` printed it, split by a real shell.

The fake answers from a JSON description of the PR and reads the PR's
head from the bare repository, so a push moves it. It answers only the
fields a GraphQL query selects, checks the host and the Int PR number,
and logs anything it does not know, which fails the test even when a
helper swallows the failed call.

Five cases: a watch from start to merge (with a reply whose answer was
lost), a push by someone else during a round, a re-run spent then
refused, the per-check fix budget across real pushes (one head's result
asked by commit), and the author's push access. Each also checks the
writes made to GitHub, that pushes are fast-forwards onto the PR's
branch, and that the user's checkout is unchanged.

* test(autofix-pr): check the field behind a GraphQL alias in the fake gh

The fake read an alias's response key and never the field it stands for,
so `root: nonexistentField(first: 1)` was answered like the real
`root: comments(first: 1)`. It now records the field and its arguments
behind each alias and answers only the one alias it renders. The message
for a query it does not answer no longer calls every such query a paging
one.

* test(autofix-pr): pin the conflict seam and the watch's refs in the chain test

- The fake answers `root` only through its alias: selected bare, as
  `root { ... }` or `root(first: 1)`, it is a field GitHub does not have.
  It also refuses a run with no job instead of answering it as a pass.
- The user's checkout is compared with its branches and worktrees too,
  and only `.qwen/tmp` is left out of its status, so a branch or a file
  the watch leaves behind shows. The re-run and fix-budget cases end
  with the cleanup a stop runs.
- A sixth case leaves a conflict for a person: `reply` records it, the
  gate stays quiet on that head and on the head the watch pushes next
  while GitHub still answers UNKNOWN, and someone else's push brings it
  back.
2026-11-01 10:50:41 +08:00
Shaojin WenandQwen-Coder 06c1f98971 fix(verify-pr): count test suites apart from the round's assertions (#16013)
* fix(verify-pr): count test suites apart from the round's assertions

Rounds folded whole workspace suites into assertions.json, so the
published "Scripted assertions" line measured the repo, not the round:
37 of the 252 counted reports in the 30-day census exceeded 1000, and
one round's 7737 held a few dozen checks of its own, the rest being
five suites counted again for every mutant run.

assertions.json gains gates: [{ suite, pass, fail, skipped? }], one
entry per existing test suite run as a targeted gate. The skill says
suite results and mutant runs stay out of pass/fail/total, and that a
gate red the PR causes still reaches the verdict as an assertion. The
checker refuses a malformed list and warns when over 1000 assertions
come with no gates. The publisher prints the gate totals on a line of
their own, numbers only, and does not let them decide the verdict.

Also, deferred from #15938: the paragraph for a merge-ready round with
failures now states the checker's rule instead of inviting the reader
to excuse the failures, and the artifact-check guard's empty-value legs
are pinned.

* fix(verify-pr): warn on empty gates and mirror the checker's shape rules

Address the three stage-2 triage suggestions: an empty gates list no
longer silences the suite-sized warning; the publisher's jq refuses
blank suite names and non-count skipped values (false/null) instead of
defaulting them, so a shape the checker refuses never renders totals;
and the design docs say 'all three' / '三者' where the rule count grew
from two to three.

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

* fix(verify-pr): give the gates shape rule one witness and honest totals

Address the five R1 review suggestions:
- one shared fixture table drives both the publisher's jq and
  checkArtifacts, and both bound counts at 2^53-1, closing the 1e309
  divergence where jq clamped an overflow the checker refuses
- the skipped clause prints only when some entry records the field,
  so an unrecorded count is no longer published as a measured zero
- a trusted verdict over gate reds carries one advisory sentence in
  both languages pointing at the assertion ledger; still verdict-inert
- pin the skill's compensation sentence and name the gates-size
  warning in the local clear-list and the CI-path sentence
- bump the workflow size baseline to 375110: the parity comment, the
  conditional clause and the advisory exceed the remaining allowance

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

* fix(verify-pr): print the skip clause only for fully recorded gates

Address the two R2 review suggestions:
- the clause now requires every entry to record skipped; a mixed list
  published a partial sum as the run's skip total
- pin the advisory's trusted-verdict guard from the false side: an
  untrusted run with gate reds prints the totals but no advisory
- fixtures: a mixed list drops the clause, a recorded skipped:0 keeps
  it, and a timeout headline over gate reds stays advisory-free

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

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-11-01 02:26:31 +08:00
Shaojin Wen 143a8b0653 ci(sdk-java): run the Hosted admission gates even when the Hosted verify fails (#16010)
* ci(sdk-java): run the Hosted admission gates even when the Hosted verify fails

The admission step had no condition, so it ran only when every earlier
step passed. The Hosted verify fails most nights, and the step was skipped
on all 13 nights from 10-14 to 10-30 that the verify failed, including the
three since the Runtime Broker fault gates stopped being skipped the same
way. It ran on the other three nights and passed each time, in about two
minutes.

The step rebuilds the module with `clean verify` and needs only the CLI
bundle, the installed SDK and the MySQL service. Each gate class works
under its own random tenant, and on green nights it already runs against
the database the Hosted verify used. So it takes the condition the fault
gates and the O4 step use: run unless the run was cancelled, as long as
the SDK install succeeded.

The class check after it reads only the admission step's reports. It now
follows that step's outcome instead of the job status; otherwise it would
still skip after a red Hosted verify while the step it audits ran.

The pin for the fault-gate condition now covers both steps, and a new case
pins the class check's condition and position. The setup-step assertion
now names the offending steps in its message: vitest abbreviates the
array once a step name is long enough, and the install step's own name
already is.

* ci(sdk-java): keep the O4 gates reachable on red-verify nights

The admission step now runs on red-verify nights too, so the job's worst
case sums all four capped steps (291 min) plus the uncapped setup. Raise
the hosted-harness-mysql ceiling from 304 to 324 so a slow setup (the CLI
install retries three times) cannot cancel the job before the O4 gates,
whose !cancelled() condition would record them skipped.

Close the two pin gaps the review found: the red-verify condition test
gains the O4 row (all three conditioned steps are now pinned), and the
admission class-check test pins that the check step carries no
continue-on-error, so a dropped gate class still fails the job.

All three pins are mutation-witnessed at this head (ceiling back to 304,
O4 if: deleted, continue-on-error on the check step each redden exactly
their case); the nine sdk-java.yml parsing suites pass 709/709.

* ci(sdk-java): boot the admission gates on their own database and bump the size baseline

Now that the admission step runs after a red Hosted verify, it must not
share that verify's database. Managed schema validation refuses any table
outside the managed set, and four Hosted ITs create fg*_fire tables they
drop only during cleanup (the relay classes only while their Spring context
is active), while the isolation extension truncates only ma_ tables. A
class killed before its cleanup would leave one behind and fail both
admission gates at boot. On a MySQL 8.4.6 rig, an fg9b_fire table in
hosted_harness_test made the base command fail both classes with "Managed
schema must contain exactly the 38 target tables", while this command
passed both on a fresh qwen_admission_ci. The step now creates that
database the way O4 creates qwen_o4_ci.

The pin test adds the O4 row, pins that the class check has no
continue-on-error, custom shell or altered command, that no gate command
swallows its failure, and that admission boots on its own database. Both
steps' names are checked before use.

sdk-java.yml outgrew the ratchet: main's copy was already 3971 bytes over
its recorded 144802 without a bump, and this change adds the rest. Even
without its comments the file would exceed the ceiling, so the baseline
moves to the file's size.

* test(ci): pin the admission step whole and keep the install ahead of the verify

The admission step's command, env and shell are now pinned with literal
values. The hosted-gates class check counts a failed class as one that
ran, so an `||` fallback, a pipe, or -fn in the command or in MAVEN_ARGS
would have turned a red admission gate green, and a second -Dmysql.url
would have pointed it back at the shared database. A list of forbidden
substrings caught some of these and rejected harmless commands, so the
fault-gate and O4 rows go back to pinning only their condition and
continue-on-error; their commands are unchanged by this PR.

The three gate rows also pin that the SDK install comes before the Hosted
verify. After it, a red verify would skip the install and every gate with
it, on exactly the nights the condition is for.

Comments: the job comment opened with stale ceilings (162 + 20 + 50 + 20
= 252); it now gives the measured setup time (under 10 minutes on every
nightly from 10-14 to 10-30), states that what changed is the timed-out
Verify path, which used to skip admission, and says the CLI install makes
up to three attempts rather than retrying three times. The O4 row comment
says O4 has carried its condition since it was added.

* test(ci): derive the red-verify contract from the job and pin the setup allowance

The red-verify condition was asserted over three hand-listed step names,
so a gate added to the job later sat outside every assertion and a red
verify would skip it silently, as it skipped the admission gates. A new
case derives the contract from the steps after the Hosted verify:

- Only the two report checks that need the verify's output may run on a
  green job alone. Every other step's condition must be `always()`, or
  `${{ }}` around `always()`, `!cancelled()` or `failure()`, optionally
  narrowed by one step's outcome. Any other condition either has no
  status function, so GitHub prefixes `success() &&`, or still reads the
  job status. A narrowing step must itself run regardless of the verify.
- No step after the verify carries continue-on-error.
- Every step that invokes Maven, matched as a word so folded commands,
  wrappers and quoted paths count, carries the exact red-verify condition
  and a ceiling, so it enters the summed step budget.

The job-budget rows now carry the setup allowance (33 for the MySQL job,
15 for failover) and assert ceiling - steps against it, so raising a step
ceiling re-derives the allowance instead of quietly spending it.
2026-11-01 02:25:44 +08:00
Shaojin Wen fe30b5daaa test(managed-agent): pin lapsed-lease ledger writes and the IT's result-store wiring (#16018)
* test(managed-agent): pin lapsed-lease ledger writes and the IT's result-store wiring

Design decision 5 says a scanner whose lease lapsed still records and
settles until another claim moves the fence, but no test held that half:
adding a lease_until condition to any ledger write left every case green.
A new AutomationScannerTest case writes past lease_until with owner and
fence unchanged, then lets another claim take the lease.

WorkspaceCsiSqlWorkerMySqlIT now asserts its journal holds the result
store it wired, so dropping that wiring can no longer leave the
zero-projection assertion passing for the wrong reason.

Decision 6 no longer says "lost its lease", which read as expiry.

* test(managed-agent): cover every fenced ledger write after the lease lapses

The lapsed-lease case now also releases, blocks, records a gate outcome
and settles with a detail after lease_until, so a lease_until condition
on any of the nine owner+fence writes fails it.

The scanner's class Javadoc, the ledger's method Javadocs and the sibling
test name said "lost its lease", which reads as expiry; they now name a
takeover by another claim, as decision 6 does.
2026-10-31 22:07:48 +08:00
Shaojin Wen 74bd584f0b fix(core): report a registry that kept changing as a PeerControllerError (#16007)
* fix(core): report a registry that kept changing as a PeerControllerError

A controller registry mutation that loses its write to a concurrent
writer three times in a row failed with a plain Error. The CLI treats
anything that is not a PeerControllerError as an I/O fault, so
`qwen sessions controllers add/remove` printed "could not write
<path>: ..." for what is contention, not a permissions or disk problem.
A caller branching on `error.code` saw undefined.

RegistryChangedError now extends PeerControllerError with the code
`registry-changed`, and its message names the file the way the sibling
registry refusals do.

The redo bound itself had no test: every rival-write case exercised a
single redo. A new case lands a rival write inside each of five
attempts and expects the third to give up with `registry-changed`,
leaving only the rival's records on disk.

* test(core,cli): pin the registry-changed message and the CLI error branches

Nothing pinned the registry-changed message: dropping the file path,
or rewording it as a disk fault, left the suite green. The case "gives
up, saving nothing, when the registry keeps changing" now expects the
exact message.

That case's comment said a rival write lands inside each of five
attempts, but only three attempts run. It now says what the rival
budget is for: the rival stops after five writes, so a missing redo
bound ends in a sixth attempt that succeeds instead of looping forever.
The previous commit's message repeats the old description. The helper's
parameter is renamed from reads to count.

handleRemove's PeerControllerError branch had no CLI test, so forcing
it either way left controllers.test.ts green. The add and remove blocks
each get a case checking that a registry-changed error is printed as it
stands, and "reports a failed write" checks that an I/O failure names
the file.

Two more comments are corrected. MAX_MUTATION_ATTEMPTS counts attempts
in all, not redos. The CLI comment's list of self-explaining errors now
covers refusals that are not about the label, such as too-many, and its
other branch is said to cover a lock wait that ran out as well as an
I/O error.
2026-10-31 13:45:37 +00:00
Shaojin Wen 7bc00a3a08 feat(review): re-anchor on drift, run actionlint, account blockers, refuse retries (#16001)
* feat(review): re-anchor on drift, run actionlint, account blockers, refuse retries

Four fixes from the 2026-10-31 audit of seven review run ledgers
(#15057, #15377, #15385, #15578, #15853, #15955, #15974):

- Head drift posts the completed review at the new head (the re-anchor
  protocol) instead of abandoning the round — the maintainer's ruling
  on #15578, never landed in the skill until now. The once-per-review
  bound and the COMMENT cap survive; the marker withholds its anchor.
- script-lint runs actionlint's workflow-level checks (syntax, context
  availability, action refs) and gates them as [lint] Criticals; only
  the embedded run: shell stays deferred. #15974's unloadable workflow
  (runner context in jobs.env) is the regression test, measured against
  actionlint 1.7.12.
- pr-context writes a blocker inventory; compose-review cross-checks it
  against the round's blockerRulings and caps at COMMENT when the walk
  skipped a blocker (#15974's missed review-level blocker).
- submit documents --dry-run as the only sanctioned probe and refuses a
  re-post at the commit already landed (already-posted-this-commit) —
  the retry that replied this round's own roots back to themselves
  (#15853). The receipt records lastCommitId.

Each fix carries its rule text, pin tests, and a DESIGN.md incident.

* fix(review): isolate actionlint config and refresh lint on re-anchor

Both Criticals from the #16001 review, reproduced against the exact PR
source and closed structurally:

- The newly active actionlint invocation auto-loaded the reviewed repo's
  .github/actionlint.yaml, so a PR-added ignore-all rule suppressed every
  workflow diagnostic — including the unloadable-workflow expression this
  linter was activated to catch. It now runs against a private mkdtemp
  neutral config via -config-file (measured to override the discovery),
  fails closed when no private config can be created, and carries exactly
  the two trusted compatibility exceptions the repo's own lint script
  runs (self-hosted runner labels, environment.deployment schema lag).
  The regression plants the ignore-all config in a real temporary git
  repository and proves the expression finding survives.

- The re-anchor protocol dropped fixed authored findings but left the
  lint gate regenerating its findings from the old-head report, so a
  lint blocker the delta fixed was republished against the new commit.
  The protocol gains a refresh step (scratch worktree at the new head,
  script-lint --diff rewriting the report bound to the new diff) and a
  lintDiffPath compose field that makes the gate — body and ledger
  marker alike — hash the new diff as its freshness basis. Executable
  regressions pin both directions: fixed blocker gone (COMMENT, absent
  from body and marker), still-standing blocker keeps gating
  (REQUEST_CHANGES). A malformed lintDiffPath fails closed.

* fix(review): base-tree actionlint config, empty-diff parse, lane provisioning

Three findings from the round-3 review of #16001, each reproduced
against the exact source and closed:

- The private {} config blocked PR-planted suppressions but also dropped
  the repo's maintainer-merged compatibility exceptions (ECS runner
  labels, concurrency.queue, the $/ self-repository action reference),
  turning correct release-workflow changes into bogus [lint] Criticals.
  The trust boundary is now the MERGE-BASE tree's .github/actionlint.yaml
  (hex-shape-guarded git show, copied into the private file, {} only when
  the base has none); the head copy is never read, and the two trusted
  -ignore constants ride on top exactly as scripts/lint.js runs them.
  Pinned with a two-commit repo and a real-binary run showing the base
  exception honored while the planted ignore-all stays blocked.

- An empty refresh diff (the new head reverted the PR's last change) was
  treated as a parse failure: the range selection fell back to the old
  plan's context-inclusive hunks and promoted a pre-existing finding into
  a blocker the gate accepted as fresh (the empty diff has a valid hash).
  diffParsed now separates parse success from range count; empty and
  deletions-only diffs classify everything pre-existing. The reviewer's
  exact scenario is an executable regression.

- The lanes that run the gate provisioned no linters, so the new
  uninstalled-capping fired on CI and the real-binary regressions were
  skipIf'd out of every run. The review job and the unit-test lane now
  install the pinned, SHA-256-verified actionlint/shellcheck through the
  repo's own installer and promote the job-scoped dirs onto GITHUB_PATH
  (unconditional, advisory, Windows excluded). The 335 workflow meta-test
  pins pass unchanged.

* fix(review): lint in the worktree cwd, guard lint.js entry, pin the sha fence

Three gaps from the scripted deep verification of #16001 (50/55
assertions, 17 mutants), two of them in the round-3 fixes themselves:

- F1: actionlint resolves the config's paths: entries against its cwd,
  and the runner spawned it in the session checkout, so every
  path-scoped base exception was inert while the global keys kept
  working — the split that hid behind the labels-only test. The runner
  now receives the worktree root and spawns inside it with PWD aligned.
  The real-binary suite gains the path-scoped fixture it was missing: a
  benign concurrency.queue edit that must lint clean, and an attack cell
  where the base exception suppresses the schema-lag finding while the
  planted head ignore-all still fails to hide the expression.

- F2: the provisioning steps imported scripts/lint.js, whose
  unconditional top-level main() ran a full lint pass on import and
  never reached the install loop, silently under continue-on-error.
  lint.js now runs its CLI only when it is the entry point
  (import.meta.url guard, argv[1]-safe for node -e imports), and both
  steps assert each installer produced its binary before promoting the
  dir, so a future no-op install fails visibly instead of degrading to
  skipped tests.

- M15: the option-injection fence was unobservable — git's own
  rejection of a malformed option matches the guard's refusal, so the
  guard's mutation survived. A spawn spy now pins the fence directly:
  non-hex merge base never spawns git; a hex-shaped nonexistent one
  does (positive control with the exact argv).

Also acknowledges the verification's correction: the bot review's
$/prepare-release-runner example does not exist at this head (the base
config entry is precautionary); the queue exception at line 485 is the
one reachable path-scoped case, and F1 is pinned on exactly that shape.
2026-10-31 13:43:34 +00:00
Shaojin Wen 2a8ae9b2df docs(managed-agent): record the ManagedAgentStore setter investigation (#16014)
* docs(managed-agent): record the ManagedAgentStore setter investigation

Complete the P1 inventory with the ManagedAgentStore finding: the seven
public setters are Spring @Autowired(required=false) optional-dependency
injection (four optional, two required, worktreeFiles manual), not ad-hoc
mutable wiring. Converting them to constructor ObjectProvider injection is
a large change to the central 7,700-line class with ~30 test wiring sites
whose correctness depends on Spring runtime assembly the manual-setter
unit tests do not fully exercise. Like the broker reconciliation cluster,
it warrants a dedicated design pass and full integration verification
before implementation rather than an incremental slice.

* docs(managed-agent): count worktreeFiles among the required setter injections

configureWorktreeFiles carries plain @Autowired and its bean is supplied
by ManagedAgentConfiguration.managedWorktreeFiles(); the direct calls in
VerificationFixtures are test setup, not production wiring. The setter
inventory therefore reads four optional and three required, and the
@Autowired(required = false) statement now applies only to the optional
four. Both language versions move together.
2026-10-31 21:40:05 +08:00
80273bd34a fix(core): extend built-in reasoning effort to DeepSeek-V4 and new Qwen prefixes (#15987)
* fix(core): extend built-in reasoning effort to DeepSeek-V4 and new Qwen prefixes

Third-party gateways (PAI-EAS, internal proxies) host DeepSeek-V4 models
under arbitrary hostnames, and the invitation-only qwen-latest-series
previews and qwen4 family speak the same tiered reasoning_effort contract
as qwen3.8-max. The hostname whitelist and exact-id tables left these
routes without any effort controls.

Recognize the deepseek-v4 prefix case-insensitively on any OpenAI-compatible
endpoint (official endpoint keeps its own contract; non-V4 aliases stay
host-gated), and route the qwen-latest-series-invite- and qwen4 prefixes
through the shared tiered-effort wire gate and the stable qwen3.8-max
control tiers. Explicit capability declarations still take precedence.

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

* fix(core): address review on effort prefix recognition

- Normalize xhigh to high for every v4.1-flash gateway alias, matching the
  stable Flash id instead of falling into the legacy xhigh->max rule.
- Carry the tiered Qwen manifest through opaque duplicate-endpoint routes:
  the route preview guard, the live ACP configuration guards, and the
  reasoning-configuration lookup now admit the new prefixes, with
  two-endpoint regression coverage.
- Stand the built-in DeepSeek normalizer down on newly recognized
  third-party hosts whenever an explicit declaration governs (wire reshape,
  display state, and override clearing), preserving declared/static effort
  semantics; established DeepSeek routes keep their shipped interaction.
- Update the duplicate-route DeepSeek assertion to the new contract and
  refresh the stale single-family scopings in docs and comments.

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

* fix(core): scope declaration stand-down to pre-PR routes, keep routed declarations

- Limit the established-route compatibility exception to the exact
  model/host pairs the pre-PR recognizer accepted, so a declaration for a
  newly recognized alias keeps its wire semantics even on a MaaS host.
- Make the stand-down self-determining: a profile-less resolved capability
  is a declaration, and both the effective-reasoning shaping and the
  DeepSeek state stand the built-in normalizer down on newly recognized
  combinations, covering pipeline injection, provider reshape, display,
  and override clearing alike.
- Preserve an already-resolved declaration in the routed Qwen fallback;
  the tiered manifest applies only when nothing resolved.
- Regressions: MaaS-alias static and configured effort, display/wire
  alignment, established-pair boundaries, and a live ACP duplicate-endpoint
  declaration case.

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

---------

Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: 易良 <[email protected]>
2026-10-31 13:17:08 +00:00
Shaojin Wenand易良 6eef5d999e fix(cli): bind the PR badge when a --flag is glued to the number (#15989)
* fix(cli): bind the PR badge when a --flag is glued to the number

* fix(cli): count a --repo qualifier glued to the PR number

* fix(cli): narrow the glued-flag grammar to flag-shaped tails

* fix(cli): anchor the glued qualifier and keep /review's strict closure

* fix(cli): recognize --repo anywhere in the command's glued flag chain

* fix(cli): anchor the URL fallback and share one glued-link grammar

---------

Co-authored-by: 易良 <[email protected]>
2026-10-31 21:09:25 +08:00
Shaojin Wen 94448d368d fix(verify-pr): keep the mutation matrix off the tree live cells measure on (#15999)
* fix(verify-pr): keep the mutation matrix off the tree live cells measure on

A local round interleaved its mutation matrix with 80 live daemon cells
on one tree. A build inside a mutation row propagated the mutant into
dist/, and the hand-patched repair still carried the mutant expression,
so every live cell and the whole matrix were withdrawn and the round
shipped as inconclusive.

Require a dedicated tree for the mutation matrix, gate every daemon or
dist-level harness launch on a scripted dist-versus-source anchor check
with a mismatch-reporting control, and forbid hand-patching dist to
repair a contaminated build. Pin the new rule in the workflow meta-test.

* test(verify-pr): pin the launch gate's coverage and control clauses

Review on 0d06209b2d showed the four rig-isolation pins left two
clauses of the launch gate unasserted: deleting "covers every PR
anchor" or the mismatch-reporting control still passed 4/4. Pin both
so a future edit cannot silently weaken the gate.

Also name the clean-dist launch gate in the mutation-runner pointer,
so the section that proves a mutant reached dist and the rule that
proves the measurement tree's dist is clean cross-reference each other.

* fix(verify-pr): define the launch gate's reference side and isolate per-tree artifacts

Review on 21014c30d6 (R1-1/R1-2/R1-3, RC1-RC5): the gate compared dist
against an unnamed source, so a self-consistent mutant build passed it;
"PR anchor" was undefined and collided with the mutation-anchor sense;
the control proved liveness, not refusal; the gate had no carve-out for
the producing row's own run; and tree isolation stopped at the checkout
while the workspace links, the shared sandbox tag, and the single Maven
repository stayed host-global.

Name the reference side as a sha256 manifest of each measurement arm's
clean dist recorded before the matrix starts, retire the undefined
anchor term, require the gate to refuse a known-mutant build, exempt the
producing row's own launches, and extend isolation to node_modules, the
sandbox image tag, and the Maven repository. Replace the fragment pins
with the whole bullet pinned inside its section plus the whole pointer
line; a 17-row mutation acceptance goes red on every row, and a
real-file M8 run turned the suite red before a byte-identical restore.

* fix(verify-pr): qualify the matrix-tree copies and name both sandbox levers

Review on ff867ec035: the cp -al recipe dropped the per-package
qualifier the install strategy already requires (root marked 17.0.1
vs packages/core marked 15.0.12 shadow for real), hardlinks share
inodes so a writing row truncates the round's copy, the sandbox clause
named only the launch-side variable while the build reads
QWEN_SANDBOX_IMAGE_TAG and --sandbox-image outranks the export, and the
pin's section slice failed open when the closing heading was renamed.

Carry the qualifier and the never-symlink rule, require real copies
from the store for rows that write under node_modules, name the build
lever and the flag precedence, and assert the slice bounds. The
acceptance matrix grows to 26 rows, all red on mutation including both
heading renames; a real-file rename run turned the suite red before a
byte-identical restore.

* fix(verify-pr): scope the sandbox image per launch and bound the pin slice by the next heading

Review on b99f52a5e3: <tree> sat where a Docker tag must go and the
export's value was never spelled out; a shell-wide export leaks the
matrix image into every later cell; the two cp -al copies disagreed on
when hardlinks are safe (the base build's postinstall step writes under
node_modules); the slice bound was a named heading, so an inserted
section could shelter a moved bullet; and the prose named a build
variable no test pinned at its read site.

Spell a sanitized label and the full image reference, inject the
variable per launch instead of exporting it, carry the write caveat in
both copies with an explicit copy import method (pnpm's auto falls back
to hardlinks without clones), bound the slice on the next heading, and
pin process.env.QWEN_SANDBOX_IMAGE_TAG in build_sandbox.js. The
acceptance matrix grows to 30 rows, all red on mutation; real-file
heading-insertion and variable-rename runs each turned the suite red
before byte-identical restores. Merged main (#15988, #16002); the suite
now runs 197 tests, all green.
2026-10-31 21:08:39 +08:00
Shaojin Wen f99062525d test(managed-agent): pin the private CSI receipt guards and fix design decision 5 (#16011)
#16000 documented that Sessions created through the private CSI lane
record no tool receipts for projection. Pin the two places that hold:

- WorkspaceCsiSqlWorkerMySqlIT now wires a ManagedToolResultStore into
  the journal, as production does, and asserts that no
  TOOL_RESULT_PROJECTION row exists after its receipt-carrying CSI
  commits. Without the store the capture guard was never reached.
- ToolPublicationStoreTest marks the Session private (through
  tool_profile or runtime_request_key, since csi_guard is generated)
  before claiming, asserts the claim is empty and the row untouched,
  then clears the marker and claims the same row.

Decision 5 of the automation runtime design said a scanner whose lease
expired records and settles nothing. Occurrence writes check owner and
fence, never lease_until, so they are refused only once another claim
moves the fence. Both language versions now say so, matching the
AutomationLedgerStore Javadoc corrected in #16000.
2026-10-31 20:28:07 +08:00
Shaojin Wen c726d2cfc1 docs(managed-agent): close the D1 prose finding; record the reconciliation prerequisite (#16012)
Carry forward the R3-1 review fix that was verified but not pushed before
#15997 merged: the D1 structural-debt prose now matches the table (the
observation cooldown and tool-hold inventory are named as extracted
collaborators, not as concerns still mixed into the class), in both
languages, and the service's hold-inventory javadoc defers the detailed
discharge invariant to RuntimeHoldInventory instead of duplicating it.

Also record the reconciliation-cluster investigation: reconciliation,
recovery scanning and cancellation share the service's three concurrent
maps, binding-lifecycle helpers and CAS settle, so none is a clean
standalone seam. The design now names the prerequisite — a shared-state
registry that owns the maps and lifecycle helpers — to be designed and
reviewed on its own before those paths can be extracted, and before the
M6 stamp-before-slot-drop ordering becomes a pinnable seam-level contract.
2026-10-31 19:18:39 +08:00
Shaojin Wen 1515428391 fix(web-shell): proxy the PR watches route in dev (#16004)
* fix(web-shell): proxy the PR watches route in dev

The dev server's proxy table had no entry for the autofix-pr routes, so
`GET /autofix-pr/watches` fell through to the SPA fallback and answered
with index.html. The PR watches page then failed JSON parsing on open
and reported "Unexpected token '<'". Only the primary workspace's
unqualified route was affected: the workspace-qualified form is already
reached by the `/workspace` prefix entry.

Production is unaffected because the daemon serves the page and the API
from the same origin, with no dev proxy in between. The browser suites
did not catch it because they stub the route at the network layer, which
intercepts the request before it reaches the dev server.

* test(web-shell): fold the PR watches proxy case into the coverage table

Review feedback on #16004: the standalone block repeated the existing
daemon-API coverage table's lookup, request shape and bypass assertion,
and pinned the entry to the Goals route by reference identity. That pin
was arbitrary — nothing requires the two routes to share one object — so
re-objecting Goals with the spread idiom this file already uses elsewhere
would have reddened the PR-watches case while the route itself stayed
correct, pointing a future reader at the wrong entry.

The case is now a row in the existing table, which keeps one enumerated
list of the primary prefixes the dev proxy must serve. Each row carries
the URL the daemon actually serves rather than a derived one. The
load-bearing assertions are unchanged: deleting the proxy entry still
reds exactly this row, and re-objecting Goals no longer affects it.
2026-10-31 11:15:30 +00:00
Shaojin Wen 35daec35ea fix(verify-pr): keep the verdict line a plain paragraph and check the comment footer (#16002)
* fix(verify-pr): keep the verdict line a plain paragraph and check the comment footer

Three verification rounds on PR #15984 shipped their opening verdict as a
single Markdown heading: the round title and the whole ~950-character
verdict paragraph shared one `#` line, which GitHub renders as one giant
H1 — a wall of bold text instead of a readable lead. All three comments
also went out without the attribution footer the publish contract
requires.

The skill only said "verdict line first" without pinning the Markdown
shape, and the consistency checker only checked that the verdict word
and head OID appear in the first ten lines, so both defects passed every
gate.

- The skill now pins the verdict line to a plain paragraph (a round
  title may lead it as plain text, never as a heading), with the
  measured #15984 example and a template.
- Local rounds assemble the exact posted body — report content plus the
  resolved footer — into comment.md, post that file, and read the
  comment back against it byte for byte; report.md stays footer-free by
  contract.
- The checker warns when the lead line is a heading carrying the
  verdict or an over-long heading (GFM indents of up to three spaces
  included), and when comment.md lacks a well-formed attribution
  footer. Warnings, not errors: a cosmetic defect must not make the CI
  publisher refuse an otherwise consistent verdict.
- Pin tests reproduce the shipped shape from the real round-3 comment,
  and the workflow meta-test pins the new skill text.

* fix(verify-pr): accept CRLF line endings in the comment-body checks

Review of #16002 reproduced a Windows-authored comment.md failing the
anchored footer regex: splitting only on \n left a stray \r on the
footer line, so a valid attribution was reported missing and the local
publish gate sent the artifact back for unnecessary repair. GitHub
renders CRLF and LF identically.

Normalize CRLF (and lone CR) to LF before the line-based checks on
report.md and comment.md, and pin LF/CRLF parity: the same valid footer
passes under both endings, a missing footer and a heading lead still
warn under CRLF, and the reviewer's CLI probe is covered end to end.
2026-10-31 11:02:46 +00:00
Shaojin Wen d811674c55 fix(verify-pr): keep each verify round in its own comment (#15988)
* fix(verify-pr): keep each verify round in its own comment

Verify jobs are serialized per PR, but their publishers queue on hosted
runners, so a round's live status is often still "running" when the
next round's verify job starts. The status step took that comment over
and both publishers then wrote into it, so one round's report replaced
the other's. A cancelled pending run's notice could likewise replace
another round's live status.

The status comment now carries a run marker. The status step reuses a
live status only when it is this run's own (a re-run attempt), and the
publisher writes only into that status, posting fresh otherwise.
WEAK_BODY no longer decides anything and is removed.

Also, from the #15966 follow-ups: evidence images are collected from
the round the comment publishes instead of from every round, oldest
first, and the pre-agent sweep comment no longer describes the
head -1 ordering.

* fix(verify-pr): let a re-run publisher find its own comment

The publisher looked only for this run's live status, but the body it
publishes carries neither the running marker nor the run marker. A
publish job re-run after it had posted therefore found nothing and
posted the report a second time. Every terminal body now keeps the run
marker right after the leading verify marker, and the publisher matches
its own run's comment whether it is still the live status or already
the report. The status step still reuses only a live status.

Tests now pin the status body's leading markers in order, use a re-run
attempt number distinct from the run id, check the run marker in every
terminal body, and check that the evidence skip count comes from the
published round.

* fix(verify-pr): never let a notice replace a published report

A publish job re-run on its own downloads its own attempt's artifact,
which the verify job never uploaded, so it always renders "results
unavailable". Since the previous commit matched this run's comment
whatever it held, that notice replaced the round's report. The lookup
now also reports whether this run's comment carries findings, and a
body without findings is posted beside it instead. A notice may still
replace this run's live status or its earlier notice.

Every body now opens with a header written once, before the branches:
the verify marker, the run marker and, when the body carries findings,
the substantive marker, ended by a blank line. The build-failure body's
substantive marker moves up into it. Both lookups that choose a comment
to write into read markers only from the header, so a marker quoted
further down a body is text.

Tests pin the notice/report gate in both directions, the header of every
terminal body and that it is written once, header-only matching in both
lookups, and that the status step does not reuse this run's report.

* fix(verify-pr): read the substantive marker from the header for the snapshot

The previous-round snapshot was the last lookup still matching its
marker anywhere in a body, so a newer notice quoting the substantive
marker below its header would have been snapshotted in place of the
newest real report. It now reads the header like the other lookups.
Build-failure reports posted before the header change carry the marker
below their header and are skipped by the snapshot during the
transition.

Tests add that notice shape to the snapshot case, and render the
reportless body to check it carries no substantive marker.
2026-10-31 18:56:36 +08:00
Shaojin Wen 39fd1b45ba refactor(managed-agent): extract the runtime hold inventory (#15997)
* refactor(managed-agent): extract the runtime hold inventory

Second P1 seam in RuntimeBrokerService: the durable hold inventory moves
to a package-private RuntimeHoldInventory. The pagination over hold rows
and the RELEASED filter (restore the close's drain obligation, never
discharge — #13930 R1-2) move verbatim; the service keeps the admission,
tenant resolution and error-mapping boundary, so the stage assembly and
the runtime_hold_inventory_scope_failed mapping are unchanged. Six new
unit tests cover the collaborator (empty ledger, released discharge,
missing-record-is-not-release, non-released still holds, cursor
pagination, dedup); the 199-test broker suite and the binding inventory
suite stay green.

Recovery scanning was investigated as the next seam and proved entangled
with reconcileInContext, the SessionContext in-memory index and the CAS
settle loop; the design records why it waits for the reconciliation
collaborator rather than forcing a callback-heavy extraction.

* docs(managed-agent): record the hold inventory in the D1 ledger; pin RELEASING

Close the review's D1-table consistency finding: the structural-debt
table's RuntimeBrokerService row now names both extracted seams
(ExecutionObservationCooldown, RuntimeHoldInventory) and refreshes the
line count to the current 6,329, in both languages, so the ledger matches
the slice narrative instead of drifting per extraction.

Also add the round-2 deferred probe as a test: a RELEASING session still
holds (only RELEASED discharges the drain obligation). A widened
discharge filter (!= READY) turns the new test red — verified by
injection with byte-level restore. 224 focused tests and Checkstyle stay
green.
2026-10-31 10:56:14 +00:00
Shaojin Wen 43415ed8a4 docs(managed-agent): note the private CSI receipt exception and correct lease wording (#16000)
The artifacts.enabled row said committed tool receipts are always
recorded for projection. Sessions created through the private CSI lane
(csi-session) are the exception: ManagedSessionStore records receipts
only when the commit has no CSI original, and the projection claim skips
csi_guard Sessions as well.

The automation.lease row now counts the lease from the claim of each
definition rather than from a scanner tick, matching the scanner-threads
row. The AutomationLedgerStore Javadoc no longer says an expired lease
alone stops a writer: its writes check owner and fence, so they are
refused only once another writer claims the definition.
2026-10-31 18:14:56 +08:00